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

如何使用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全局唯一:

  1. 修改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();
}
  1. 在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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.25 05:47:51