Flutter 借助SearchDelegate实现搜索页面,实现搜索建议、搜索结果,解决IOS拼音问题

news/2024/9/28 14:17:12

搜索界面使用Flutter自带的SearchDelegate组件实现,通过魔改实现如下效果:

  1. 搜素建议
  2. 搜索结果,支持刷新和加载更多
  3. IOS中文输入拼音问题

界面预览

拷贝源码

将SearchDelegate的源码拷贝一份,修改内容如下:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';/// 修改此处为 showMySearch
Future<T?> showMySearch<T>({required BuildContext context,required MySearchDelegate<T> delegate,String? query = '',bool useRootNavigator = false,
}) {delegate.query = query ?? delegate.query;delegate._currentBody = _SearchBody.suggestions;return Navigator.of(context, rootNavigator: useRootNavigator).push(_SearchPageRoute<T>(delegate: delegate,));
}/// https://juejin.cn/post/7090374603951833118
abstract class MySearchDelegate<T> {MySearchDelegate({this.searchFieldLabel,this.searchFieldStyle,this.searchFieldDecorationTheme,this.keyboardType,this.textInputAction = TextInputAction.search,}) : assert(searchFieldStyle == null || searchFieldDecorationTheme == null);Widget buildSuggestions(BuildContext context);Widget buildResults(BuildContext context);Widget? buildLeading(BuildContext context);bool? automaticallyImplyLeading;double? leadingWidth;List<Widget>? buildActions(BuildContext context);PreferredSizeWidget? buildBottom(BuildContext context) => null;Widget? buildFlexibleSpace(BuildContext context) => null;ThemeData appBarTheme(BuildContext context) {final ThemeData theme = Theme.of(context);final ColorScheme colorScheme = theme.colorScheme;return theme.copyWith(appBarTheme: AppBarTheme(systemOverlayStyle: colorScheme.brightness == Brightness.dark? SystemUiOverlayStyle.light: SystemUiOverlayStyle.dark,backgroundColor: colorScheme.brightness == Brightness.dark? Colors.grey[900]: Colors.white,iconTheme: theme.primaryIconTheme.copyWith(color: Colors.grey),titleTextStyle: theme.textTheme.titleLarge,toolbarTextStyle: theme.textTheme.bodyMedium,),inputDecorationTheme: searchFieldDecorationTheme ??InputDecorationTheme(hintStyle: searchFieldStyle ?? theme.inputDecorationTheme.hintStyle,border: InputBorder.none,),);}String get query => _queryTextController.completeText;set query(String value) {_queryTextController.completeText = value; // 更新实际搜索内容_queryTextController.text = value; // 更新输入框内容if (_queryTextController.text.isNotEmpty) {_queryTextController.selection = TextSelection.fromPosition(TextPosition(offset: _queryTextController.text.length));}}void showResults(BuildContext context) {_focusNode?.unfocus();_currentBody = _SearchBody.results;}void showSuggestions(BuildContext context) {assert(_focusNode != null,'_focusNode must be set by route before showSuggestions is called.');_focusNode!.requestFocus();_currentBody = _SearchBody.suggestions;}void close(BuildContext context, T result) {_currentBody = null;_focusNode?.unfocus();Navigator.of(context)..popUntil((Route<dynamic> route) => route == _route)..pop(result);}final String? searchFieldLabel;final TextStyle? searchFieldStyle;final InputDecorationTheme? searchFieldDecorationTheme;final TextInputType? keyboardType;final TextInputAction textInputAction;Animation<double> get transitionAnimation => _proxyAnimation;FocusNode? _focusNode;final ChinaTextEditController _queryTextController = ChinaTextEditController();final ProxyAnimation _proxyAnimation =ProxyAnimation(kAlwaysDismissedAnimation);final ValueNotifier<_SearchBody?> _currentBodyNotifier =ValueNotifier<_SearchBody?>(null);_SearchBody? get _currentBody => _currentBodyNotifier.value;set _currentBody(_SearchBody? value) {_currentBodyNotifier.value = value;}_SearchPageRoute<T>? _route;/// Releases the resources.@mustCallSupervoid dispose() {_currentBodyNotifier.dispose();_focusNode?.dispose();_queryTextController.dispose();_proxyAnimation.parent = null;}
}/// search page.
enum _SearchBody {suggestions,results,
}class _SearchPageRoute<T> extends PageRoute<T> {_SearchPageRoute({required this.delegate,}) {assert(delegate._route == null,'The ${delegate.runtimeType} instance is currently used by another active ''search. Please close that search by calling close() on the MySearchDelegate ''before opening another search with the same delegate instance.',);delegate._route = this;}final MySearchDelegate<T> delegate;@overrideColor? get barrierColor => null;@overrideString? get barrierLabel => null;@overrideDuration get transitionDuration => const Duration(milliseconds: 300);@overridebool get maintainState => false;@overrideWidget buildTransitions(BuildContext context,Animation<double> animation,Animation<double> secondaryAnimation,Widget child,) {return FadeTransition(opacity: animation,child: child,);}@overrideAnimation<double> createAnimation() {final Animation<double> animation = super.createAnimation();delegate._proxyAnimation.parent = animation;return animation;}@overrideWidget buildPage(BuildContext context,Animation<double> animation,Animation<double> secondaryAnimation,) {return _SearchPage<T>(delegate: delegate,animation: animation,);}@overridevoid didComplete(T? result) {super.didComplete(result);assert(delegate._route == this);delegate._route = null;delegate._currentBody = null;}
}class _SearchPage<T> extends StatefulWidget {const _SearchPage({required this.delegate,required this.animation,});final MySearchDelegate<T> delegate;final Animation<double> animation;@overrideState<StatefulWidget> createState() => _SearchPageState<T>();
}class _SearchPageState<T> extends State<_SearchPage<T>> {// This node is owned, but not hosted by, the search page. Hosting is done by// the text field.FocusNode focusNode = FocusNode();@overridevoid initState() {super.initState();widget.delegate._queryTextController.addListener(_onQueryChanged);widget.animation.addStatusListener(_onAnimationStatusChanged);widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);focusNode.addListener(_onFocusChanged);widget.delegate._focusNode = focusNode;}@overridevoid dispose() {super.dispose();widget.delegate._queryTextController.removeListener(_onQueryChanged);widget.animation.removeStatusListener(_onAnimationStatusChanged);widget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged);widget.delegate._focusNode = null;focusNode.dispose();}void _onAnimationStatusChanged(AnimationStatus status) {if (status != AnimationStatus.completed) {return;}widget.animation.removeStatusListener(_onAnimationStatusChanged);if (widget.delegate._currentBody == _SearchBody.suggestions) {focusNode.requestFocus();}}@overridevoid didUpdateWidget(_SearchPage<T> oldWidget) {super.didUpdateWidget(oldWidget);if (widget.delegate != oldWidget.delegate) {oldWidget.delegate._queryTextController.removeListener(_onQueryChanged);widget.delegate._queryTextController.addListener(_onQueryChanged);oldWidget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged);widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);oldWidget.delegate._focusNode = null;widget.delegate._focusNode = focusNode;}}void _onFocusChanged() {if (focusNode.hasFocus &&widget.delegate._currentBody != _SearchBody.suggestions) {widget.delegate.showSuggestions(context);}}void _onQueryChanged() {setState(() {// rebuild ourselves because query changed.});}void _onSearchBodyChanged() {setState(() {// rebuild ourselves because search body changed.});}@overrideWidget 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;switch (widget.delegate._currentBody) {case _SearchBody.suggestions:body = KeyedSubtree(key: const ValueKey<_SearchBody>(_SearchBody.suggestions),child: widget.delegate.buildSuggestions(context),);case _SearchBody.results:body = KeyedSubtree(key: const ValueKey<_SearchBody>(_SearchBody.results),child: widget.delegate.buildResults(context),);case null:break;}late final String routeName;switch (theme.platform) {case TargetPlatform.iOS:case TargetPlatform.macOS:routeName = '';case TargetPlatform.android:case TargetPlatform.fuchsia:case TargetPlatform.linux:case TargetPlatform.windows:routeName = searchFieldLabel;}return Semantics(explicitChildNodes: true,scopesRoute: true,namesRoute: true,label: routeName,child: Theme(data: theme,child: Scaffold(appBar: AppBar(leadingWidth: widget.delegate.leadingWidth,automaticallyImplyLeading:widget.delegate.automaticallyImplyLeading ?? true,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),),flexibleSpace: widget.delegate.buildFlexibleSpace(context),actions: widget.delegate.buildActions(context),bottom: widget.delegate.buildBottom(context),),body: AnimatedSwitcher(duration: const Duration(milliseconds: 300),child: body,),),),);}
}class ChinaTextEditController extends TextEditingController {///拼音输入完成后的文字var completeText = '';@overrideTextSpan buildTextSpan({required BuildContext context,TextStyle? style,required bool withComposing}) {///拼音输入完成if (!value.composing.isValid || !withComposing) {if (completeText != value.text) {completeText = value.text;WidgetsBinding.instance.addPostFrameCallback((_) {notifyListeners();});}return TextSpan(style: style, text: text);}///返回输入样式,可自定义样式final TextStyle composingStyle = style?.merge(const TextStyle(decoration: TextDecoration.underline),) ?? const TextStyle(decoration: TextDecoration.underline);return TextSpan(style: style, children: <TextSpan>[TextSpan(text: value.composing.textBefore(value.text)),TextSpan(style: composingStyle,text: value.composing.isValid && !value.composing.isCollapsed? value.composing.textInside(value.text): "",),TextSpan(text: value.composing.textAfter(value.text)),]);}
}

实现搜索

创建SearchPage继承MySearchDelegate,修改样式,实现页面。需要重写下面5个方法

