You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何实现YouTube式搜索栏:用箭头键选ListView项,FocusNode用法困惑

实现带键盘箭头导航的搜索结果ListView(类似YouTube搜索栏)

核心实现思路

要实现类似YouTube的搜索栏箭头键导航,核心是监听搜索栏的焦点状态与键盘事件,配合FocusNode管理ListView条目的焦点,同时维护选中索引并联动滚动。

FocusNode的正确用法拆解

  1. 分层管理FocusNode:给搜索栏单独绑定一个FocusNode,给每个ListView条目也创建独立的FocusNode列表。
  2. 动态维护条目FocusNode:搜索结果更新时,同步创建/销毁对应数量的条目FocusNode,避免内存泄漏。
  3. 键盘事件绑定时机:仅当搜索栏获得焦点时,才注册键盘事件监听器;失去焦点时立即移除,避免影响其他交互。

完整实现代码

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.06 03:43:11