Guides / Building Search UI / Getting started

Getting started with Flutter Helper

This guide explains how to build a multi-platform search experience using Flutter and the Algolia Flutter Helper library. This search experience will include:

  • A search box to type your query
  • Statistics about the current search
  • A list to display search results with infinite scrolling
  • A refinement list for filtering results

Prepare your project

Before you can use Algolia, you need an Algolia account. You can create a new one or use the following credentials (for a preloaded dataset of products appropriate for this guide):

  • Application ID: latency
  • Search API Key: 927c3fe76d4b52c5a2912973f35a3077
  • Index name: STAGING_native_ecom_demo_products

Create a new app

Start by creating a new app. In a terminal, run:

1
flutter create algoliasearch

Add project dependencies

This tutorial uses the Algolia Flutter Helper library to integrate Algolia and the Infinite Scroll Pagination library for infinite scrolling. Add algolia_helper_flutter and infinite_scroll_pagination as dependencies to your project. Update the pubspec.yaml file:

1
2
3
dependencies:
  algolia_helper_flutter: ^1.0.0
  infinite_scroll_pagination: ^3.2.0

In a terminal, run:

1
flutter pub get

Create a basic search interface

As a first step, build a basic search interface with a search box and a search metadata panel for showing the number of search results.

Open ./lib/main.dart and look for the _MyHomePageState class. Remove its sample variables and method declarations (_counter, _incrementCounter), then import the Flutter Helper library:

1
import 'package:algolia_helper_flutter/algolia_helper_flutter.dart';

Then, add the _productsSearcher property of the HitsSearcher type with your Algolia credentials as parameters. The HitsSearcher component performs search requests and obtains search results.

1
2
3
final _productsSearcher = HitsSearcher(applicationID: 'latency',
                                        apiKey: '927c3fe76d4b52c5a2912973f35a3077',
                                        indexName: 'STAGING_native_ecom_demo_products');

Add the _searchTextController property to _MyHomePageState. It controls and listens to the state of the TextField component you use as the search box.

1
final _searchTextController = TextEditingController();

Add a SearchMetadata class with the metadata of the latest search. In this example, it only contains the nbHits value, which is the number of search results. The SearchMetadata class also has a fromResponse factory method which extracts the nbHits value from the SearchResponse.

1
2
3
4
5
6
7
8
class SearchMetadata {
  final int nbHits;

  const SearchMetadata(this.nbHits);

  factory SearchMetadata.fromResponse(SearchResponse response) =>
      SearchMetadata(response.nbHits);
}

Add the _searchMetadata stream which listens to _productSearcher responses and transforms them to SearchMetaData instance.

1
Stream<SearchMetadata> get _searchMetadata => _productsSearcher.responses.map(SearchMetadata.fromResponse);

Override the build method containing the user interface declaration. The interface is based on the Scaffold component. Add the AppBar with “Algolia & Flutter” as its title, and the Column component as its body:

1
2
3
4
5
6
7
8
9
10
11
12
13
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('Algolia & Flutter'),
    ),
    body: Center(
      child: Column(
        children: <Widget>[],
      ),
    ),
  );
}

The Column’s body will consist of three children: the search box, the metadata panel and the hits list. Start with adding a search box.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('Algolia & Flutter'),
    ),
    body: Center(
      child: Column(
        children: <Widget>[
          SizedBox(
            height: 44,
            child: TextField(
              controller: _searchTextController,
              decoration: const InputDecoration(
                border: InputBorder.none,
                hintText: 'Enter a search term',
                prefixIcon: Icon(Icons.search),
              ),
            )),
        ],
      ),
    ),
  );
}

Save your changes in the main.dart file. Build and run your application by running flutter run in a terminal or your development tool. In the simulator, you should see the app bar with title and the search box below. Add a Text widget embedded in Padding and StreamBuilder widgets alternately to show the search metadata. StreamBuilder widget ensures update of the Text on each _searchMetadata stream change.

1
2
3
4
5
6
7
8
9
10
11
12
StreamBuilder<SearchMetadata>(
  stream: _searchMetadata,
  builder: (context, snapshot) {
    if (!snapshot.hasData) {
      return const SizedBox.shrink();
    }
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Text('${snapshot.data!.nbHits} hits'),
    );
  },
)

