如何实现YouTube式搜索栏:用箭头键选ListView项,FocusNode用法困惑
实现带键盘箭头导航的搜索结果ListView(类似YouTube搜索栏)
核心实现思路
要实现类似YouTube的搜索栏箭头键导航,核心是监听搜索栏的焦点状态与键盘事件,配合FocusNode管理ListView条目的焦点,同时维护选中索引并联动滚动。
FocusNode的正确用法拆解
- 分层管理FocusNode:给搜索栏单独绑定一个
FocusNode,给每个ListView条目也创建独立的FocusNode列表。 - 动态维护条目FocusNode:搜索结果更新时,同步创建/销毁对应数量的条目FocusNode,避免内存泄漏。
- 键盘事件绑定时机:仅当搜索栏获得焦点时,才注册键盘事件监听器;失去焦点时立即移除,避免影响其他交互。
完整实现代码
import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class SearchBarWithArrowNavigation extends StatefulWidget { const SearchBarWithArrowNavigation({super.key}); @override State<SearchBarWithArrowNavigation> createState() => _SearchBarWithArrowNavigationState(); } class _SearchBarWithArrowNavigationState extends State<SearchBarWithArrowNavigation> { final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); List<String> _searchResults = []; int _selectedIndex = -1; List<FocusNode> _itemFocusNodes = []; final ScrollController _scrollController = ScrollController(); @override void initState() { super.initState(); _searchController.addListener(_onSearchTextChanged); _searchFocusNode.addListener(_onSearchFocusChanged); } void _onSearchTextChanged() { setState(() { if (_searchController.text.isEmpty) { _searchResults = []; _selectedIndex = -1; _itemFocusNodes.clear(); } else { // 模拟搜索接口返回结果 _searchResults = List.generate(10, (index) => "搜索结果 ${index + 1}: ${_searchController.text}"); _itemFocusNodes = List.generate(_searchResults.length, (index) => FocusNode()); } }); } void _onSearchFocusChanged() { if (_searchFocusNode.hasFocus) { RawKeyboard.instance.addListener(_handleKeyEvent); } else { RawKeyboard.instance.removeListener(_handleKeyEvent); } } void _handleKeyEvent(RawKeyEvent event) { if (_searchResults.isEmpty || !_searchFocusNode.hasFocus) return; if (event is RawKeyDownEvent) { if (event.logicalKey == LogicalKeyboardKey.arrowDown) { _moveSelection(1); } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { _moveSelection(-1); } } } void _moveSelection(int delta) { int newIndex = _selectedIndex + delta; // 边界处理:循环切换(可根据需求改为停在首尾) if (newIndex < 0) newIndex = _searchResults.length - 1; else if (newIndex >= _searchResults.length) newIndex = 0; // 取消上一个条目的焦点 if (_selectedIndex != -1) { _itemFocusNodes[_selectedIndex].unfocus(); } setState(() { _selectedIndex = newIndex; }); // 新条目获取焦点并滚动到可见区域 _itemFocusNodes[newIndex].requestFocus(); _scrollController.animateTo( newIndex * 56.0, // 假设ListTile高度为56,可根据实际调整 duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, ); } @override void dispose() { _searchController.dispose(); _searchFocusNode.dispose(); for (final node in _itemFocusNodes) node.dispose(); _scrollController.dispose(); RawKeyboard.instance.removeListener(_handleKeyEvent); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: TextField( controller: _searchController, focusNode: _searchFocusNode, decoration: const InputDecoration( hintText: "搜索...", border: InputBorder.none, ), ), ), body: _searchResults.isEmpty ? const Center(child: Text("请输入关键词搜索")) : ListView.builder( controller: _scrollController, itemCount: _searchResults.length, itemBuilder: (context, index) { return ListTile( focusNode: _itemFocusNodes[index], title: Text(_searchResults[index]), selected: _selectedIndex == index, selectedTileColor: Colors.grey[200], onTap: () => debugPrint("选中条目: ${_searchResults[index]}"), ); }, ), ); } }
关键细节提示
- 内存泄漏防范:所有FocusNode必须在组件dispose时逐个销毁,包括搜索栏和条目节点。
- 滚动适配:如果条目高度不固定,可使用
Scrollable.ensureVisible替代固定高度的滚动计算,确保条目完全可见。 - 交互优化:可以添加回车触发条目点击的逻辑,在键盘事件中监听
LogicalKeyboardKey.enter,调用对应条目的点击回调。
内容的提问来源于stack exchange,提问作者arkarwine
相关产品推荐
相关产品推荐

