cover

为 Flutter 应用添加搜索功能

前言

SearchDelegate 是 Flutter 框架提供的一个实现搜索功能的类,使用它可以快速实现搜索功能,本文说明如何使用它来实现搜索功能。

最终效果如下

创建新项目,初始化代码如下

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
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Search App',
home: HomePage(),
);
}
}

class HomePage extends StatelessWidget {

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Search App'),
),
);
}
}

显示搜索页面

showSearch 方法是 Flutter 里用来显示一个搜索页面的方法,这个页面由一个带有搜索框的 AppBar 和显示搜索建议或搜索结果的 body 组成。它有两个必要参数 contextdelegatecontext 即为当前的应用上下文,delegate 是一个实现了 SearchDelegate 抽象类自定义的部件,这个自定义部件定义了如何显示搜索页面,关闭搜索页面时返回用户选择的搜索结果。

AppBaractions 数组里面添加一个 IconButton,按下时调用 showSearch 方法,进入搜索页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Search App'),
actions: <Widget>[
IconButton(
icon: Icon(Icons.search),
onPressed: () {
showSearch(context: context, delegate: CustomSearchDelegate());
},
)
],
),
);
}
}

初始化一个继承了 SearchDelegateCustomSearchDelegate,类的名字是自定义的。

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
class CustomSearchDelegate extends SearchDelegate {
@override
List<Widget> buildActions(BuildContext context) {
// TODO: implement buildActions
throw UnimplementedError();
}

@override
Widget buildLeading(BuildContext context) {
// TODO: implement buildLeading
throw UnimplementedError();
}

@override
Widget buildResults(BuildContext context) {
// TODO: implement buildResults
throw UnimplementedError();
}

@override
Widget buildSuggestions(BuildContext context) {
// TODO: implement buildSuggestions
throw UnimplementedError();
}
}

实现 CustomSearchDelegate

自定义的 CustomSearchDelegate 需要实现四个方法

  • buildLeading 显示在输入框之前的部件,一般显示返回前一个页面箭头按钮
  • buildActions 显示在输入框之后的部件
  • buildResults 显示搜索结果
  • buildSuggestions 显示搜索建议

先实现 buildActionsbuildLeadingbuildActions 显示一个清除按钮,可以把当前的 query 查询参数清空,并显示搜索建议。buildLeading 显示一个箭头的按钮,使用 close 方法关闭搜索页面,close 方法第二个参数是选定的搜索结果,如果使用系统后退按钮关闭搜索页面,则返回 null 值。

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
List<Widget> buildActions(BuildContext context) {
return [
IconButton(
tooltip: 'Clear',
icon: const Icon(Icons.clear),
onPressed: () {
query = '';
showSuggestions(context);
},
)
];
}

@override
Widget buildLeading(BuildContext context) {
return IconButton(
tooltip: 'Back',
icon: AnimatedIcon(
icon: AnimatedIcons.menu_arrow,
progress: transitionAnimation,
),
onPressed: () {
close(context, null);
},
);
}

@override
Widget buildResults(BuildContext context) {
return ListView();
}

@override
Widget buildSuggestions(BuildContext context) {
return ListView();
}

然后实现 buildResultsbuildSuggestions,这两个方法用来展示搜索页面内容,可以使用不同的部显示,这里使用 ListView 部件。

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 buildResults(BuildContext context) {
return ListView.builder(
itemCount: Random().nextInt(10),
itemBuilder: (context, index) {
return ListTile(
title: Text('result $index'),
);
},
);
}

@override
Widget buildSuggestions(BuildContext context) {
return ListView(
children: <Widget>[
ListTile(title: Text('Suggest 01')),
ListTile(title: Text('Suggest 02')),
ListTile(title: Text('Suggest 03')),
ListTile(title: Text('Suggest 04')),
ListTile(title: Text('Suggest 05')),
],
);
}

搜索结果

搜索建议

获取远程数据

搜索功能一般需要请求后端的搜索接口来获取数据,此时可以使用 FutureBuilder 部件来请求数据然后渲染结果。首先需要定义一个请求接口的方法,返回一个 Future,然后在 buildResults 方法中使用 FutureBuilder 来展示结果。

先添加 http 包,用来发送 http 请求,然后引入需要的依赖包

1
2
dependencies:
http: <latest_version>
1
2
import 'dart:convert';
import 'package:http/http.dart' as http;

将键盘输入类型设置为数字,定义一个 _fetchPosts 方法用来获取远端数据,在 buildResults 方法里使用 FutureBuilder 渲染搜索结果。

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
@override
TextInputType get keyboardType => TextInputType.number;

Future _fetchPosts() async {
http.Response response =
await http.get('https://jsonplaceholder.typicode.com/posts/$query');
final data = await json.decode(response.body);
return data;
}

@override
Widget buildResults(BuildContext context) {
if (int.tryParse(query) >= 100) {
return Center(child: Text('请输入小于 100 的数字'));
}

return FutureBuilder(
future: _fetchPosts(),
builder: (context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {
final post = snapshot.data;

return ListTile(
title: Text(post['title'], maxLines: 1),
subtitle: Text(post['body'], maxLines: 3),
);
}
return Center(child: CircularProgressIndicator());
},
);
}

使用 FutureBuilder 部件获取了远程的数据,但是遇到一个问题,搜索结果可能是分页显示的,一开始只获取了第一页的数据,想追加下一页数据时需要像 stateFullWidget 那样使用 setState 方法更新页面,但是在 SearchDelegate 里无法使用…暂时没想到解决方法。

完整代码

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
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

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

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Search App',
home: HomePage(),
);
}
}

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Search App'),
actions: <Widget>[
IconButton(
icon: Icon(Icons.search),
onPressed: () {
showSearch(context: context, delegate: CustomSearchDelegate());
},
)
],
),
);
}
}

class CustomSearchDelegate extends SearchDelegate {
@override
List<Widget> buildActions(BuildContext context) {
return [
IconButton(
tooltip: 'Clear',
icon: const Icon(Icons.clear),
onPressed: () {
query = '';
showSuggestions(context);
},
)
];
}

@override
Widget buildLeading(BuildContext context) {
return IconButton(
tooltip: 'Back',
icon: AnimatedIcon(
icon: AnimatedIcons.menu_arrow,
progress: transitionAnimation,
),
onPressed: () {
this.close(context, null);
},
);
}

@override
TextInputType get keyboardType => TextInputType.number;

Future _fetchPosts() async {
http.Response response =
await http.get('https://jsonplaceholder.typicode.com/posts/$query');
final data = await json.decode(response.body);

return data;
}

@override
Widget buildResults(BuildContext context) {
if (int.parse(query) >= 100) {
return Center(child: Text('请输入小于 100 的数字'));
}

return FutureBuilder(
future: _fetchPosts(),
builder: (context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {
final post = snapshot.data;

return ListTile(
title: Text(post['title'], maxLines: 1),
subtitle: Text(post['body'], maxLines: 3),
);
}

return Center(child: CircularProgressIndicator());
},
);
}

@override
Widget buildSuggestions(BuildContext context) {
return ListView(
children: <Widget>[
ListTile(title: Text('Suggest 01')),
ListTile(title: Text('Suggest 02')),
ListTile(title: Text('Suggest 03')),
ListTile(title: Text('Suggest 04')),
ListTile(title: Text('Suggest 05')),
],
);
}
}
Buy Me A Coffee
← 使用 Codemagic 持续部署 Flutter 应用 Flutter 创建自定义路由过渡动画 →