Implementing Search action in AppBar

Posted on February 06, 2019 in Flutter

Search in AppBar

In today's recipe, I'll show you how search action can be integrated at top of the page. I'll be using Flutter's SearchDelegate component to achieve this. It comes packed with support for populating suggestions in search bar, adding actions items to the right side of the search bar. I'm using dart's library english_words to populate the list of words.

Target Audience: Beginner

Recipe: Implementing Search action in AppBar using Flutter for Android and iOS mobile apps.

Focus Widget: SearchDelegate

Goal: In this recipe, I'll show you : 1. How to populate sorted english word list in app and search for a given word using Search bar at the top of the application. 2. How to build suggestion list for search bar. 3. How to add actions like "clear" to reset search bar, and a dummy action "mic" for voice input.

Sorted Wordlist Search in Wordlist

Checkout in action:

Lets's go!

Step #0: Create "Flutter Application" project in Android Studio.

Step #1. Get dependencies. First thing to add english_words dependency in pubspec.yaml to be able to populate English words in our recipe app's word list.

dependencies:
  flutter:
    sdk: flutter
  # This is needed to populate English words in app's word list.
  english_words: ^3.1.3

Step #2. Display Word list To display English word list as list in app, first thing is to import the dependency in main.dart file:

import 'package:english_words/english_words.dart' as words;

Step #3. Initialize word list in SearchAppBarRecipeState class. Now, in the _SearchAppBarRecipeState class, fetch the list of words like below. Make sure that you've a list data structure to hold word list. I'm using kWords for this purpose.

class _SearchAppBarRecipeState extends State<SeachAppBarRecipe> {
  //Data structure to hold word list
  final List<String> kWords;

  ...

    //Initializing kWords list with data fetched from english_words library
  _SearchAppBarRecipeState()
      : kWords = List.from(Set.from(words.all)),
        super();

  ...
}

Step #4. Sorting word list. You'll notice at this point that data is un-sorted at this point. Let's make it alphabetically sorted as below:

class _SearchAppBarRecipeState extends State<SeachAppBarRecipe> {
  final List<String> kWords;
  ...

  //Initializing with sorted list of english words
  _SearchAppBarRecipeState()
      : kWords = List.from(Set.from(words.all))
    ..sort(
          (w1, w2) => w1.toLowerCase().compareTo(w2.toLowerCase()),
    ),
        super();
   ...
}

Step #5. Setup SearchDelegate to implement Search AppBar. Search delegate is where all the magic happens. It holds two list of words. One list is of the general word list passed during initialization. Another list _history contains the list of history words. You can pre-populate _history list to give a baseline for history search items. This list will be shown when user clicks on the search bar.

class _SearchAppBarDelegate extends SearchDelegate<String> {
  //list holds the full word list
  final List<String> _words;

  //list holds history search words.
  final List<String> _history;

    //initialize delegate with full word list and history words
  _SearchAppBarDelegate(List<String> words)
      : _words = words,
  //pre-populated history of words
        _history = <String>['apple', 'orange', 'banana', 'watermelon'],
        super();

  ...
}

Step #6. After setting up words list options in delegate, now its time to pick up an icon that will be placed at the left side of search bar. Mostly, this icon menu is meant for navigating back to previous screen.

class _SearchAppBarDelegate extends SearchDelegate<String> {
  ...

    // Setting leading icon for the search bar.
    //Clicking on back arrow will take control to main page
    @override
    Widget buildLeading(BuildContext context) {
      return IconButton(
        tooltip: 'Back',
        icon: AnimatedIcon(
          icon: AnimatedIcons.menu_arrow,
          progress: transitionAnimation,
        ),
        onPressed: () {
          //Take control back to previous page
          this.close(context, null);
        },
      );
    }

  ...
}

Alright we got the icon for search bar ! What next ?

Step #7. Now it's time to implement buildResults method to show searched item. This is where search query this.query will display searched item. For sake of simplicity, I'll be adding two widgets only. First widget is Text and will display message '===Your Word Choice==='. Second widget is GestureDetector which has it's another Text widget as its child. Tapping on the Text widget will take control back to previous page along with this.query parameter to display the searched word in main page.

class _SearchAppBarDelegate extends SearchDelegate<String> {
  ...

  //Builds page to populate search results.
  @override
  Widget buildResults(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Text('===Your Word Choice==='),
            GestureDetector(
              onTap: () {
                //Define your action when clicking on result item.
                //In this example, it simply closes the page
                this.close(context, this.query);
              },
              child: Text(
                this.query,
                style: Theme.of(context)
                    .textTheme
                    .display2
                    .copyWith(fontWeight: FontWeight.normal),
              ),
            ),
          ],
        ),
      ),
    );
  }

  ...
}

Step #8. Next, it's time for implementing buildSuggestions method of SearchDelegate class. So, what does building suggestions mean ? This is the list of suggestions for words that you'll see when typing your search query in search bar. There're two parts of this. First, we've to pass the list of words that could be displayed as suggestions. Second, we need to implement the UI widget to display this suggestion list.

