vb_search_select.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. import 'package:chicken_farm/components/vb_select.dart';
  2. import 'package:chicken_farm/core/utils/logger.dart';
  3. import 'package:flutter/material.dart';
  4. /// 搜索API函数签名
  5. /// T 为数据类型
  6. typedef SearchApiFunction<T> =
  7. Future<Map<String, dynamic>> Function({
  8. required Map<String, dynamic> queryParams,
  9. });
  10. /// 选项转换器,将数据类型T转换为SelectOption
  11. typedef SelectOptionConverter<T> = SelectOption Function(T data);
  12. /// 搜索选择组件
  13. class VberSearchSelect<T> extends StatefulWidget {
  14. /// 搜索API函数
  15. final SearchApiFunction<T> searchApi;
  16. /// 数据转换方法,将原始数据转换为SelectOption
  17. final SelectOptionConverter<T> converter;
  18. /// 根据value获取对应label的函数
  19. final Future<String?> Function(String?)? getValueLabel;
  20. /// 当前选中的值
  21. final String? value;
  22. /// 值改变回调
  23. final ValueChanged<String?>? onChanged;
  24. /// 提示文字
  25. final String? hint;
  26. /// 是否启用
  27. final bool enabled;
  28. /// 每页数据条数
  29. final int pageSize;
  30. /// 额外参数,用于传递给搜索API
  31. final Map<String, dynamic>? extraParams;
  32. const VberSearchSelect({
  33. super.key,
  34. required this.searchApi,
  35. required this.converter,
  36. this.getValueLabel,
  37. this.value,
  38. this.onChanged,
  39. this.hint,
  40. this.enabled = true,
  41. this.pageSize = 3,
  42. this.extraParams,
  43. });
  44. @override
  45. State<VberSearchSelect<T>> createState() => _VberSearchSelectState<T>();
  46. }
  47. class _VberSearchSelectState<T> extends State<VberSearchSelect<T>>
  48. with SingleTickerProviderStateMixin {
  49. final TextEditingController _textController = TextEditingController();
  50. final TextEditingController _searchController = TextEditingController();
  51. final FocusNode _focusNode = FocusNode();
  52. final FocusNode _searchFocusNode = FocusNode(); // 添加搜索框的FocusNode
  53. final LayerLink _layerLink = LayerLink();
  54. OverlayEntry? _overlayEntry;
  55. List<T> _items = [];
  56. bool _isLoading = false;
  57. bool _isOverlayVisible = false;
  58. String? _selectedValue;
  59. SelectOption? _selectedOption; // 新增:用来存储选中的选项
  60. int _currentPage = 1;
  61. bool _hasMore = true;
  62. String _lastSearchText = '';
  63. @override
  64. void initState() {
  65. super.initState();
  66. _selectedValue = widget.value;
  67. _initTextController();
  68. _focusNode.addListener(_onFocusChange);
  69. }
  70. /// 初始化文本控制器
  71. void _initTextController() async {
  72. if (widget.value != null && widget.getValueLabel != null) {
  73. try {
  74. final label = await widget.getValueLabel!(widget.value);
  75. if (mounted) {
  76. setState(() {
  77. _textController.text = label ?? widget.value ?? '';
  78. });
  79. }
  80. } catch (e) {
  81. logger.e('Failed to get label for value: ${widget.value}', e);
  82. }
  83. }
  84. }
  85. @override
  86. void didUpdateWidget(covariant VberSearchSelect<T> oldWidget) {
  87. super.didUpdateWidget(oldWidget);
  88. // 当外部传入的value发生变化时,同步更新内部状态
  89. if (oldWidget.value != widget.value) {
  90. _syncValueAndText(widget.value);
  91. }
  92. }
  93. /// 同步外部value和内部_selectedValue以及_textController.text
  94. void _syncValueAndText(String? newValue) async {
  95. setState(() {
  96. _selectedValue = newValue;
  97. });
  98. // 优先使用存储的选项
  99. String? label;
  100. if (_selectedOption != null && _selectedOption!.value == newValue) {
  101. label = _selectedOption!.label;
  102. }
  103. // 尝试在现有选项中查找匹配的标签
  104. if (label == null && newValue != null) {
  105. for (var item in _items) {
  106. final option = widget.converter(item);
  107. if (option.value == newValue) {
  108. label = option.label;
  109. break;
  110. }
  111. }
  112. }
  113. // 如果在现有选项中没找到,且提供了getValueLabel函数,则尝试通过它获取
  114. if (label == null && newValue != null && widget.getValueLabel != null) {
  115. try {
  116. label = await widget.getValueLabel!(newValue);
  117. } catch (e) {
  118. logger.e('Failed to get label for value: $newValue', e);
  119. }
  120. }
  121. // 更新文本显示控件
  122. if (mounted) {
  123. setState(() {
  124. // 优先级: 找到的label > value本身 > 空字符串
  125. _textController.text = label ?? newValue ?? '';
  126. });
  127. }
  128. }
  129. @override
  130. void dispose() {
  131. _textController.dispose();
  132. _searchController.dispose();
  133. _focusNode.removeListener(_onFocusChange);
  134. _focusNode.dispose();
  135. _searchFocusNode.dispose(); // 释放搜索框的FocusNode
  136. _overlayEntry?.remove();
  137. super.dispose();
  138. }
  139. void _onFocusChange() {
  140. // 只有当_focusNode获得焦点且组件启用时才显示弹出层
  141. if (_focusNode.hasFocus && widget.enabled) {
  142. // 添加一个微小的延迟,避免焦点竞争
  143. Future.microtask(() {
  144. if (_focusNode.hasFocus && mounted) {
  145. _showOverlay();
  146. }
  147. });
  148. }
  149. }
  150. void _showOverlay() {
  151. if (_isOverlayVisible) return;
  152. _overlayEntry = _createOverlayEntry();
  153. Overlay.of(context).insert(_overlayEntry!);
  154. _isOverlayVisible = true;
  155. // 清空搜索框
  156. _searchController.clear();
  157. _items.clear();
  158. _currentPage = 1;
  159. _hasMore = true;
  160. _lastSearchText = '';
  161. _performSearch('', (callback) => setState(() => callback()));
  162. WidgetsBinding.instance.addPostFrameCallback((_) {
  163. if (_searchFocusNode.canRequestFocus && mounted) {
  164. _searchFocusNode.requestFocus();
  165. }
  166. });
  167. }
  168. void _hideOverlay() {
  169. _overlayEntry?.remove();
  170. _overlayEntry = null;
  171. _isOverlayVisible = false;
  172. _searchFocusNode.unfocus();
  173. _lastSearchText = '';
  174. _items.clear();
  175. }
  176. OverlayEntry _createOverlayEntry() {
  177. // 获取选择框的大小和位置
  178. final renderBox = context.findRenderObject() as RenderBox;
  179. final size = renderBox.size;
  180. final offset = renderBox.localToGlobal(Offset.zero);
  181. return OverlayEntry(
  182. builder: (context) => Positioned(
  183. left: offset.dx,
  184. top: offset.dy + size.height + 5,
  185. width: size.width,
  186. child: Material(
  187. elevation: 4,
  188. borderRadius: BorderRadius.circular(4),
  189. child: Container(
  190. constraints: BoxConstraints(
  191. maxHeight: MediaQuery.of(context).size.height * 0.4,
  192. ),
  193. child: StatefulBuilder(
  194. builder: (context, setState) {
  195. return Column(
  196. mainAxisSize: MainAxisSize.min,
  197. children: [
  198. Padding(
  199. padding: const EdgeInsets.all(16.0),
  200. child: TextField(
  201. key: ValueKey('search_text_field_${widget.hint ?? ''}_${UniqueKey().toString()}'),
  202. controller: _searchController,
  203. focusNode: _searchFocusNode,
  204. autofocus: false,
  205. decoration: InputDecoration(
  206. hintText: '请输入${widget.hint}搜索关键词',
  207. contentPadding: EdgeInsets.symmetric(
  208. horizontal: 12,
  209. vertical: 8,
  210. ),
  211. border: OutlineInputBorder(
  212. borderRadius: BorderRadius.circular(4),
  213. ),
  214. isDense: true,
  215. ),
  216. onChanged: (value) {
  217. Future.delayed(const Duration(milliseconds: 300), () {
  218. if (_lastSearchText != _searchController.text) {
  219. _lastSearchText = _searchController.text;
  220. _items.clear();
  221. _currentPage = 1;
  222. _hasMore = true;
  223. _performSearch(_searchController.text, setState);
  224. } else {}
  225. });
  226. },
  227. ),
  228. ),
  229. if (_isLoading && _items.isEmpty)
  230. Padding(
  231. padding: const EdgeInsets.all(16.0),
  232. child: Center(child: CircularProgressIndicator()),
  233. )
  234. else if (!_isLoading && _items.isEmpty)
  235. Padding(
  236. padding: const EdgeInsets.all(16.0),
  237. child: Center(
  238. child: Text(
  239. '没有查询到结果',
  240. style: TextStyle(color: Colors.grey, fontSize: 14),
  241. ),
  242. ),
  243. )
  244. else
  245. Flexible(
  246. child: NotificationListener<ScrollNotification>(
  247. onNotification: (notification) {
  248. if (notification.metrics.pixels ==
  249. notification.metrics.maxScrollExtent &&
  250. _hasMore &&
  251. !_isLoading) {
  252. _loadMore(setState);
  253. return true;
  254. }
  255. return false;
  256. },
  257. child: Builder(
  258. builder: (BuildContext context) {
  259. return ListView.builder(
  260. padding: EdgeInsets.zero,
  261. shrinkWrap: true,
  262. itemCount: _items.length + (_hasMore ? 1 : 0),
  263. itemBuilder: (context, index) {
  264. if (index == _items.length) {
  265. // 显示加载更多指示器
  266. return Padding(
  267. padding: const EdgeInsets.all(12.0),
  268. child: Center(
  269. child: _isLoading
  270. ? CircularProgressIndicator()
  271. : Text('上拉加载更多'),
  272. ),
  273. );
  274. }
  275. final item = _items[index];
  276. final option = widget.converter(item);
  277. return ListTile(
  278. title: Text(option.label),
  279. onTap: () => _selectItem(option, item),
  280. dense: true,
  281. contentPadding: EdgeInsets.symmetric(
  282. horizontal: 16,
  283. vertical: 4,
  284. ),
  285. visualDensity: VisualDensity.compact,
  286. );
  287. },
  288. );
  289. },
  290. ),
  291. ),
  292. ),
  293. ],
  294. );
  295. },
  296. ),
  297. ),
  298. ),
  299. ),
  300. );
  301. }
  302. void _loadMore(StateSetter overlaySetState) async {
  303. if (!_hasMore || _isLoading) return;
  304. overlaySetState(() {
  305. _isLoading = true;
  306. });
  307. try {
  308. _currentPage++;
  309. final result = await _fetchData(_currentPage);
  310. final List<dynamic> rows = result['rows'] is List ? result['rows'] : [];
  311. final int total = result['total'] is int ? result['total'] : 0;
  312. final newItems = rows.cast<T>().toList();
  313. if (mounted) {
  314. overlaySetState(() {
  315. _items.addAll(newItems);
  316. _hasMore = _items.length < total;
  317. _isLoading = false;
  318. });
  319. }
  320. } catch (e) {
  321. logger.e('加载更多失败:$e');
  322. if (mounted) {
  323. overlaySetState(() {
  324. _currentPage--; // 回退页码
  325. _isLoading = false;
  326. });
  327. }
  328. }
  329. }
  330. void _performSearch(String keyword, StateSetter overlaySetState) async {
  331. overlaySetState(() {
  332. _isLoading = true;
  333. });
  334. try {
  335. _currentPage = 1;
  336. _hasMore = true;
  337. final result = await _fetchData(_currentPage, keyword: keyword);
  338. final List<dynamic> rows = result['rows'] is List ? result['rows'] : [];
  339. final int total = result['total'] is int ? result['total'] : 0;
  340. final newItems = rows.cast<T>().toList();
  341. if (mounted) {
  342. overlaySetState(() {
  343. _items = newItems;
  344. _hasMore = _items.length < total;
  345. _isLoading = false;
  346. });
  347. }
  348. } catch (e) {
  349. logger.e('搜索失败:$e');
  350. if (mounted) {
  351. overlaySetState(() {
  352. _items = [];
  353. _hasMore = false;
  354. _isLoading = false;
  355. });
  356. }
  357. }
  358. }
  359. Future<Map<String, dynamic>> _fetchData(
  360. int pageNum, {
  361. String? keyword,
  362. }) async {
  363. final Map<String, dynamic> queryParams = {
  364. 'pageSize': widget.pageSize,
  365. 'pageNum': pageNum,
  366. };
  367. if (keyword != null) {
  368. queryParams['keyword'] = keyword;
  369. } else if (keyword == null && _searchController.text.isNotEmpty) {
  370. // 如果keyword参数为null但_searchController中有内容,则使用该内容
  371. queryParams['keyword'] = _searchController.text;
  372. }
  373. if (widget.extraParams != null) {
  374. queryParams.addAll(widget.extraParams!);
  375. }
  376. final r = await widget.searchApi(queryParams: queryParams);
  377. return r;
  378. }
  379. void _selectItem(SelectOption option, T item) {
  380. setState(() {
  381. _textController.text = option.label;
  382. _selectedValue = option.value;
  383. _selectedOption = option; // 保存选中的选项
  384. // 检查当前选中的项是否已经在列表中,如果不在则添加到列表开头
  385. // 这样可以在后续通过value同步label时找到对应的项
  386. bool itemExists = _items.any((element) {
  387. final elementOption = widget.converter(element);
  388. return elementOption.value == option.value;
  389. });
  390. if (!itemExists) {
  391. // 将选中项插入到列表开头,方便快速查找
  392. _items.insert(0, item);
  393. }
  394. });
  395. widget.onChanged?.call(option.value);
  396. _hideOverlay();
  397. // 不需要在下一帧失去焦点,立即失去焦点可能会导致一些问题
  398. // 使用post frame callback确保overlay完全隐藏后再失去焦点
  399. WidgetsBinding.instance.addPostFrameCallback((_) {
  400. if (mounted) {
  401. _focusNode.unfocus();
  402. }
  403. });
  404. }
  405. void _clearSelection() {
  406. setState(() {
  407. _textController.clear();
  408. _selectedValue = null;
  409. _selectedOption = null; // 清除保存的选项
  410. });
  411. widget.onChanged?.call(null);
  412. }
  413. @override
  414. Widget build(BuildContext context) {
  415. return GestureDetector(
  416. onTap: () {
  417. // 点击时请求焦点
  418. if (widget.enabled) {
  419. _focusNode.requestFocus();
  420. }
  421. },
  422. child: CompositedTransformTarget(
  423. link: _layerLink,
  424. child: TextField(
  425. controller: _textController,
  426. focusNode: _focusNode,
  427. readOnly: true,
  428. enabled: widget.enabled,
  429. decoration: InputDecoration(
  430. hintText: '请选择${widget.hint ?? ""}...',
  431. suffixIcon: _selectedValue != null
  432. ? IconButton(
  433. icon: Icon(Icons.clear, size: 20),
  434. onPressed: _clearSelection,
  435. )
  436. : Icon(Icons.arrow_drop_down),
  437. ),
  438. ),
  439. ),
  440. );
  441. }
  442. }