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

Flutter:自定义AnimationController控制Hero动画及Overlay注入Widget问题

1. 不依赖Navigator的自定义Hero式动画

完全可以脱离Navigator,用自定义AnimationController实现类Hero的共享元素过渡动画。Hero动画的核心是共享元素的位置、大小、透明度等属性的平滑过渡,我们可以手动模拟这个逻辑,无需依赖Navigator的页面跳转机制。

核心实现思路:

  • 通过GlobalKey获取原Widget的初始位置和尺寸(可选,用于更精准的过渡计算)
  • 用AnimationController驱动动画,配合Tween定义过渡的属性范围
  • 借助AnimatedBuilder、SlideTransition、ScaleTransition等组件实时更新Widget的显示状态

举个卡片从列表位置过渡到SliverAppBar的示例:

class CustomHeroDemo extends StatefulWidget {
  const CustomHeroDemo({super.key});

  @override
  State<CustomHeroDemo> createState() => _CustomHeroDemoState();
}

class _CustomHeroDemoState extends State<CustomHeroDemo> with SingleTickerProviderStateMixin {
  late AnimationController _animController;
  late Animation<double> _scaleAnim;
  late Animation<Offset> _offsetAnim;
  late Animation<double> _opacityAnim;

  bool _isAnimating = false;
  final GlobalKey _sourceCardKey = GlobalKey();

  @override
  void initState() {
    super.initState();
    _animController = AnimationController(
      duration: const Duration(milliseconds: 650),
      vsync: this,
    );

    // 定义过渡动画的曲线与属性
    _scaleAnim = Tween<double>(begin: 1.0, end: 0.25).animate(
      CurvedAnimation(parent: _animController, curve: Curves.easeInOutCubic),
    );
    _offsetAnim = Tween<Offset>(begin: Offset.zero, end: const Offset(0, -1.3)).animate(
      CurvedAnimation(parent: _animController, curve: Curves.easeInOut),
    );
    _opacityAnim = Tween<double>(begin: 1.0, end: 0.0).animate(
      CurvedAnimation(parent: _animController, curve: const Interval(0.6, 1.0)),
    );
  }

  void _triggerTransition() {
    setState(() => _isAnimating = true);
    _animController.forward();
  }

  void _resetTransition() {
    _animController.reverse();
    Future.delayed(const Duration(milliseconds: 650), () {
      setState(() => _isAnimating = false);
    });
  }

  @override
  void dispose() {
    _animController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          SliverAppBar(
            expandedHeight: 220,
            flexibleSpace: FlexibleSpaceBar(
              title: _isAnimating
                  ? SlideTransition(
                      position: _offsetAnim,
                      child: ScaleTransition(
                        scale: _scaleAnim,
                        child: const Card(
                          color: Colors.blueAccent,
                          child: Padding(
                            padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
                            child: Text("过渡后的卡片", style: TextStyle(color: Colors.white)),
                          ),
                        ),
                      ),
                    )
                  : null,
            ),
          ),
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (ctx, index) {
                if (index == 3) {
                  return _isAnimating
                      ? FadeTransition(
                          opacity: _opacityAnim,
                          child: Card(
                            key: _sourceCardKey,
                            child: const Padding(
                              padding: EdgeInsets.all(16.0),
                              child: Text("原位置卡片"),
                            ),
                          ),
                        )
                      : GestureDetector(
                          onTap: _triggerTransition,
                          child: Card(
                            key: _sourceCardKey,
                            child: const Padding(
                              padding: EdgeInsets.all(16.0),
                              child: Text("点击触发过渡到AppBar"),
                            ),
                          ),
                        );
                }
                return ListTile(title: Text("列表项 $index"));
              },
              childCount: 15,
            ),
          ),
        ],
      ),
      floatingActionButton: _isAnimating
          ? FloatingActionButton(
              onPressed: _resetTransition,
              child: const Icon(Icons.arrow_back),
            )
          : null,
    );
  }
}
2. 将Widget注入Overlay并保留原状态/控制器

可以实现这个需求,核心是用**GlobalKey绑定Widget的State对象**,确保Widget从原渲染树移到Overlay时,状态、逻辑及控制器不丢失。

关键步骤:

  1. 用GlobalKey声明需要保留状态的Widget,绑定其State
  2. 通过Overlay.of(context)获取Overlay上下文,创建OverlayEntry插入目标位置
  3. 原位置用占位Widget(如SizedBox.shrink())避免重复渲染

举个带状态的计数器Widget移到Overlay顶部的示例:

class OverlayStatePreserveDemo extends StatefulWidget {
  const OverlayStatePreserveDemo({super.key});

  @override
  State<OverlayStatePreserveDemo> createState() => _OverlayStatePreserveDemoState();
}

class _OverlayStatePreserveDemoState extends State<OverlayStatePreserveDemo> {
  final GlobalKey<_CounterWidgetState> _counterKey = GlobalKey();
  OverlayEntry? _overlayEntry;

  void _moveToOverlay() {
    if (_overlayEntry != null) return;

    _overlayEntry = OverlayEntry(
      builder: (ctx) => Positioned(
        top: MediaQuery.of(ctx).padding.top + 12,
        left: 12,
        right: 12,
        child: Card(
          elevation: 6,
          child: CounterWidget(key: _counterKey),
        ),
      ),
    );

    Overlay.of(context).insert(_overlayEntry!);
    setState(() {});
  }

  void _moveBackToOriginal() {
    _overlayEntry?.remove();
    _overlayEntry = null;
    setState(() {});
  }

  @override
  void dispose() {
    _overlayEntry?.remove();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Overlay状态保留")),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // 原位置:已移到Overlay则显示占位,否则显示计数器
          _overlayEntry == null
              ? CounterWidget(key: _counterKey)
              : const SizedBox.shrink(),
          const SizedBox(height: 20),
          ElevatedButton(
            onPressed: _moveToOverlay,
            child: const Text("移到顶部Overlay"),
          ),
          const SizedBox(height: 10),
          ElevatedButton(
            onPressed: _overlayEntry != null ? _moveBackToOriginal : null,
            child: const Text("移回原位置"),
          ),
          ...List.generate(8, (index) => ListTile(title: Text("列表项 $index"))),
        ],
      ),
    );
  }
}

// 带状态的计数器Widget
class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});

  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int _count = 0;

  void _incrementCount() {
    setState(() => _count++);
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text("当前计数:$_count", style: const TextStyle(fontSize: 18)),
          const SizedBox(width: 20),
          ElevatedButton(onPressed: _incrementCount, child: const Icon(Icons.add)),
        ],
      ),
    );
  }
}
结合需求:SliverAppBar的页面式过渡

将上述两种技术结合,即可实现“不跳转页面,让SliverAppBar呈现新页面进入的过渡”:

  1. 监听用户交互触发自定义AnimationController,控制原Widget的隐藏动画
  2. 通过OverlayEntry将目标Widget插入SliverAppBar区域,同时用GlobalKey保留其原状态
  3. 用动画控制器驱动Overlay中Widget的位置、大小过渡,模拟页面跳转的视觉效果

内容的提问来源于stack exchange,提问作者Walrus

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.10 05:35:11