For data part, we display pre-populated history words when query term is empty. When user starts typing then we show all the words that starts with search terms entered in search bar. That's it !

class _SearchAppBarDelegate extends SearchDelegate<String> {
  ...

  // Suggestions list while typing search query - this.query.
  @override
  Widget buildSuggestions(BuildContext context) {
    final Iterable<String> suggestions = this.query.isEmpty
        ? _history
        : _words.where((word) => word.startsWith(query));

        ...
  }

  ...
}

It's UI widget's turn now. As you can see buildSuggestions method returns a Widget. We'll make a separate class say _WordSuggestionList to populate suggestion list widget and will return it from buildSuggestions. _WordSuggestionList is going to be a StatelessWidget, and has three things: First, list of suggestions. Second, search query and last a callback say ValueChanged<String> onSelected. It'll be ListView of size of number of suggestions provided. Each suggestion is displayed in ListTile widget. This widget will show history icon for items fetched from pre-populated history words only. Text for widget will be updated as user is typing the search query.

class _WordSuggestionList extends StatelessWidget {
  const _WordSuggestionList({this.suggestions, this.query, this.onSelected});

  final List<String> suggestions;
  final String query;
  final ValueChanged<String> onSelected;

  @override
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context).textTheme.subhead;
    return ListView.builder(
      itemCount: suggestions.length,
      itemBuilder: (BuildContext context, int i) {
        final String suggestion = suggestions[i];
        return ListTile(
          leading: query.isEmpty ? Icon(Icons.history) : Icon(null),
          // Highlight the substring that matched the query.
          title: RichText(
            text: TextSpan(
              text: suggestion.substring(0, query.length),
              style: textTheme.copyWith(fontWeight: FontWeight.bold),
              children: <TextSpan>[
                TextSpan(
                  text: suggestion.substring(query.length),
                  style: textTheme,
                ),
              ],
            ),
          ),
          onTap: () {
            onSelected(suggestion);
          },
        );
      },
    );
  }
}

Let's plug-in _WordSuggestionList back into buildSuggestions method:

class _SearchAppBarDelegate extends SearchDelegate<String> {
  ...

  // Suggestions list while typing search query - this.query.
  @override
  Widget buildSuggestions(BuildContext context) {
    final Iterable<String> suggestions = this.query.isEmpty
        ? _history
        : _words.where((word) => word.startsWith(query));

    //calling wordsuggestion list    
    return _WordSuggestionList(
          query: this.query,
          suggestions: suggestions.toList(),
          onSelected: (String suggestion) {
            this.query = suggestion;
            this._history.insert(0, suggestion);
            showResults(context);
          },
  }

  ...
}

Step #9. Building actions in SearchBar. Last step is to add action menus in SearchBar's right side. I'll be adding two menu actions. One is "Clear" icon to clear search query from search bar. Another is a "mic" icon, that could be improved to accept voice input, which is beyond the scope of this recipe. However, if you would like me to write about another recipe for accepting voice input, feel free to contact me as mentioned at the end of this post.

class _SearchAppBarDelegate extends SearchDelegate<String> {
  ...

// Action buttons at the right of search bar.
  @override
  List<Widget> buildActions(BuildContext context) {
    return <Widget>[
      query.isNotEmpty ?
      IconButton(
        tooltip: 'Clear',
        icon: const Icon(Icons.clear),
        onPressed: () {
          query = '';
          showSuggestions(context);
        },
      ) : IconButton(
        icon: const Icon(Icons.mic),
        tooltip: 'Voice input',
        onPressed: () {
          this.query = 'TBW: Get input from voice';
        },

      ),
    ];
  }

  ...
}

Step #10. Finally, call Search Delegate from SearchAppBarRecipeState. After creating SearchDelegate class, call it from _SearchAppBarRecipeState class and initialize it in initState.

class _SearchAppBarRecipeState extends State<SeachAppBarRecipe> {
    final List<String> kWords;

    //Calling search delegate class
    _SearchAppBarDelegate _searchDelegate;
    ...

    @override
    void initState() {
       super.initState();
       //Initializing search delegate with sorted list of English words
       _searchDelegate = _SearchAppBarDelegate(kWords);
    }

    ...
}

That's it !

Complete example code

import 'package:flutter/material.dart';
import 'package:english_words/english_words.dart' as words;

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SeachAppBarRecipe',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SeachAppBarRecipe(title: 'SeachAppBarRecipe'),
    );
  }
}