Add the previously defined StreamBuilder as the second child of the main Column widget.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('Algolia & Flutter'),
    ),
    body: Center(
      child: Column(
        children: <Widget>[
          SizedBox(
            height: 44,
            child: TextField(
              controller: _searchTextController,
              decoration: const InputDecoration(
                border: InputBorder.none,
                hintText: 'Enter a search term',
                prefixIcon: Icon(Icons.search),
              ),
            )),
            StreamBuilder<SearchMetadata>(
              stream: _searchMetadata,
              builder: (context, snapshot) {
                if (!snapshot.hasData) {
                  return const SizedBox.shrink();
                }
                return Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: Text('${snapshot.data!.nbHits} hits'),
                );
              },
            )
        ],
      ),
    ),
  );
}

Build and run the application. You might see the centered text with the hits count below the search box. If you type something in the search box, the displayed value remains unchanged. This happens because the _searchTextController and _productsSearcher aren’t connected. To fix it, override the initState method of the _MyHomePageState class and add a listener to the _searchTextController that propagates the input text to the _productsSearcher.

1
2
3
4
void initState() {
  super.initState();
  _searchTextController.addListener(() => _productsSearcher.query(_searchTextController.text));
}

Build and run the application. The search metadata panel now updates dynamically on each change of the search box.

Step1

To free up resources, dispose the _searchTextController and _productsSearcher by overriding the dispose method of the _MyHomePageState.

1
2
3
4
5
6
@override
void dispose() {
  _searchTextController.dispose();
  _productsSearcher.dispose();
  super.dispose();
}

Results list and infinite scrolling

Now it’s time to show the results themselves and their number with infinite scrolling.

Add a Product class that represents a search hit. To keep this example simple, it contains a name and an image URL field. Declare a fromJson constructor method for creating Product from a JSON string.

1
2
3
4
5
6
7
8
9
10
class Product {
  final String name;
  final String image;

  Product(this.name, this.image);

  static Product fromJson(Map<String, dynamic> json) {
    return Product(json['name'], json['image_urls'][0]);
  }
}

Import the infinite_scroll_pagination library.

1
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';

Add the _pagingController component that handles the infinite scrolling logic as _MyHomePageState class property.

1
final PagingController<int, Product> _pagingController = PagingController(firstPageKey: 0);

Declare the HitsPage class, which represents a page of search results. Call the fromResponse factory method which builds a HitsPage from a SearchResponse.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class HitsPage {
  const HitsPage(this.items, this.pageKey, this.nextPageKey);

  final List<Product> items;
  final int pageKey;
  final int? nextPageKey;

  factory HitsPage.fromResponse(SearchResponse response) {
    final items = response.hits.map(Product.fromJson).toList();
    final isLastPage = response.page >= response.nbPages;
    final nextPageKey = isLastPage ? null : response.page + 1;
    return HitsPage(items, response.page, nextPageKey);
  }
}

Add the _searchPage stream which listens to _productSearcher responses and transforms it to HitsPage object.

1
Stream<HitsPage> get _searchPage => _productsSearcher.responses.map(HitsPage.fromResponse);

Add the _hits function to the _MyHomePageState class which builds the list of search results. It returns the PagedListView, a component of the infinite_scroll_pagination library, taking _pagingController as parameter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Widget _hits(BuildContext context) => PagedListView<int, Product>(
  pagingController: _pagingController,
  builderDelegate: PagedChildBuilderDelegate<Product>(
      noItemsFoundIndicatorBuilder: (_) => const Center(
            child: Text('No results found'),
          ),
      itemBuilder: (_, item, __) => Container(
            color: Colors.white,
            height: 80,
            padding: const EdgeInsets.all(8),
            child: Row(
              children: [
                SizedBox(width: 50, child: Image.network(item.image)),
                const SizedBox(width: 20),
                Expanded(child: Text(item.name))
              ],
            ),
          )));