  • appBarTheme:修改搜索样式
  • buildActions:搜索框右侧的方法
  • buildLeading:搜索框左侧的返回按钮
  • buildResults:搜索结果
  • buildSuggestions:搜索建议
import 'package:e_book_clone/pages/search/MySearchDelegate.dart';
import 'package:flutter/src/material/theme_data.dart';
import 'package:flutter/src/widgets/framework.dart';class Demo extends MySearchDelegate {@overrideThemeData appBarTheme(BuildContext context) {// TODO: implement appBarThemereturn super.appBarTheme(context);}@overrideList<Widget>? buildActions(BuildContext context) {// TODO: implement buildActionsthrow UnimplementedError();}@overrideWidget? buildLeading(BuildContext context) {// TODO: implement buildLeadingthrow UnimplementedError();}@overrideWidget buildResults(BuildContext context) {// TODO: implement buildResultsthrow UnimplementedError();}@overrideWidget buildSuggestions(BuildContext context) {// TODO: implement buildSuggestionsthrow UnimplementedError();}
}

修改样式

@override
ThemeData appBarTheme(BuildContext context) {final ThemeData theme = Theme.of(context);final ColorScheme colorScheme = theme.colorScheme;return theme.copyWith( // 使用copyWith,适配全局主题appBarTheme: AppBarTheme( // AppBar样式修改systemOverlayStyle: colorScheme.brightness == Brightness.dark? SystemUiOverlayStyle.light: SystemUiOverlayStyle.dark,surfaceTintColor: Theme.of(context).colorScheme.surface,titleSpacing: 0, // textfield前面的间距elevation: 0, // 阴影),inputDecorationTheme: InputDecorationTheme(isCollapsed: true,hintStyle: TextStyle( // 提示文字颜色color: Theme.of(ToastUtils.context).colorScheme.inversePrimary),filled: true,  // 填充颜色contentPadding: EdgeInsets.symmetric(vertical: 10.h, horizontal: 15.w),fillColor: Theme.of(context).colorScheme.secondary, // 填充颜色,需要配合 filledenabledBorder: OutlineInputBorder( // testified 边框borderRadius: BorderRadius.circular(12.r),borderSide: BorderSide(color: Theme.of(context).colorScheme.surface,),),focusedBorder: OutlineInputBorder( // testified 边框borderRadius: BorderRadius.circular(12.r),borderSide: BorderSide(color: Theme.of(context).colorScheme.surface,),),),);
}@override
TextStyle? get searchFieldStyle => TextStyle(fontSize: 14.sp); // 字体大小设置,主要是覆盖默认样式

按钮功能

左侧返回按钮,右侧就放了一个搜索文本,点击之后显示搜索结果

@override
Widget? buildLeading(BuildContext context) {return IconButton(onPressed: () {close(context, null);},icon: Icon(color: Theme.of(context).colorScheme.onSurface,Icons.arrow_back_ios_new,size: 20.r,),);
}@override
List<Widget>? buildActions(BuildContext context) {return [Padding(padding: EdgeInsets.only(right: 15.w, left: 15.w),child: GestureDetector(onTap: () {showResults(context);},child: Text('搜索',style: TextStyle(color: Theme.of(context).colorScheme.primary, fontSize: 15.sp),),),)];
}

搜索建议

当 TextField 输入变化时,就会调用buildSuggestions方法,刷新布局,因此考虑使用FlutterBuilder管理页面和数据。

final SearchViewModel _viewModel = SearchViewModel();@override
Widget buildSuggestions(BuildContext context) {if (query.isEmpty) {// 这里可以展示热门搜索等,有搜索建议时,热门搜索会被替换成搜索建议return const SizedBox();}return FutureBuilder(future: _viewModel.getSuggest(query),builder: (BuildContext context, AsyncSnapshot<List<Suggest>> snapshot) {if (snapshot.connectionState == ConnectionState.waiting) {// 数据加载中return const Center(child: CircularProgressIndicator());} else if (snapshot.hasError) {// 数据加载错误return Center(child: Text('Error: ${snapshot.error}'));} else if (snapshot.hasData) {// 数据加载成功,展示结果final List<Suggest> searchResults = snapshot.data ?? [];return ListView.builder(padding: EdgeInsets.all(15.r),itemCount: searchResults.length,itemBuilder: (context, index) {return GestureDetector(onTap: () {// 更新输入框query = searchResults[index].text ?? query;showResults(context);},child: Container(padding: EdgeInsets.symmetric(vertical: 10.h),decoration: BoxDecoration(border: BorderDirectional(bottom: BorderSide(width: 0.6,color: Theme.of(context).colorScheme.surfaceContainer,),),),child: Text('${searchResults[index].text}'),),);});} else {// 数据为空return const Center(child: Text('No results found'));}},);
}

实体类代码如下:

class Suggest {Suggest({this.id,this.url,this.text,this.isHot,this.hotLevel,});Suggest.fromJson(dynamic json) {id = json['id'];url = json['url'];text = json['text'];isHot = json['is_hot'];hotLevel = json['hot_level'];}String? id;String? url;String? text;bool? isHot;int? hotLevel;
}

ViewModel代码如下:

class SearchViewModel {Future<List<Suggest>> getSuggest(String keyword) async {if (keyword.isEmpty) {return [];}return await JsonApi.instance().fetchSuggestV3(keyword);}
}

搜索结果

我们需要搜索结果页面支持加载更多,这里用到了 SmartRefrsh 组件

flutter pub add pull_to_refresh

buildResults方法是通过调用showResults(context);方法刷新页面,因此为了方便数据动态变化,新建search_result_page.dart页面

import 'package:e_book_clone/components/book_tile/book_tile_vertical/my_book_tile_vertical_item.dart';
import 'package:e_book_clone/components/book_tile/book_tile_vertical/my_book_tile_vertical_item_skeleton.dart';
import 'package:e_book_clone/components/my_smart_refresh.dart';
import 'package:e_book_clone/models/book.dart';
import 'package:e_book_clone/models/types.dart';
import 'package:e_book_clone/pages/search/search_vm.dart';
import 'package:e_book_clone/utils/navigator_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';class SearchResultPage extends StatefulWidget {final String query; // 请求参数const SearchResultPage({super.key, required this.query});@overrideState<SearchResultPage> createState() => _SearchResultPageState();
}class _SearchResultPageState extends State<SearchResultPage> {final RefreshController _refreshController = RefreshController();final SearchViewModel _viewModel = SearchViewModel();void loadOrRefresh(bool loadMore) {_viewModel.getResults(widget.query, loadMore).then((_) {if (loadMore) {_refreshController.loadComplete();} else {_refreshController.refreshCompleted();}});}@overridevoid initState() {super.initState();loadOrRefresh(false);}@overridevoid dispose() {_viewModel.isDispose = true;_refreshController.dispose();super.dispose();}@overrideWidget build(BuildContext context) {return ChangeNotifierProvider<SearchViewModel>.value(value:  _viewModel,builder: (context, child) {return Consumer<SearchViewModel>(builder: (context, vm, child) {List<Book>? searchResult = vm.searchResult;// 下拉刷新和上拉加载组件return MySmartRefresh(enablePullDown: false,onLoading: () {loadOrRefresh(true);},controller: _refreshController,child: ListView.builder(padding: EdgeInsets.only(left: 15.w, right: 15.w, top: 15.h),itemCount: searchResult?.length ?? 10,itemBuilder: (context, index) {if (searchResult == null) {// 骨架屏 return MyBookTileVerticalItemSkeleton(width: 80.w, height: 120.h);}// 结果渲染组件return MyBookTileVerticalItem(book: searchResult[index],width: 80.w,height: 120.h,onTap: (id) {NavigatorUtils.nav2Detail(context, DetailPageType.ebook, searchResult[index]);},);},),);},);},);}
}

MySmartRefresh组件代码如下,主要是对SmartRefresher做了进一步的封装

import 'package:flutter/material.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';class MySmartRefresh extends StatelessWidget {// 启用下拉final bool? enablePullDown;// 启用上拉final bool? enablePullUp;// 头布局final Widget? header;// 尾布局final Widget? footer;// 刷新事件final VoidCallback? onRefresh;// 加载事件final VoidCallback? onLoading;// 刷新组件控制器final RefreshController controller;final ScrollController? scrollController;// 被刷新的子组件final Widget child;const MySmartRefresh({super.key,this.enablePullDown,this.enablePullUp,this.header,this.footer,this.onLoading,this.onRefresh,required this.controller,required this.child,this.scrollController,});@overrideWidget build(BuildContext context) {return _refreshView();}Widget _refreshView() {return SmartRefresher(scrollController: scrollController,controller: controller,enablePullDown: enablePullDown ?? true,enablePullUp: enablePullUp ?? true,header: header ?? const ClassicHeader(),footer: footer ?? const ClassicFooter(),onRefresh: onRefresh,onLoading: onLoading,child: child,);}
}

SearchViewModel 代码如下:

import 'package:e_book_clone/http/spider/json_api.dart';
import 'package:e_book_clone/models/book.dart';
import 'package:e_book_clone/models/query_param.dart';
import 'package:e_book_clone/models/suggest.dart';
import 'package:flutter/material.dart';class SearchViewModel extends ChangeNotifier {int _currPage = 2;bool isDispose = false;List<Book>? _searchResult;List<Book>? get searchResult => _searchResult;Future<List<Suggest>> getSuggest(String keyword) async {if (keyword.isEmpty) {return [];}return await JsonApi.instance().fetchSuggestV3(keyword);}Future getResults(String keyword, bool loadMore, {VoidCallback? callback}) async {if (loadMore) {_currPage++;} else {_currPage = 1;_searchResult?.clear();}// 请求参数SearchParam param = SearchParam(page: _currPage,rootKind: null,q: keyword,sort: "defalut",query: SearchParam.ebookSearch,);// 请求结果List<Book> res = await JsonApi.instance().fetchEbookSearch(param);// 加载更多,使用addAllif (_searchResult == null) {_searchResult = res;} else {_searchResult!.addAll(res);}if (res.isEmpty && _currPage > 0) {_currPage--;}// 防止Provider被销毁,数据延迟请求去通知报错if (isDispose) return;notifyListeners();}
}

buildResults方法如下:

@override
Widget buildResults(BuildContext context) {if (query.isEmpty) {return const SizedBox();}return SearchResultPage(query: query);
}

显示搜索界面

注意调用的是我们自己拷贝修改的MySearchDelegate中的方法

onTap: () {showMySearch(context: context, delegate: SearchPage());
},

更多内容见

  • EBook - 电子书城

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hjln.cn/news/47295.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈,一经查实,立即删除!

相关文章

.NET Core 3.x 基于AspectCore实现AOP,实现事务、缓存拦截器

最近想给我的框架加一种功能,就是比如给一个方法加一个事务的特性Attribute,那这个方法就会启用事务处理。给一个方法加一个缓存特性,那这个方法就会进行缓存。这个也是网上说的面向切面编程AOP。AOP的概念也很好理解,跟中间件差不多,说白了,就是我可以任意地在方法的前面…

内存调优实战

实战篇1、内存调优1.1 内存溢出和内存泄漏内存泄漏(memory leak):在Java中如果不再使用一个对象,但是该对象依然在GC ROOT的引用链上,这个对象就不会被垃圾回收器回收,这种情况就称之为内存泄漏。内存泄漏绝大多数情况都是由堆内存泄漏引起的,所以后续没有特别说明则讨论…

团队开发日记4

日期:2024年5月11日标题:校园兼职招聘系统开发日记 - 收藏功能和邮箱功能实现 项目概述:今天我们完成了用户收藏功能和邮箱功能的设计和实现,增强了系统的个性化和通知能力。 当天的工作内容:李健龙负责了用户收藏兼职和帖子的页面设计和数据存储逻辑。 郑盾实现了系统的邮…

团队开发日记2

日期:2024年5月5日标题:校园兼职招聘系统开发日记 - 兼职发布与搜索功能实现 项目概述:今天我们重点完成了兼职发布和搜索功能的初步实现,用户可以发布兼职信息并进行基本的搜索。 当天的工作内容:李健龙完成了兼职信息录入页面的前端设计和部分后端逻辑的编写。 郑盾实现…

[模式识别复习笔记] 第4章 SVM

1. SVM 简介 1.1 SVM 支持向量机 给定如图所示的线性可分训练集,能够将两类样本正确分开的直线很多。 感知机算法可以找到一条直线,且找到的直线不唯一。 然而感知机无法确定哪一条直线最优,但是 \(\text{SVM}\) 可以。 \(\text{SVM}\) 可以找到能够将训练样本正确分类的直线…

react 18 基础教程

1.React开发环境搭建执行 npx create-react-app 项目名称 命令来创建项目 2.实现列表渲染在react中可以通过在{}中写入js表达式来执行js代码,所以可以通过如下手段来执行来实现列表的渲染。 function App() {let list = [{id:1,name:"Vue"},{id:2,name:"React&…

成为MySQL DBA后,再看ORACLE数据库(十一、闪回技术)

前文说到ORACLE通过undo实现数据的多版本模型,同样的道理ORACLE还通过undo实现了闪回查询的特性,本文将总结ORACLE的几种闪回技术。闪回技术是Oracle数据库独有的特性,支持各级恢复,包括行、事务、表、表空间和数据库范围。采用闪回技术,可以针对行级和事务级发生过变化的…

KD散件组装的几种形式

介绍 KD指散件组装,英文全称为:knockdown。 KD有三种形式: CKD 、 SKD 、 DKD 。 CKD (Complete Knockdown)为全散件组装, SKD(Semi-Knockdown) 则是半散件组装,一部分总成是现成的。 DKD(Direct Knockdown) 直接组装或成品组装,如手机组装生产中,单机头整体进口,安装配…