class SeachAppBarRecipe extends StatefulWidget {
  SeachAppBarRecipe({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _SearchAppBarRecipeState createState() => _SearchAppBarRecipeState();
}

class _SearchAppBarRecipeState extends State<SeachAppBarRecipe> {
  final List<String> kWords;
  _SearchAppBarDelegate _searchDelegate;

  //Initializing with sorted list of english words
  _SearchAppBarRecipeState()
      : kWords = List.from(Set.from(words.all))
    ..sort(
          (w1, w2) => w1.toLowerCase().compareTo(w2.toLowerCase()),
    ),
        super();


  @override
  void initState() {
    super.initState();
    //Initializing search delegate with sorted list of English words
    _searchDelegate = _SearchAppBarDelegate(kWords);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        automaticallyImplyLeading: false,
        title: Text('Word List'),
        actions: <Widget>[
          //Adding the search widget in AppBar
          IconButton(
            tooltip: 'Search',
            icon: const Icon(Icons.search),
            //Don't block the main thread
            onPressed: () {
              showSearchPage(context, _searchDelegate);
            },
          ),
        ],
      ),
      body: Scrollbar(
        //Displaying all English words in list in app's main page
        child: ListView.builder(
          itemCount: kWords.length,
          itemBuilder: (context, idx) =>
              ListTile(
                title: Text(kWords[idx]),
                onTap: () {
                  Scaffold.of(context).showSnackBar(
                      SnackBar(
                          content: Text("Click the Search action"),
                          action: SnackBarAction(
                            label: 'Search',
                            onPressed: (){
                              showSearchPage(context, _searchDelegate);
                            },
                          )
                      )
                  );
                },
              ),
        ),
      ),
    );
  }

  //Shows Search result
  void showSearchPage(BuildContext context,
      _SearchAppBarDelegate searchDelegate) async {
    final String selected = await showSearch<String>(
      context: context,
      delegate: searchDelegate,
    );

    if (selected != null) {
      Scaffold.of(context).showSnackBar(
        SnackBar(
          content: Text('Your Word Choice: $selected'),
        ),
      );
    }
  }
}

//Search delegate
class _SearchAppBarDelegate extends SearchDelegate<String> {
  final List<String> _words;
  final List<String> _history;

  _SearchAppBarDelegate(List<String> words)
      : _words = words,
  //pre-populated history of words
        _history = <String>['apple', 'orange', 'banana', 'watermelon'],
        super();

  // Setting leading icon for the search bar.
  //Clicking on back arrow will take control to main page
  @override
  Widget buildLeading(BuildContext context) {
    return IconButton(
      tooltip: 'Back',
      icon: AnimatedIcon(
        icon: AnimatedIcons.menu_arrow,
        progress: transitionAnimation,
      ),
      onPressed: () {
        //Take control back to previous page
        this.close(context, null);
      },
    );
  }

  // Builds page to populate search results.
  @override
  Widget buildResults(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Text('===Your Word Choice==='),
            GestureDetector(
              onTap: () {
                //Define your action when clicking on result item.
                //In this example, it simply closes the page
                this.close(context, this.query);
              },
              child: Text(
                this.query,
                style: Theme.of(context)
                    .textTheme
                    .display2
                    .copyWith(fontWeight: FontWeight.normal),
              ),
            ),
          ],
        ),
      ),
    );
  }

  // Suggestions list while typing search query - this.query.
  @override
  Widget buildSuggestions(BuildContext context) {
    final Iterable<String> suggestions = this.query.isEmpty
        ? _history
        : _words.where((word) => word.startsWith(query));

    return _WordSuggestionList(
      query: this.query,
      suggestions: suggestions.toList(),
      onSelected: (String suggestion) {
        this.query = suggestion;
        this._history.insert(0, suggestion);
        showResults(context);
      },
    );
  }

  // Action buttons at the right of search bar.
  @override
  List<Widget> buildActions(BuildContext context) {
    return <Widget>[
      query.isNotEmpty ?
      IconButton(
        tooltip: 'Clear',
        icon: const Icon(Icons.clear),
        onPressed: () {
          query = '';
          showSuggestions(context);
        },
      ) : IconButton(
        icon: const Icon(Icons.mic),
        tooltip: 'Voice input',
        onPressed: () {
          this.query = 'TBW: Get input from voice';
        },

      ),
    ];
  }
}

// Suggestions list widget displayed in the search page.
class _WordSuggestionList extends StatelessWidget {
  const _WordSuggestionList({this.suggestions, this.query, this.onSelected});

  final List<String> suggestions;
  final String query;
  final ValueChanged<String> onSelected;

  @override
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context).textTheme.subhead;
    return ListView.builder(
      itemCount: suggestions.length,
      itemBuilder: (BuildContext context, int i) {
        final String suggestion = suggestions[i];
        return ListTile(
          leading: query.isEmpty ? Icon(Icons.history) : Icon(null),
          // Highlight the substring that matched the query.
          title: RichText(
            text: TextSpan(
              text: suggestion.substring(0, query.length),
              style: textTheme.copyWith(fontWeight: FontWeight.bold),
              children: <TextSpan>[
                TextSpan(
                  text: suggestion.substring(query.length),
                  style: textTheme,
                ),
              ],
            ),
          ),
          onTap: () {
            onSelected(suggestion);
          },
        );
      },
    );
  }
}

Source code repo: Recipe source code is available here

References:

  1. Search Delegate
  2. Flutter Gallery
  3. BuildActions
  4. BuildResults

Happy cooking with Flutter :)

Liked the article ? Couldn't find a topic of your interest ? Please leave comments or email me about topics you would like me to write ! BTW I love cupcakes and coffee both :)