如何使用GlobalObjectKey滚动到指定组件?解决重复键异常
问题描述
我有一个带横向滚动ChoiceChip列表的页面,点击Chip时用Scrollable.ensureVisible配合GlobalObjectKey实现滚动到选中项,功能正常。但通过Navigator.pushReplacement从当前页面重新打开自身时,会抛出Duplicate GlobalKeys detected in widget tree错误(先打开其他页面再进入则无此问题),应用没崩溃但错误日志一直存在。
最小复现代码
import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: const MyHomePage(title: 'Flutter Demo Home Page'), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); final String title; @override State<MyHomePage> createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { final ScrollController _chipsScrollController = ScrollController(); String _currentChip = 'Chip 1'; static const _chips = [ 'Chip 1', 'Chip 2', 'Chip 3', 'Chip 4', 'Chip 5', 'Chip 6', 'Chip 7', 'Chip 8', ]; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: SizedBox( height: 70, child: ListView.separated( controller: _chipsScrollController, physics: const ClampingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), scrollDirection: Axis.horizontal, itemCount: _chips.length, itemBuilder: (final BuildContext context, final int index) { return ChoiceChip( key: GlobalObjectKey(_chips[index]), label: Text(_chips[index]), selected: _chips[index] == _currentChip, onSelected: (final bool value) { _currentChip = _chips[index]; Scrollable.ensureVisible( GlobalObjectKey(_chips[index]).currentContext!, alignment: 0.5, ); }); }, separatorBuilder: (final BuildContext context, final int index) => const SizedBox(width: 12), ), ), ), floatingActionButton: FloatingActionButton( onPressed: () => Navigator.pushReplacement( context, MaterialPageRoute( builder: (_) => const MyHomePage( title: 'Flutter Demo Home Page', ), ), ), tooltip: 'Increment', child: const Icon(Icons.add), ), ); } }
问题原因
GlobalObjectKey的唯一性依赖传入的对象实例。这里_chips是静态常量字符串,新旧页面的ChoiceChip会基于同一个字符串对象创建GlobalObjectKey。而Navigator.pushReplacement执行时,旧页面的widget树不会立即销毁(存在短暂的生命周期重叠),导致两棵树中出现相同的全局key,触发错误。
解决方案
方案1:用ValueKey+页面唯一标识避免全局key重复
给每个MyHomePage实例添加唯一ID,结合ValueKey确保每个页面的Chip key全局唯一:
- 修改
MyHomePage类,添加可选的pageId参数:
class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title, this.pageId}); final String title; final String? pageId; @override State<MyHomePage> createState() => _MyHomePageState(); }
- 在State中初始化唯一ID,构建Chip的key时组合页面ID和Chip文本:
class _MyHomePageState extends State<MyHomePage> { late final String _pageId; final ScrollController _chipsScrollController = ScrollController(); String _currentChip = 'Chip 1'; static const _chips = [ 'Chip 1', 'Chip 2', 'Chip 3', 'Chip 4', 'Chip 5', 'Chip 6', 'Chip 7', 'Chip 8', ]; @override void initState() { super.initState(); // 优先使用传入的pageId,否则生成唯一值 _pageId = widget.pageId ?? UniqueKey().toString(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(widget.title)), body: Center( child: SizedBox( height: 70, child: ListView.separated( controller: _chipsScrollController, physics: const ClampingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), scrollDirection: Axis.horizontal, itemCount: _chips.length, itemBuilder: (context, index) { final chipText = _chips[index]; return ChoiceChip( key: ValueKey('${_pageId}_$chipText'), label: Text(chipText), selected: chipText == _currentChip, onSelected: (value) { setState(() => _currentChip = chipText); // 直接用当前context获取目标Element Scrollable.ensureVisible( context.findAncestorWidgetOfExactType<ChoiceChip>()!.context, alignment: 0.5, ); }, ); }, separatorBuilder: (_, __) => const SizedBox(width: 12), ), ), ), floatingActionButton: FloatingActionButton( onPressed: () => Navigator.pushReplacement( context, MaterialPageRoute( builder: (_) => MyHomePage( title: 'Flutter Demo Home Page', pageId: UniqueKey().toString(), ), ), ), child: const Icon(Icons.add), ), ); } }
方案2:不使用GlobalKey,通过ScrollController计算滚动位置
完全绕开GlobalKey,利用ScrollController和RenderBox计算目标Chip的位置,手动控制滚动:
class _MyHomePageState extends State<MyHomePage> { final ScrollController _chipsScrollController = ScrollController(); String _currentChip = 'Chip 1'; static const _chips = [ 'Chip 1', 'Chip 2', 'Chip 3', 'Chip 4', 'Chip 5', 'Chip 6', 'Chip 7', 'Chip 8', ]; // 用本地GlobalKey存储每个Chip的引用(仅用于获取RenderBox,不会全局冲突) final Map<String, GlobalKey> _chipLocalKeys = {}; @override void initState() { super.initState(); // 为每个Chip初始化本地GlobalKey for (final chip in _chips) { _chipLocalKeys[chip] = GlobalKey(); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(widget.title)), body: Center( child: SizedBox( height: 70, child: ListView.separated( controller: _chipsScrollController, physics: const ClampingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), scrollDirection: Axis.horizontal, itemCount: _chips.length, itemBuilder: (context, index) { final chipText = _chips[index]; return ChoiceChip( key: _chipLocalKeys[chipText], label: Text(chipText), selected: chipText == _currentChip, onSelected: (value) { setState(() => _currentChip = chipText); _scrollToTargetChip(chipText); }, ); }, separatorBuilder: (_, __) => const SizedBox(width: 12), ), ), ), floatingActionButton: FloatingActionButton( onPressed: () => Navigator.pushReplacement( context, MaterialPageRoute( builder: (_) => const MyHomePage( title: 'Flutter Demo Home Page', ), ), ), child: const Icon(Icons.add), ), ); } void _scrollToTargetChip(String chipText) { final targetKey = _chipLocalKeys[chipText]; if (targetKey?.currentContext == null) return; // 获取目标Chip的RenderBox和位置信息 final renderBox = targetKey!.currentContext!.findRenderObject() as RenderBox; final screenWidth = MediaQuery.of(context).size.width; final chipWidth = renderBox.size.width; // 计算目标滚动偏移,让Chip居中显示 final targetOffset = renderBox.localToGlobal(Offset.zero).dx + _chipsScrollController.offset - (screenWidth / 2) + (chipWidth / 2); _chipsScrollController.animateTo( targetOffset, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } }
总结
方案1通过给页面添加唯一标识确保全局key不重复,方案2完全避开GlobalKey的全局特性,手动计算滚动位置。推荐方案2,逻辑更简洁,不需要额外管理页面标识。
内容的提问来源于stack exchange,提问作者Abigotado
相关产品推荐
相关产品推荐

