Flutter中Overlay内AnimatedPositioned坐标变换后无动画问题
Flutter多选切换器动画失效问题
我在开发一款Flutter多选切换器,最初用Stack实现带平移动画的橙色选中指示器,但多语言场景下文本长度不一,固定宽度容易溢出。后来改用Overlay方案:先渲染按钮行,再插入适配按钮宽度的选中指示器。但切换选项时,AnimatedPositioned没有执行平移动画,指示器直接跳转,想要实现平滑移动效果。
简化版代码如下:
class AttributeSwitcher extends StatefulWidget { AttributeSwitcher( {super.key, required this.selected, required this.onSelected}) { if (selected < 0 || selected > 2) { throw Exception('selected must be in [0, 2]'); } } final int selected; final void Function(int) onSelected; @override State<AttributeSwitcher> createState() => _AttributeSwitcherState(); } class _AttributeSwitcherState extends State<AttributeSwitcher> { int _selected = 0; @override void initState() { super.initState(); _selected = widget.selected; } void _updateSelected(int selected) { setState(() { _selected = selected; }); widget.onSelected(selected); } Widget _createSticky({ required double posX, required double posY, required double height, required double width, required ColorScheme colorScheme, }) { return AnimatedPositioned( duration: const Duration(milliseconds: 500), top: posY, left: posX, child: UnconstrainedBox( child: AnimatedContainer( height: height, width: width, decoration: BoxDecoration( color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(80), ), duration: const Duration(milliseconds: 500), ), )); } OverlayEntry _createRow({required List<GlobalKey> btnKeys}) { return OverlayEntry( builder: (context) => Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ AttributeBtn( btnKey: btnKeys[0], text: '专注', onPressed: () => _updateSelected(0)), const AttributeSplitter(), AttributeBtn( btnKey: btnKeys[1], text: '小休息', onPressed: () => _updateSelected(1), ), const AttributeSplitter(), AttributeBtn( btnKey: btnKeys[2], text: '大休息', onPressed: () => _updateSelected(2), ), ], ), ); } @override Widget build(BuildContext context) { ThemeData theme = Theme.of(context); ColorScheme colorScheme = theme.colorScheme; TextTheme textTheme = theme.textTheme; List<GlobalKey> btnKeys = [GlobalKey(), GlobalKey(), GlobalKey()]; List<Offset> btnPos = []; List<Size> btnSize = []; GlobalKey overlayKey = GlobalKey(); OverlayEntry row = _createRow(btnKeys: btnKeys); SchedulerBinding.instance.addPostFrameCallback((_) { OverlayState state = overlayKey.currentState as OverlayState; if (overlayKey.currentContext == null) { throw Exception('overlayKey.currentContext is null'); } var overlayPos = (overlayKey.currentContext?.findRenderObject() as RenderBox) .localToGlobal(Offset.zero); for (var element in btnKeys) { if (element.currentContext == null) { throw Exception('element.currentContext is null'); } var readerBox = element.currentContext?.findRenderObject() as RenderBox; var readerSize = readerBox.size; var readerPos = readerBox.localToGlobal(Offset(-overlayPos.dx, -overlayPos.dy)); btnPos.add(readerPos); btnSize.add(readerSize); } state.insert(OverlayEntry(builder: (context) { return _createSticky( posX: btnPos[_selected].dx, posY: btnPos[_selected].dy, height: btnSize[_selected].height, width: btnSize[_selected].width, colorScheme: colorScheme, ); }), below: row); }); return SizedBox( width: 300, height: 50, child: Overlay(key: overlayKey, initialEntries: [row]), ); } }
问题原因
- 每次
setState触发build时,会重新创建全新的OverlayEntry和全局Key,旧的OverlayEntry被直接丢弃,动画状态无法延续 AnimatedPositioned依赖稳定的父布局上下文,直接在PostFrameCallback中插入临时OverlayEntry,没有持续的状态绑定,动画无法触发
修复方案
修复后的代码示例
class AttributeSwitcher extends StatefulWidget { AttributeSwitcher( {super.key, required this.selected, required this.onSelected}) { if (selected < 0 || selected > 2) { throw Exception('selected must be in [0, 2]'); } } final int selected; final void Function(int) onSelected; @override State<AttributeSwitcher> createState() => _AttributeSwitcherState(); } class _AttributeSwitcherState extends State<AttributeSwitcher> { int _selected = 0; late List<GlobalKey> btnKeys; late List<Offset> btnPos; late List<Size> btnSize; late OverlayEntry _rowEntry; OverlayEntry? _stickyEntry; final GlobalKey _overlayKey = GlobalKey(); @override void initState() { super.initState(); _selected = widget.selected; btnKeys = [GlobalKey(), GlobalKey(), GlobalKey()]; btnPos = []; btnSize = []; _rowEntry = _createRow(); WidgetsBinding.instance.addPostFrameCallback((_) { _getButtonPositions(); _updateStickyEntry(); }); } void _getButtonPositions() { if (_overlayKey.currentContext == null) return; final overlayRenderBox = _overlayKey.currentContext!.findRenderObject() as RenderBox; final overlayPos = overlayRenderBox.localToGlobal(Offset.zero); btnPos.clear(); btnSize.clear(); for (final key in btnKeys) { if (key.currentContext == null) continue; final btnRenderBox = key.currentContext!.findRenderObject() as RenderBox; final size = btnRenderBox.size; final pos = btnRenderBox.localToGlobal(Offset(-overlayPos.dx, -overlayPos.dy)); btnPos.add(pos); btnSize.add(size); } } OverlayEntry _createRow() { return OverlayEntry( builder: (context) => Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ AttributeBtn( btnKey: btnKeys[0], text: '专注', onPressed: () => _updateSelected(0)), const AttributeSplitter(), AttributeBtn( btnKey: btnKeys[1], text: '小休息', onPressed: () => _updateSelected(1), ), const AttributeSplitter(), AttributeBtn( btnKey: btnKeys[2], text: '大休息', onPressed: () => _updateSelected(2), ), ], ), ); } void _updateStickyEntry() { if (_stickyEntry != null) { _stickyEntry!.remove(); } _stickyEntry = OverlayEntry( builder: (context) => AnimatedBuilder( animation: AlwaysStoppedAnimation(_selected), builder: (context, child) { return AnimatedPositioned( duration: const Duration(milliseconds: 500), top: btnPos[_selected].dy, left: btnPos[_selected].dx, child: UnconstrainedBox( child: AnimatedContainer( height: btnSize[_selected].height, width: btnSize[_selected].width, decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(80), ), duration: const Duration(milliseconds: 500), ), ), ); }, ), ); final overlayState = _overlayKey.currentState as OverlayState; overlayState.insert(_stickyEntry!, below: _rowEntry); } void _updateSelected(int selected) { setState(() { _selected = selected; }); _updateStickyEntry(); widget.onSelected(selected); } @override void dispose() { _rowEntry.remove(); _stickyEntry?.remove(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( width: 300, height: 50, child: Overlay(key: _overlayKey, initialEntries: [_rowEntry]), ); } }
关键修改点
- 将按钮全局Key、位置尺寸数据、OverlayEntry实例提升为State成员,避免build重建时丢失状态
- 在
initState中初始化按钮行的OverlayEntry,仅在选中状态变化时更新指示器Entry - 使用
AnimatedBuilder绑定选中状态,确保动画能感知状态变化并触发平滑过渡 - 增加
dispose方法清理OverlayEntry,避免内存泄漏
内容的提问来源于stack exchange,提问作者lushangkan
相关产品推荐
相关产品推荐