Add the results of the _hits function as the third child of the main Column widget embedded in the Expanded widget so that it can fill the available screen space.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Column(children: <Widget>[
  SizedBox(
      height: 44,
      child: TextField(
        controller: _searchTextController,
        decoration: const InputDecoration(
          border: InputBorder.none,
          hintText: 'Enter a search term',
          prefixIcon: Icon(Icons.search),
        ),
      )),
  StreamBuilder<SearchMetadata>(
    stream: _searchMetadata,
    builder: (context, snapshot) {
      if (!snapshot.hasData) {
        return const SizedBox.shrink();
      }
      return Padding(
        padding: const EdgeInsets.all(8.0),
        child: Text('${snapshot.data!.nbHits} hits'),
      );
    },
  ),
  Expanded(
    child: _hits(context),
  )
],)

Build and run the application. You can now see the loading indicator instead of search results.

Step2

This happens because the _pagingController and the _productsSearcher aren’t connected. To update the _pagingController whenever a new results page is fetched, add a listener to __searchPage in the initState method. Add a call to _pagingController.refresh() to the _searchTextController listener callback.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@override
void initState() {
  super.initState();
  _searchTextController.addListener(
    () => _productsSearcher.applyState(
      (state) => state.copyWith(
        query: _searchTextController.text,
        page: 0,
      ),
    ),
  );
  _searchPage.listen((page) {
    if (page.pageKey == 0) {
      _pagingController.refresh();
    }
    _pagingController.appendPage(page.items, page.nextPageKey);
  }).onError((error) => _pagingController.error = error);
}

Build and run the application. Now it displays the list of search results. Scroll it to bottom. Instead of the subsequent results page the loading indicator appears.

Step3

Although _pagingController triggered a request for next page, this request wasn’t processed. To fix it, complete the initState method by adding a page request listener to _pagingController. It triggers the loading of the next page in the _productSearcher.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@override
void initState() {
  super.initState();
  _searchTextController.addListener(
    () => _productsSearcher.applyState(
      (state) => state.copyWith(
        query: _searchTextController.text,
        page: 0,
      ),
    ),
  );
  _searchPage.listen((page) {
    if (page.pageKey == 0) {
      _pagingController.refresh();
    }
    _pagingController.appendPage(page.items, page.nextPageKey);
  }).onError((error) => _pagingController.error = error);
  _pagingController.addPageRequestListener(
    (pageKey) => _productsSearcher.applyState(
        (state) => state.copyWith(
          page: pageKey,
        )
    )
  );
}

Build and run the application. Now infinite scrolling is working as expected.

Step4

You now get the basic search experience with search box, metadata, and results. Consider disposing the _pagingController in the dispose method of the _MyHomePageState to free up the resources properly.

1
2
3
4
5
6
7
@override
void dispose() {
  _searchTextController.dispose();
  _productSearcher.dispose();
  _pagingController.dispose();
  super.dispose();
}

Implement results filtering

Now you can add an extra screen to implement filtering of the search results.

Start implementing search results filtering by adding a FilterState property to _MyHomePageState. FilterState is a component that stores the state of applied filters and provides an interface to alter the state.

1
final _filterState = FilterState();

Add the FacetList property which manages the appearance of the list of refinement facets for a designated attribute. In this guide, the brand attribute is used.

1
2
3
4
late final _facetList = _productsSearcher.buildFacetList(
  filterState: _filterState,
  attribute: 'brand',
);

Add the _filters method to present the filtering interface as a list of CheckboxListTiles embedded in the Scaffold widget. The FacetList class provides a facets stream, combining the facets themselves and their selection state as well as a toggle method that allows to change this state.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Widget _filters(BuildContext context) => Scaffold(
  appBar: AppBar(
    title: const Text('Filters'),
  ),
  body: StreamBuilder<List<SelectableItem<Facet>>>(
      stream: _facetList.facets,
      builder: (context, snapshot) {
        if (!snapshot.hasData) {
          return const SizedBox.shrink();
        }
        final selectableFacets = snapshot.data!;
        return ListView.builder(
            padding: const EdgeInsets.all(8),
            itemCount: selectableFacets.length,
            itemBuilder: (_, index) {
              final selectableFacet = selectableFacets[index];
              return CheckboxListTile(
                value: selectableFacet.isSelected,
                title: Text(
                    "${selectableFacet.item.value} (${selectableFacet.item.count})"),
                onChanged: (_) {
                  _facetList.toggle(selectableFacet.item.value);
                },
              );
            });
      }),
);

