Getting started with Flutter Helper
On this page
Algolia for Flutter is beta software according to Algolia’s Terms of Service (“Beta Services”). To share feedback or report a bug, open an issue.
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: ^0.5.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.
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.
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.
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.
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 = FacetList(
searcher: _productsSearcher,
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 theactions
list of theAppBar
. This triggers opening the end drawer. - Assign the
endDrawer
property ofScaffold
with filters the widget embedded in theDrawer
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.
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 = FacetList(
searcher: _productsSearcher,
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.
- To explore the components in more detail, see the Algolia Helper for Flutter API reference.
- To explore a complete ecommerce app built with Flutter, see the Flutter Ecommerce UI Template.