import 'dart:async'; import 'package:flutter/material.dart'; /// 轻量级异步搜索控制器,配合 [SearchBar] 使用。 /// /// 替代已停维的 `flappy_search_bar` 的 `SearchBarController`。 /// 保留泛型 `` 以兼容调用方 `SearchBarController()` 写法。 /// [replayLastSearch] 用于“加载更多 / 分页”:外部 page++ 后触发重跑上一次搜索。 class SearchBarController { String lastQuery = ''; VoidCallback? _replay; void _register(VoidCallback replay) { _replay = replay; } /// 重新执行上一次搜索(分页加载更多时调用)。 void replayLastSearch() => _replay?.call(); } /// 轻量级 Web 异步搜索栏,替代已停维的 `flappy_search_bar` 的 `SearchBar`。 /// /// 基本流程:输入达到 [minimumChars] 个字符后,调用 [onSearch] 取结果, /// 用 [onItemFound] 渲染每一条;无结果时显示 [emptyWidget];出错显示 [onError]。 /// /// 注意:`searchBarController` 参数刻意使用裸类型(`SearchBarController?`), /// 以兼容调用方 `SearchBarController _controller = SearchBarController();` /// 的写法(Dart 3 sound 类型下避免泛型不变性报错)。 class SearchBar extends StatefulWidget { final SearchBarController? searchBarController; final int minimumChars; final String? hintText; final Widget? cancellationWidget; final Future> Function(String text) onSearch; final Widget Function(T item, int index) onItemFound; final Widget Function(dynamic error)? onError; final Widget? emptyWidget; final Duration debounceDuration; const SearchBar({ super.key, this.searchBarController, this.minimumChars = 2, this.hintText, this.cancellationWidget, required this.onSearch, required this.onItemFound, this.onError, this.emptyWidget, this.debounceDuration = const Duration(milliseconds: 350), }); @override State> createState() => _SearchBarState(); } class _SearchBarState extends State> { final TextEditingController _textController = TextEditingController(); final FocusNode _focusNode = FocusNode(); Timer? _debounce; List _results = []; bool _loading = false; dynamic _error; bool _hasSearched = false; @override void initState() { super.initState(); widget.searchBarController?._register(_runSearch); _textController.addListener(_onTextChanged); } @override void dispose() { _debounce?.cancel(); _textController.dispose(); _focusNode.dispose(); super.dispose(); } void _onTextChanged() { final text = _textController.text; _debounce?.cancel(); if (text.length >= widget.minimumChars) { _debounce = Timer(widget.debounceDuration, _runSearch); } else if (text.isEmpty) { setState(() { _results = []; _hasSearched = false; _error = null; }); } } Future _runSearch() async { final query = _textController.text; if (query.length < widget.minimumChars) return; if (widget.searchBarController != null) { widget.searchBarController!.lastQuery = query; } setState(() { _loading = true; _error = null; _hasSearched = true; }); try { final results = await widget.onSearch(query); if (mounted) { setState(() { _results = results; _loading = false; }); } } catch (e) { if (mounted) { setState(() { _error = e; _loading = false; }); } } } void _cancel() { _textController.clear(); _focusNode.unfocus(); setState(() { _results = []; _hasSearched = false; _error = null; }); } @override Widget build(BuildContext context) { return Column( children: [ TextField( controller: _textController, focusNode: _focusNode, decoration: InputDecoration( hintText: widget.hintText, prefixIcon: const Icon(Icons.search), suffixIcon: _textController.text.isNotEmpty ? GestureDetector( onTap: _cancel, child: widget.cancellationWidget ?? const Icon(Icons.close, size: 20), ) : null, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), contentPadding: const EdgeInsets.symmetric(horizontal: 12), ), ), const SizedBox(height: 8), Expanded(child: _buildBody()), ], ); } Widget _buildBody() { if (_loading) { return const Center(child: CircularProgressIndicator()); } if (_error != null) { return widget.onError != null ? widget.onError!(_error) : Center(child: Text('$_error')); } if (!_hasSearched) { return const SizedBox.shrink(); } if (_results.isEmpty) { return widget.emptyWidget ?? const SizedBox.shrink(); } return ListView.builder( itemCount: _results.length, itemBuilder: (BuildContext context, int index) => widget.onItemFound(_results[index], index), ); } }