这么好看的搜索框,快来看看是怎么实现的
最近项目中在实现一个搜索的功能,根据 Flutter
的类似组件的调用习惯,输入 showSearch
后发现还真有,跳进源码中一看,Flutter
已经实现了相应的 Widget
和交互,简直不要太方便了,先来看看如何调用的。
showSearch
方法介绍
Future<T?> showSearch<T>({
required BuildContext context,
required SearchDelegate<T> delegate,
String? query = '',
bool useRootNavigator = false,
}) {
assert(delegate != null);
assert(context != null);
assert(useRootNavigator != null);
delegate.query = query ?? delegate.query;
delegate._currentBody = _SearchBody.suggestions;
return Navigator.of(context, rootNavigator: useRootNavigator).push(_SearchPageRoute<T>(
delegate: delegate,
));
}
上面函数定义在源码 flutter/lib/src/material/search.dart
文件中,根据该函数要求须传入一个 context
和 delegate
,context
是我们的老朋友,这里就无需过多介绍了。但是这个 delegate
(SearchDelegate
类)是干啥的?继续跳到 SearchDelegate
发现SearchDelegate
是一个抽象类,SearchDelegate
第一句介绍 Delegate for [showSearch] to define the content of the search page.
定义搜索页面的内容,也就是说需要我们创建一个继承自 SearchDelegate
的子类来实例化参数 delegate
,下面是这个子类CustomSearchPageDelegate
的代码。
class CustomSearchPageDelegate extends SearchDelegate<String> {
// 搜索框右边的显示,如返回按钮
List<Widget>? buildActions(BuildContext context) {}
// 搜索框左边的显示,如搜索按钮
Widget? buildLeading(BuildContext context) {}
// 搜索的结果展示,如列表 ListView
Widget buildResults(BuildContext context) {}
// 输入框输入内容时给出的提示
Widget buildSuggestions(BuildContext context) {}
}
从上面可以看出我们需要返回4个 Widget
来显示内容,其中 buildLeading
和 buildActions
分别对应搜索框左右两边的内容,通常是 button
,如 buildLeading
是返回按钮,buildActions
右边是搜索按钮。buildResults
则表示搜索的结果展示,通常是一个列表,而 buildSuggestions
展示当用户在输入框输入内容时给出的提示,展示多条提示内容时也会用到列表(ListView
)。
实现 CustomSearchPageDelegate
接下来以搜索文章为例子利用自定义的CustomSearchPageDelegate
类实现一下搜索功能。
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_todo/pages/search_page/data_item_model.dart';
import 'package:flutter_todo/pages/search_page/search_item_widget.dart';
class CustomSearchPageDelegate extends SearchDelegate<DataItemModel> {
CustomSearchPageDelegate({
String? hintText,
required this.models,
}) : super(
searchFieldLabel: hintText,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.search,
);
/// 搜素提示
List<String> suggestions = [
"Flutter",
"Flutter开发7个建议,让你的工作效率飙升",
"浅谈 Flutter 的并发和 isolates",
"Flutter 技术实践",
"Flutter 中如何优雅地使用弹框",
"Flutter设计模式全面解析:单例模式",
"Flutter Dart",
"Flutter 状态管理",
"Flutter大型项目架构:UI设计系统实现(二)",
"Flutter大型项目架构:分层设计篇",
"Dart 语法原来这么好玩儿"
];
/// 模拟数据,一般调用接口返回的数据
List<DataItemModel> models = [];
/// 搜索结果
List<DataItemModel> results = [];
/// 右边的搜索按钮
List<Widget>? buildActions(BuildContext context) {
return [
InkWell(
onTap: () {},
child: Container(
margin: const EdgeInsets.all(10),
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.red, borderRadius: BorderRadius.circular(30)),
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16),
child: const Text(
"搜索",
style: TextStyle(color: Colors.white, fontSize: 14),
),
),
),
];
}
/// 左边返回按钮
Widget? buildLeading(BuildContext context) {
return InkWell(
onTap: () {
/// 返回操作,关闭搜索功能
/// 这里应该返回 null
close(context, DataItemModel());
},
child: Container(
padding: const EdgeInsets.all(15.0),
child: SvgPicture.asset(
"assets/images/arrow.svg",
height: 22,
color: Colors.black,
),
));
}
/// 搜索结果列表
Widget buildResults(BuildContext context) {
return ListView.separated(
physics: const BouncingScrollPhysics(),
itemCount: results.length,
itemBuilder: (context, index) {
DataItemModel item = results[index];
/// 自定义Widget,用来显示每一条搜素到的数据。
return SearchResultItemWidget(
itemModel: item,
click: () {
/// 点击一条数据后关闭搜索功能,返回该条数据。
close(context, item);
},
);
},
separatorBuilder: (BuildContext context, int index) {
return divider;
},
);
}
/// 提示词列表
Widget buildSuggestions(BuildContext context) {
List<String> suggestionList = query.isEmpty
? []
: suggestions
.where((p) => p.toLowerCase().contains(query.toLowerCase()))
.toList();
if (suggestionList.isEmpty) return Container();
return ListView.separated(
itemBuilder: (context, index) {
String name = suggestionList[index];
return InkWell(
onTap: () {
/// 点击提示词,会根据提示词开始搜索,这里模拟从models数组中搜索数据。
query = name;
results = models
.where((e) =>
(e.title?.toLowerCase().contains(name.toLowerCase()) ??
false) ||
(e.desc?.toLowerCase().contains(name.toLowerCase()) ??
false))
.toList();
/// 展示结果,这个时候就调用 buildResults,主页面就会用来显示搜索结果
showResults(context);
},
child: Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 10),
child: Row(
children: [
SvgPicture.asset(
"assets/images/search.svg",
height: 16,
color: const Color(0xFF373737),
),
const SizedBox(
width: 4,
),
RichText(
text: TextSpan(children: getSpans(name)),
),
],
),
),
);
},
itemCount: suggestionList.length,
separatorBuilder: (BuildContext context, int index) {
return divider;
},
);
}
/// 分割线
Widget get divider => Container(
color: const Color(0xFFAFAFAF),
height: 0.3,
);
/// 富文本提示词,其中如果和输入的文本匹配到的关键字显示红色。
List<TextSpan> getSpans(String name) {
int start = name.toLowerCase().indexOf(query.toLowerCase());
String end = name.substring(start, start + query.length);
List<String> spanStrings = name
.toLowerCase()
.replaceAll(end.toLowerCase(), "*${end.toLowerCase()}*")
.split("*");
return spanStrings
.map((e) => (e.toLowerCase() == end.toLowerCase()
? TextSpan(
text: e,
style: const TextStyle(color: Colors.red, fontSize: 14))
: TextSpan(
text: e,
style:
const TextStyle(color: Color(0xFF373737), fontSize: 14))))
.toList();
}
}
这里要说明一下,query
关键字是输入框的文本内容。调用的时候实例化一下该类,传递给 shwoSearch
的 delegate
参数。下图就是我们看到的效果:
总结问题
以上图片的搜索框还可以通过重写 appBarTheme
来定制自己想要的 UI
效果,虽然可以这样,但是和我们要实现的效果比起来还相差甚远,尤其是顶部的搜索框,其左右两边的留白区域过多,背景颜色无法调整,内部的输入框 TextField
也无法定制自己想要的效果,如不能调整其圆角、背景颜色以及添加额外控件等等。
还有一点就是当我们点击返回按钮调用 close
时,这里返回值是泛型 T
却不支持 null
类型,在文章的开头,我们可以看到 shwoSearch
的 delegate
参数类型是 SearchDelegate<T>
,所以创建 CustomSearchPageDelegate
时必须这样去声明。
class CustomSearchPageDelegate extends SearchDelegate<DataItemModel>
而我们想要实现这样去声明
class CustomSearchPageDelegate extends SearchDelegate<DataItemModel?>
这样当我们调用 close
时可以做到传 null
,在外面调用的位置可以对返回值进行判断,返回值为 null
就不作任何处理。
交互上,在点击键盘上的 搜索
按键时,直接调用的是 showResults
函数,而通常的操作是需要调用搜索的接口拿到数据后,再去调用 showResults
函数来展示搜索结果的数据。
对于上述问题,我们可以做什么呢?
源码分析
想要到达我们需要的效果,还是需要看看 Flutter
的源码是怎么实现的,我们再次来到 flutter/lib/src/material/search.dart
文件中,可以看到该文件中定义了除上面提到的抽象类 SearchDelegate
和全局函数 showSearch
之外,还有内部类 _SearchPageRoute
和 _SearchPage
。 _SearchPageRoute
继承自 PageRoute
,顾名思义就是负责路由跳转及转场动画的。
以下是 _SearchPageRoute
部分代码:
class _SearchPageRoute<T> extends PageRoute<T> {
_SearchPageRoute({
required this.delegate,
}) : assert(delegate != null) {
delegate._route = this;
}
final SearchDelegate<T> delegate;
/// ...
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return _SearchPage<T>(
delegate: delegate,
animation: animation,
);
}
/// ...
}
重写父类的 buildPage
方法,将 delegate
传递给 _SearchPage
并将其返回,而所有的 UI
逻辑都在这个 _SearchPage
中,来到 _SearchPage
的 build
函数中就可以看到下面的实现。
_SearchPage
的 build
函数代码
build(BuildContext context) {
assert(debugCheckHasMaterialLocalizations(context));
final ThemeData theme = widget.delegate.appBarTheme(context);
final String searchFieldLabel = widget.delegate.searchFieldLabel
?? MaterialLocalizations.of(context).searchFieldLabel;
Widget? body;
// _currentBody 枚举类型_SearchBody,用来区分当前body是展示提示列表还是搜索结果列表,
// 当调用 SearchDelegate 中 showResults 函数时,_currentBody = _SearchBody.results
// 当调用 SearchDelegate 中 showSuggestions 函数时,_currentBody = _SearchBody.suggestions
switch(widget.delegate._currentBody) {
case _SearchBody.suggestions:
body = KeyedSubtree(
key: const ValueKey<_SearchBody>(_SearchBody.suggestions),
child: widget.delegate.buildSuggestions(context),
);
break;
case _SearchBody.results:
body = KeyedSubtree(
key: const ValueKey<_SearchBody>(_SearchBody.results),
child: widget.delegate.buildResults(context),
);
break;
case null:
break;
}
return Semantics(
explicitChildNodes: true,
scopesRoute: true,
namesRoute: true,
label: routeName,
child: Theme(
data: theme,
child: Scaffold(
appBar: AppBar(
leading: widget.delegate.buildLeading(context),
title: TextField(
controller: widget.delegate._queryTextController,
focusNode: focusNode,
style: widget.delegate.searchFieldStyle ?? theme.textTheme.titleLarge,
textInputAction: widget.delegate.textInputAction,
keyboardType: widget.delegate.keyboardType,
onSubmitted: (String _) {
widget.delegate.showResults(context);
},
decoration: InputDecoration(hintText: searchFieldLabel),
),
actions: widget.delegate.buildActions(context),
bottom: widget.delegate.buildBottom(context),
),
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: body,
),
),
),
);
}
Widget
_SearchPage
中的实现也非常简单,就是一个嵌入到 AppBar
中的搜索框和呈现 suggestion list
及 result list
的 body
。想要定制自己的 UI
效果,改的也是该位置的代码。
优化实现
UI
方面主要针对 TextField
和 AppBar
代码修改,怎么改就看想要实现什么效果了。参考 Flutter
官方的源码,重新实现一个的 _SearchPage
类,然后在 _SearchPageRoute
替换成自己写的 _SearchPage
,再去 SearchDelegate
替换一下修改过的 _SearchPageRoute
。
还一个问题怎么实现调用 close
时可以返回 null
的结果内呢?除了上面提到的这样去声明
class CustomSearchPageDelegate extends SearchDelegate<DataItemModel?>
之外,还需要修改 _SearchPageRoute
。
// 改之后
final CustomSearchDelegate<T?> delegate;
// 改之前
// final CustomSearchDelegate<T> delegate;
重新定义一个全局函数 showSearchWithCustomiseSearchDelegate
,和官方的区分开来。
Future<T?> showSearchWithCustomiseSearchDelegate<T>({
required BuildContext context,
// 这里的泛型由原来的 T 改成了 T?
required CustomSearchDelegate<T?> delegate,
String? query = '',
bool useRootNavigator = false,
}) {
delegate.query = query ?? delegate.query;
delegate._currentBody = _SearchBody.suggestions;
// 这里的 _SearchPageRoute 是我们自己实现的类
return Navigator.of(context, rootNavigator: useRootNavigator)
.push(_SearchPageRoute<T>(
delegate: delegate,
));
}
来看看最终调用上面的函数
DataItemModel? result =
await showSearchWithCustomiseSearchDelegate(
context: context,
delegate: SearchPageDelegate(
hintText: "Flutter 技术实践", models: models));
if (result != null) {
/// to detail page
}
解决交互上的问题,需要在我们自己抽象类 SearchDelegate
单独定义一个函数 onSubmit
,点击键盘上的搜索按键和右边的搜索按钮调用 onSubmit
函数,如:widget.delegate.onSubmit(context, text);
,在 SearchDelegate
子类的 onSubmit
中来实现具体的逻辑,如发送网络请求,返回数据后在调用 showResults
。
void onSubmit(BuildContext context, String text) {
// 发送网络请求,拿到数据。
// showResults(context);
}
整体实现的代码量多,就不在文中贴出来了,具体实现大家可以参考这里的代码:
https://github.com/joedrm/flutter_todo/blob/master/lib/pages/search_page/search_page_delegate.dart
下图是最终实现效果:
小结
自定义搜索框的实现整体来说还是比较简单的,相比于源码改动的地方并不多,就可以显示想要的效果。当然还有其它更多的实现方式,这里只是提供了一种分析思路。我们还可以发散一下,去看看其它的如:showBottomSheet
、showDialog
等等和 SearchDelegate
,他们直之间也有不少类似的地方,当我想要自定义自己的控件时,会发现其实很多答案就在官方的源码里,动手改吧改吧就出来了。最后聊一下近况,近期有一些想法在忙着实现,时间有点安排不过来,文章的更新就有点儿偷懒了,跟大家说声抱歉,后面有机会单独来分享一下最近忙的事情,最后感谢大家耐心的阅读!