To present the filters screen in the end drawer, add a GlobalKey property to the _MyHomePageState class.

1
final GlobalKey<ScaffoldState> _mainScaffoldKey = GlobalKey();
  • Assign this key to the key property of Scaffold in its constructor.
  • Add IconButton to the actions list of the AppBar. This triggers opening the end drawer.
  • Assign the endDrawer property of Scaffold with filters the widget embedded in the Drawer widget.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@override
Widget build(BuildContext context) {
  return Scaffold(
    key: _mainScaffoldKey,
    appBar: AppBar(
      title: const Text('Algolia & Flutter'),
      actions: [
        IconButton(
            onPressed: () => _mainScaffoldKey.currentState?.openEndDrawer(),
            icon: const Icon(Icons.filter_list_sharp))
      ],
    ),
    endDrawer: Drawer(
      child: _filters(context),
    ),
    body: (/* ... */),
  );
}

Build and run the application. The app bar now displays the filters button which shows the list of facet values (individual brands) for the brand attribute.

Step5

Step6

A selection of these values doesn’t affect the search results. To fix it, connect FilterState to HitsState in the initState method, so that each change of FilterState triggers a new search request. Also, each filter state change might refresh the _pagingController to remove the obsolete loaded pages. Add the corresponding listener to the _filterState.filters stream.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@override
void initState() {
  super.initState();
  _searchTextController.addListener(
    () => _productsSearcher.applyState(
      (state) => state.copyWith(
        query: _searchTextController.text,
        page: 0,
      ),
    ),
  );
  _searchPage.listen((page) {
    if (page.pageKey == 0) {
      _pagingController.refresh();
    }
    _pagingController.appendPage(page.items, page.nextPageKey);
  }).onError((error) => _pagingController.error = error);
  _pagingController.addPageRequestListener(
    (pageKey) => _productsSearcher.applyState(
        (state) => state.copyWith(
          page: pageKey,
        )
    )
  );
  _productsSearcher.connectFilterState(_filterState);
  _filterState.filters.listen((_) => _pagingController.refresh());
}

Build and run the application. The selection/deselection of the brand in the facet list now triggers a new search with applied filters.

Don’t forget to dispose the _filterState and ‘_facetList’ in the dispose method of the _MyHomePageState.

1
2
3
4
5
6
7
8
9
@override
void dispose() {
  _searchTextController.dispose();
  _productSearcher.dispose();
  _pagingController.dispose();
  _filterState.dispose();
  _facetList.dispose();
  super.dispose();
}

The final result

The final version of the main.dart file should look as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
import 'package:algolia_helper_flutter/algolia_helper_flutter.dart';
import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';

void main() {
  runApp(const MyApp());
}

class SearchMetadata {
  final int nbHits;

  const SearchMetadata(this.nbHits);

  factory SearchMetadata.fromResponse(SearchResponse response) =>
      SearchMetadata(response.nbHits);
}

class Product {
  final String name;
  final String image;

  Product(this.name, this.image);

  static Product fromJson(Map<String, dynamic> json) {
    return Product(json['name'], json['image_urls'][0]);
  }
}

class HitsPage {
  const HitsPage(this.items, this.pageKey, this.nextPageKey);

  final List<Product> items;
  final int pageKey;
  final int? nextPageKey;

  factory HitsPage.fromResponse(SearchResponse response) {
    final items = response.hits.map(Product.fromJson).toList();
    final isLastPage = response.page >= response.nbPages;
    final nextPageKey = isLastPage ? null : response.page + 1;
    return HitsPage(items, response.page, nextPageKey);
  }
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  final _searchTextController = TextEditingController();

  final _productsSearcher = HitsSearcher(
      applicationID: 'latency',
      apiKey: '927c3fe76d4b52c5a2912973f35a3077',
      indexName: 'STAGING_native_ecom_demo_products');

  Stream<SearchMetadata> get _searchMetadata =>
      _productsSearcher.responses.map(SearchMetadata.fromResponse);

  final PagingController<int, Product> _pagingController =
      PagingController(firstPageKey: 0);

