flutter_searchbox offers a declarative API for querying Elasticsearch and OpenSearch with ReactiveSearch. It seamlessly integrates UI widgets with various types of search queries, making it easy to build robust search interfaces.
Installation
- Create a new Flutter project
flutter create flutter_searchbox_demo
- Add the library dependencies
Add this to your package's pubspec.yaml
file:
dependencies:
flutter_searchbox: ^4.0.0
searchbase: ^4.0.0
- Install the dependencies
You can install packages from the command line:
$ flutter pub get
Basic usage
A simple example
The following example renders an autosuggestion search-widget
with one custom widget result-widget
to render the results. The result-widget
watches the search-widget
for input changes and updates its UI when the user selects a suggestion.
import 'package:flutter/material.dart';
import 'package:searchbase/searchbase.dart';
import 'package:searchbase/src/searchcontroller.dart' as searchbase;
import 'package:flutter_searchbox/flutter_searchbox.dart';
void main() {
runApp(FlutterSearchBoxApp());
}
class FlutterSearchBoxApp extends StatelessWidget {
final searchbaseInstance = SearchBase(
'good-books-ds',
'https://appbase-demo-ansible-abxiydt-arc.searchbase.io',
'a03a1cb71321:75b6603d-9456-4a5a-af6b-a487b309eb61',
appbaseConfig: AppbaseSettings(
recordAnalytics: true,
userId: 'test@dev'));
FlutterSearchBoxApp({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return SearchBaseProvider(
searchbase: searchbaseInstance,
child: MaterialApp(
title: "SearchBox Demo",
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: HomePage(),
),
);
}
}
class HomePage extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
actions: <Widget>[
IconButton(
icon: Icon(Icons.search),
onPressed: () {
showSearch(
context: context,
delegate: SearchBox(
id: 'search-widget',
enableRecentSearches: false,
enablePopularSuggestions: false,
showAutoFill: true,
maxPopularSuggestions: 3,
size: 10,
dataField: [
{'field': 'original_title', 'weight': 1},
{'field': 'original_title.search', 'weight': 3}
],
distinctField: 'authors.keyword',
distinctFieldConfig: {
'inner_hits': {
'name': 'most_recent',
'size': 5,
'sort': [
{'timestamp': 'asc'}
],
},
'max_concurrent_group_searches': 4,
},
));
}),
],
title: Text('SearchBox Demo'),
),
body: Center(
child: SearchWidgetConnector(
id: 'result-widget',
dataField: 'original_title',
react: {
'and': ['search-widget'],
},
size: 10,
triggerQueryOnInit: true,
preserveResults: true,
builder: (context, searchController) => ResultsWidget(searchController)),
),
);
}
}
class ResultsWidget extends StatelessWidget {
final searchbase.SearchController searchController; // Use the aliased class name
ResultsWidget(this.searchController);
Widget build(BuildContext context) {
return Column(
children: [
Card(
child: Align(
alignment: Alignment.centerLeft,
child: Container(
height: 20,
padding: const EdgeInsets.only(left: 10.0),
child: Text('${searchController.results.numberOfResults} results found in ${searchController.results.time.toString()} ms'),
),
),
),
Expanded(
child: ListView.builder(
itemBuilder: (context, index) {
WidgetsBinding.instance.addPostFrameCallback((_) {
var offset = (searchController.from ?? 0) + (searchController.size ?? 0);
if (index == offset - 1) {
if (searchController.results.numberOfResults > offset) {
searchController.setFrom(offset, options: Options(triggerDefaultQuery: true));
}
}
});
return Container(
child: (index < searchController.results.data.length)
? Container(
margin: const EdgeInsets.all(0.5),
padding: const EdgeInsets.fromLTRB(0, 15, 0, 0),
decoration: BoxDecoration(border: Border.all(color: Colors.black26)),
height: 200,
child: Row(
children: [
Expanded(
flex: 3,
child: Column(
children: [
Card(
semanticContainer: true,
clipBehavior: Clip.antiAliasWithSaveLayer,
child: Image.network(
searchController.results.data[index]["image_medium"],
fit: BoxFit.fill,
),
elevation: 5,
margin: EdgeInsets.all(10),
),
],
),
),
Expanded(
flex: 7,
child: Column(
children: [
ListTile(
title: Text(
searchController.results.data[index]["original_title"],
style: TextStyle(fontSize: 20.0),
),
subtitle: Text('By: ${searchController.results.data[index]["authors"]}'),
),
Text('(${searchController.results.data[index]["average_rating"]} avg)'),
Text('Pub: ${searchController.results.data[index]["original_publication_year"]}'),
],
),
),
],
),
)
: (searchController.requestPending
? Center(child: CircularProgressIndicator())
: ListTile(
title: Center(
child: Text(searchController.results.data.isNotEmpty ? "No more results" : 'No results found'),
),
)));
},
itemCount: searchController.results.data.length + 1,
),
),
],
);
}
}
An example with a facet
The following example renders one more custom widget with id author-filter
to render a list of authors. This widget is being used by result-widget
to filter the results data. The author-filter
widget also reacts to the search-widget
(check the react
property) to update the authors list reactively whenever the search query changes.
import 'package:flutter/material.dart';
import 'package:searchbase/searchbase.dart';
import 'package:flutter_searchbox/flutter_searchbox.dart';
void main() {
runApp(FlutterSearchBoxApp());
}
class FlutterSearchBoxApp extends StatelessWidget {
// Avoid creating searchbase instance in build method
// to preserve state on hot reloading
final searchbaseInstance = SearchBase(
'good-books-ds',
'https://appbase-demo-ansible-abxiydt-arc.searchbase.io',
'a03a1cb71321:75b6603d-9456-4a5a-af6b-a487b309eb61',
appbaseConfig: AppbaseSettings(
recordAnalytics: true,
// Use unique user id to personalize the recent searches
userId: 'test@dev'));
FlutterSearchBoxApp({Key key}) : super(key: key);
Widget build(BuildContext context) {
// The SearchBaseProvider should wrap your MaterialApp or WidgetsApp. This will
// ensure all routes have access to the store.
return SearchBaseProvider(
// Pass the searchbase instance to the SearchBaseProvider. Any ancestor `SearchWidgetConnector`
// Widgets will find and use this value as the `SearchController`.
searchbase: searchbaseInstance,
child: MaterialApp(
title: "SearchBox Demo",
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: HomePage(),
),
);
}
}
class HomePage extends StatelessWidget {
// This widget is the root of your application.
Widget build(BuildContext context) {
return MaterialApp(
title: 'SearchBox Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: Scaffold(
appBar: AppBar(
actions: <Widget>[
IconButton(
icon: Icon(Icons.search),
onPressed: () {
// Invoke the Search Delegate to display search UI with autosuggestions
showSearch(
context: context,
// SearchBox widget from flutter searchbox
delegate: SearchBox(
// A unique identifier that can be used by other widgetss to reactively update data
id: 'search-widget',
enableRecentSearches: true,
enablePopularSuggestions: true,
showAutoFill: true,
maxPopularSuggestions: 3,
size: 10,
dataField: [
{'field': 'original_title', 'weight': 1},
{'field': 'original_title.search', 'weight': 3}
],
));
}),
],
title: Text('SearchBox Demo'),
),
body: Center(
// A custom UI widget to render a list of results
child: SearchWidgetConnector(
id: 'result-widget',
dataField: 'original_title',
react: {
'and': ['search-widget', 'author-filter'],
},
size: 10,
triggerQueryOnInit: true,
preserveResults: true,
builder: (context, searchController) =>
ResultsWidget(searchController)),
),
// A custom UI widget to render a list of authors
drawer: SearchWidgetConnector(
id: 'author-filter',
type: QueryType.term,
dataField: "authors.keyword",
size: 10,
// Initialize with default value
value: List<String>(),
react: {
'and': ['search-widget']
},
builder: (context, searchController) {
// Call searchController's query at first time
if (searchController.query == null) {
searchController.triggerDefaultQuery();
}
return AuthorFilter(searchController);
},
// Avoid fetching query for each open/close action instead call it manually
triggerQueryOnInit: false,
)),
);
}
}
class ResultsWidget extends StatelessWidget {
final SearchController searchController;
ResultsWidget(this.searchController);
Widget build(BuildContext context) {
return Column(
children: [
Card(
child: Align(
alignment: Alignment.centerLeft,
child: Container(
color: Colors.white,
height: 20,
child: Text(
'${searchController.results.numberOfResults} results found in ${searchController.results.time.toString()} ms'),
),
),
),
Expanded(
child: ListView.builder(
itemBuilder: (context, index) {
WidgetsBinding.instance.addPostFrameCallback((_) {
var offset =
(searchController.from != null ? searchController.from : 0) +
searchController.size;
if (index == offset - 1) {
if (searchController.results.numberOfResults > offset) {
// Load next set of results
searchController.setFrom(offset,
options: Options(triggerDefaultQuery: true));
}
}
});
return Container(
child: (index < searchController.results.data.length)
? Container(
margin: const EdgeInsets.all(0.5),
padding: const EdgeInsets.fromLTRB(0, 15, 0, 0),
decoration: new BoxDecoration(
border: Border.all(color: Colors.black26)),
height: 200,
child: Row(
children: [
Expanded(
flex: 3,
child: Column(
children: [
Card(
semanticContainer: true,
clipBehavior: Clip.antiAliasWithSaveLayer,
child: Image.network(
searchController.results.data[index]
["image_medium"],
fit: BoxFit.fill,
),
elevation: 5,
margin: EdgeInsets.all(10),
),
],
),
),
Expanded(
flex: 7,
child: Column(
children: [
Column(
children: [
SizedBox(
height: 110,
width: 280,
child: ListTile(
title: Tooltip(
padding: EdgeInsets.all(5),
height: 35,
textStyle: TextStyle(
fontSize: 15,
color: Colors.grey,
fontWeight:
FontWeight.normal),
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.grey,
spreadRadius: 1,
blurRadius: 1,
offset: Offset(0, 1),
),
],
color: Colors.white,
),
message:
'By: ${searchController.results.data[index]["original_title"]}',
child: Text(
searchController
.results
.data[index][
"original_title"]
.length <
40
? searchController
.results.data[index]
["original_title"]
: '${searchController.results.data[index]["original_title"].substring(0, 39)}...',
style: TextStyle(
fontSize: 20.0,
),
),
),
subtitle: Tooltip(
padding: EdgeInsets.all(5),
height: 35,
textStyle: TextStyle(
fontSize: 15,
color: Colors.grey,
fontWeight:
FontWeight.normal),
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.grey,
spreadRadius: 1,
blurRadius: 1,
offset: Offset(0, 1),
),
],
color: Colors.white,
),
message:
'By: ${searchController.results.data[index]["authors"]}',
child: Text(
searchController
.results
.data[index]
["authors"]
.length >
50
? 'By: ${searchController.results.data[index]["authors"].substring(0, 49)}...'
: 'By: ${searchController.results.data[index]["authors"]}',
style: TextStyle(
fontSize: 15.0,
),
),
),
isThreeLine: true,
),
),
Row(
children: [
Padding(
padding:
const EdgeInsets.fromLTRB(
25, 0, 0, 0),
),
Padding(
padding:
const EdgeInsets.fromLTRB(
10, 5, 0, 0),
child: Text(
'(${searchController.results.data[index]["average_rating"]} avg)',
style: TextStyle(
fontSize: 12.0,
),
),
),
],
),
Row(
children: [
Padding(
padding:
const EdgeInsets.fromLTRB(
27, 10, 0, 0),
child: Text(
'Pub: ${searchController.results.data[index]["original_publication_year"]}',
style: TextStyle(
fontSize: 12.0,
),
),
)
],
)
],
),
],
),
),
],
),
)
: (searchController.requestPending
? Center(child: CircularProgressIndicator())
: ListTile(
title: Center(
child: RichText(
text: TextSpan(
text: searchController.results.data.length > 0
? "No more results"
: 'No results found',
style: TextStyle(
color: Colors.black54,
fontSize: 20,
fontWeight: FontWeight.bold),
),
),
),
)));
},
itemCount: searchController.results.data.length + 1,
),
),
],
);
}
}
class FilterHeader extends PreferredSize {
final double height;
final Widget child;
FilterHeader({ this.child, this.height = kToolbarHeight});
Size get preferredSize => Size.fromHeight(height);
Widget build(BuildContext context) {
return Container(
height: preferredSize.height,
// color: Colors.white,
alignment: Alignment.centerLeft,
child: child,
padding: const EdgeInsets.fromLTRB(10, 0, 0, 0),
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.grey,
spreadRadius: 1,
blurRadius: 1,
offset: Offset(0, 1),
),
],
color: Colors.white,
),
);
}
}
class AuthorFilter extends StatelessWidget {
final SearchController searchController;
AuthorFilter(this.searchController);
Widget build(BuildContext context) {
return Container(
width: 350,
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 105, 0, 0),
child: Column(
children: [
Container(
child: Align(
alignment: Alignment.center,
child: Container(
height: 750,
color: Colors.white,
child: Scaffold(
appBar: FilterHeader(
height: 50,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
RichText(
text: TextSpan(
text: 'Selects Authors',
style: TextStyle(
fontSize: 20,
color: Colors.black,
fontWeight: FontWeight.bold,
)),
),
],
),
),
body: searchController.requestPending
? Center(child: CircularProgressIndicator())
: ListView(
children:
searchController.aggregationData.data.map((bucket) {
return Container(
child: Column(
children: [
new CheckboxListTile(
controlAffinity:
ListTileControlAffinity.leading,
activeColor: Colors.black54,
dense: true,
title: new Text(
"${bucket['_key']} (${bucket['_doc_count']})"),
value: (searchController.value == null
? []
: searchController.value)
.contains(bucket['_key']),
onChanged: (bool value) {
final List<String> values =
searchController.value == null
? []
: searchController.value;
if (values.contains(bucket['_key'])) {
values.remove(bucket['_key']);
} else {
values.add(bucket['_key']);
}
searchController.setValue(values);
},
),
const Divider(
color: Colors.black,
height: 10,
thickness: 0.1,
indent: 25,
endIndent: 20,
)
],
),
);
}).toList(),
),
),
),
),
),
Container(
child: Align(
alignment: Alignment.center,
child: Container(
color: Colors.black,
height: 70,
width: 500,
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
flex: 6,
child: RaisedButton(
padding: EdgeInsets.symmetric(
horizontal: 10.0, vertical: 23.0),
color: Colors.black,
child: Text(
'Apply',
style: TextStyle(
fontSize: 20.0,
fontWeight: FontWeight.w300,
color: Colors.white,
),
),
onPressed: () {
searchController.triggerCustomQuery();
Navigator.of(context).pop();
},
),
),
Expanded(
flex: 2,
child: Align(
alignment: Alignment.center,
child: RichText(
text: TextSpan(
text: '|',
style: TextStyle(
color: Colors.white,
fontSize: 50,
fontWeight: FontWeight.w100),
),
),
),
),
Expanded(
flex: 6,
child: RaisedButton(
padding: EdgeInsets.symmetric(
horizontal: 10.0, vertical: 23.0),
color: Colors.black,
child: Text(
'Close',
style: TextStyle(
fontSize: 20.0,
fontWeight: FontWeight.w300,
color: Colors.white,
),
),
onPressed: () {
Navigator.of(context).pop();
},
),
),
],
),
),
),
),
)
],
),
),
);
}
}
Custom SearchBox UI
The following example integrates the flutter_searchbox
with a third party library named flutter_typeahead to display the suggestions as an overlay.
https://github.com/appbaseio/flutter-searchbox-typeahead-example
API Reference
Check out the docs for API Reference over here.