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

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]),
    );
  }
}

问题原因

  1. 每次setState触发build时,会重新创建全新的OverlayEntry和全局Key,旧的OverlayEntry被直接丢弃,动画状态无法延续
  2. 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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.01 18:46:01