  Stream<HitsPage> get _searchPage =>
      _productsSearcher.responses.map(HitsPage.fromResponse);

  final GlobalKey<ScaffoldState> _mainScaffoldKey = GlobalKey();

  final _filterState = FilterState();

  late final _facetList = _productsSearcher.buildFacetList(
    filterState: _filterState,
    attribute: 'brand',
  );

  @override
  void initState() {
    super.initState();
    _searchTextController.addListener(
      () => _productsSearcher.applyState(
        (state) => state.copyWith(
          query: _searchTextController.text,
          page: 0,
        ),
      ),
    );
    _searchPage.listen((page) {
      if (page.pageKey == 0) {
        _pagingController.refresh();
      }
      _pagingController.appendPage(page.items, page.nextPageKey);
    }).onError((error) => _pagingController.error = error);
    _pagingController.addPageRequestListener(
      (pageKey) => _productsSearcher.applyState(
          (state) => state.copyWith(
            page: pageKey,
          )
      )
    );
    _productsSearcher.connectFilterState(_filterState);
    _filterState.filters.listen((_) => _pagingController.refresh());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      key: _mainScaffoldKey,
      appBar: AppBar(
        title: const Text('Algolia & Flutter'),
        actions: [
          IconButton(
              onPressed: () => _mainScaffoldKey.currentState?.openEndDrawer(),
              icon: const Icon(Icons.filter_list_sharp))
        ],
      ),
      endDrawer: Drawer(
        child: _filters(context),
      ),
      body: Center(
        child: Column(
          children: <Widget>[
            SizedBox(
                height: 44,
                child: TextField(
                  controller: _searchTextController,
                  decoration: const InputDecoration(
                    border: InputBorder.none,
                    hintText: 'Enter a search term',
                    prefixIcon: Icon(Icons.search),
                  ),
                )),
            StreamBuilder<SearchMetadata>(
              stream: _searchMetadata,
              builder: (context, snapshot) {
                if (!snapshot.hasData) {
                  return const SizedBox.shrink();
                }
                return Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: Text('${snapshot.data!.nbHits} hits'),
                );
              },
            ),
            Expanded(
              child: _hits(context),
            )
          ],
        ),
      ),
    );
  }

  Widget _hits(BuildContext context) => PagedListView<int, Product>(
      pagingController: _pagingController,
      builderDelegate: PagedChildBuilderDelegate<Product>(
          noItemsFoundIndicatorBuilder: (_) => const Center(
                child: Text('No results found'),
              ),
          itemBuilder: (_, item, __) => Container(
                color: Colors.white,
                height: 80,
                padding: const EdgeInsets.all(8),
                child: Row(
                  children: [
                    SizedBox(width: 50, child: Image.network(item.image)),
                    const SizedBox(width: 20),
                    Expanded(child: Text(item.name))
                  ],
                ),
              )));

  Widget _filters(BuildContext context) => Scaffold(
        appBar: AppBar(
          title: const Text('Filters'),
        ),
        body: StreamBuilder<List<SelectableItem<Facet>>>(
            stream: _facetList.facets,
            builder: (context, snapshot) {
              if (!snapshot.hasData) {
                return const SizedBox.shrink();
              }
              final selectableFacets = snapshot.data!;
              return ListView.builder(
                  padding: const EdgeInsets.all(8),
                  itemCount: selectableFacets.length,
                  itemBuilder: (_, index) {
                    final selectableFacet = selectableFacets[index];
                    return CheckboxListTile(
                      value: selectableFacet.isSelected,
                      title: Text(
                          "${selectableFacet.item.value} (${selectableFacet.item.count})"),
                      onChanged: (_) {
                        _facetList.toggle(selectableFacet.item.value);
                      },
                    );
                  });
            }),
      );

  @override
  void dispose() {
    _searchTextController.dispose();
    _productSearcher.dispose();
    _pagingController.dispose();
    _filterState.dispose();
    _facetList.dispose();
    super.dispose();
  }
}

Find the source code for this project in the Algolia Flutter playground repository on GitHub.

What’s next?

This examples shows how to bridge native search with the Algolia Flutter Helper library. You can use it as a basis for more complex applications.