Flutter 动画与交互动效

Flutter 动画系统全解:Implicit/Explicit 动画、Hero 共享元素转场、Lottie/Rive 集成、手势系统与自定义动画。

开篇:动画是用户体验的灵魂

从滑动的列表项到渐显的页面,从点击按钮时的 ripples 到页面转场时的共享元素动画——动画让界面从"能用"变成"好用"。Flutter 的动画系统由层叠的 API 组成:从开箱即用的 Implicit Animations,到精确控制的 Explicit Animations,再到与 UI 框架深度集成的 Hero 和物理动画。

本章将系统介绍 Flutter 的各类动画方案,帮助你为应用添加恰到好处的动效——既能引导用户注意力,又不会因过度炫技而喧宾夺主。


一、Implicit Animations:隐式动画(零代码框架动画)

当 Widget 属性变化时,Flutter 可自动插值过渡。

1.1 内置动画 Widget

class ImplicitAnimationDemo extends StatefulWidget {
  @override
  _ImplicitAnimationDemoState createState() => _ImplicitAnimationDemoState();
}

class _ImplicitAnimationDemoState extends State<ImplicitAnimationDemo> {
  bool _expanded = false;
  Color _color = Colors.blue;
  double _opacity = 1.0;
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GestureDetector(
        onTap: () => setState(() {
          _expanded = !_expanded;
          _color = _expanded ? Colors.orange : Colors.blue;
          _opacity = _expanded ? 0.5 : 1.0;
        }),
        child: Column(
          children: [
            // 尺寸动画
            AnimatedContainer(
              duration: Duration(milliseconds: 300),
              curve: Curves.easeInOut,
              width: _expanded ? 200 : 100,
              height: _expanded ? 200 : 100,
              color: _color,
              borderRadius: BorderRadius.circular(_expanded ? 20 : 8),
            ),
            
            // 透明度动画
            AnimatedOpacity(
              duration: Duration(milliseconds: 300),
              opacity: _opacity,
              child: Text('渐隐文本'),
            ),
            
            // 位置动画
            AnimatedAlign(
              duration: Duration(milliseconds: 300),
              alignment: _expanded ? Alignment.centerRight : Alignment.centerLeft,
              child: FlutterLogo(size: 50),
            ),
            
            // 内边距动画
            AnimatedPadding(
              duration: Duration(milliseconds: 300),
              padding: EdgeInsets.all(_expanded ? 32 : 8),
              child: Container(color: Colors.green, child: Text('Padding')),
            ),
            
            // 缩放动画
            AnimatedScale(
              duration: Duration(milliseconds: 300),
              scale: _expanded ? 1.5 : 1.0,
              child: Icon(Icons.star, size: 50),
            ),
            
            // 旋转动画
            AnimatedRotation(
              duration: Duration(milliseconds: 500),
              turns: _expanded ? 0.5 : 0,
              child: Icon(Icons.refresh, size: 50),
            ),
          ],
        ),
      ),
    );
  }
}

1.2 动画原理与性能机制

Understanding how Flutter handles animations is essential for debugging performance issues and choosing the right approach for your needs.

Flutter动画采用主动画系统,由AnimationController驱动。它通过Ticker(与屏幕刷新率同步的定时器)按每秒60或120帧的频率生成动画值。这些值经过Curve(插值曲线)变换后,传递给AnimatedWidget或AnimationBuilder进行重建。

底层渲染流程涉及三个核心线程:

动画渲染流水线:

┌─────────────┐   ┌──────────────────┐   ┌─────────────────┐
│  UI 线程     │ → │                  │ → │                 │
│ (Dart 代码)  │   │  动画值插值计算   │   │  构建 Widget 树  │
│              │   │  矩阵变换计算     │   │  布局与绘制指令   │
└─────────────┘   └──────────────────┘   └─────────────────┘
                                                  ↓
                                          ┌─────────────────┐
                                          │  GPU 线程        │
                                          │ (Skia/Impeller) │
                                          │  光栅化、合成    │
                                          └─────────────────┘

性能关键指标:

指标目标值检测方法
帧率≥ 60 FPSDevTools Performance
Commit 帧耗时< 16msTimeline 分析
Build 调用次数每帧一次Widget Build 追踪
内存占用无泄漏Memory Profiling

优化动画性能的黄金法则:

  1. 避免在动画中重建整棵树:使用 AnimatedBuilder 的 child 参数缓存不变的子树
  2. 使用 RepaintBoundary:隔离需要频繁重绘的区域,减少重绘范围
  3. 优先使用 Opacity 的 opacity 属性而非 color:Color.fromRGBO 的 alpha 通道变化更容易被 GPU 优化
  4. 避免深层嵌套动画:多层动画同时触发会导致帧预算不足
  5. 使用 AnimationController.addListener 而非 setState:前者只触发局部重建

1.3 AnimatedSwitcher:子 Widget 切换动画

AnimatedSwitcher(
  duration: Duration(milliseconds: 300),
  transitionBuilder: (child, animation) {
    return FadeTransition(
      opacity: animation,
      child: SlideTransition(
        position: Tween<Offset>(
          begin: const Offset(0, 1),
          end: Offset.zero,
        ).animate(animation),
        child: child,
      ),
    );
  },
  child: _isLoading
      ? CircularProgressIndicator(key: ValueKey('loading'))
      : Text('数据加载完成', key: ValueKey('done')),
)

1.3 TweenAnimationBuilder:自定义插值

TweenAnimationBuilder<double>(
  tween: Tween(begin: 0, end: 1),
  duration: Duration(seconds: 2),
  curve: Curves.bounceOut,
  builder: (context, value, child) {
    return Transform.translate(
      offset: Offset(0, -100 * value),
      child: Opacity(
        opacity: value,
        child: child,
      ),
    );
  },
  child: Text('弹跳入场!', style: TextStyle(fontSize: 24)),
)

一句话总结:隐式动画让你只需改变属性值就能获得流畅过渡,是 Flutter 最省力的动画方案。


二、Explicit Animations:显式动画(精确控制)

2.1 AnimationController + Tween

class ExplicitAnimationDemo extends StatefulWidget {
  @override
  _ExplicitAnimationDemoState createState() => _ExplicitAnimationDemoState();
}

class _ExplicitAnimationDemoState extends State<ExplicitAnimationDemo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;
  
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,  // TickerProvider 防止离屏动画消耗资源
      duration: Duration(seconds: 2),
    );
    
    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.elasticOut,
    );
    
    // 监听动画状态
    _controller.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        print('动画完成');
      }
    });
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        if (_controller.status == AnimationStatus.completed) {
          _controller.reverse();
        } else {
          _controller.forward();
        }
      },
      child: AnimatedBuilder(
        animation: _animation,
        builder: (context, child) {
          return Transform.rotate(
            angle: _animation.value * 2 * pi,
            child: Transform.scale(
              scale: 0.5 + _animation.value * 0.5,
              child: child,
            ),
          );
        },
        child: Container(
          width: 100,
          height: 100,
          color: Colors.purple,
          child: Center(child: Text('Tap')),
        ),
      ),
    );
  }
}

2.2 交错动画(Staggered Animation)

class StaggeredDemo extends StatefulWidget {
  @override
  _StaggeredDemoState createState() => _StaggeredDemoState();
}

class _StaggeredDemoState extends State<StaggeredDemo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late List<Animation<double>> _animations;
  
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 1500),
    );
    
    _animations = List.generate(5, (index) {
      final start = index * 0.15;
      final end = start + 0.6;
      return Tween<double>(begin: 0, end: 1).animate(
        CurvedAnimation(
          parent: _controller,
          curve: Interval(start, end, curve: Curves.easeOut),
        ),
      );
    });
    
    _controller.forward();
  }
  
  @override
  Widget build(BuildContext context) {
    return Column(
      children: List.generate(5, (index) {
        return AnimatedBuilder(
          animation: _animations[index],
          builder: (context, child) {
            return Opacity(
              opacity: _animations[index].value,
              child: Transform.translate(
                offset: Offset(50 * (1 - _animations[index].value), 0),
                child: child,
              ),
            );
          },
          child: ListTile(title: Text('Item $index')),
        );
      }),
    );
  }
}

一句话总结:显式动画通过 AnimationController 赋予你完整的控制能力——播放、暂停、倒放、逐帧监听——适合需要精确时序的复杂动画。


三、Hero 动画与页面转场

// 列表页
class ProductList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GridView.builder(
        itemCount: products.length,
        itemBuilder: (context, index) {
          final product = products[index];
          return GestureDetector(
            onTap: () => context.push('/detail/${product.id}'),
            child: Hero(
              tag: 'product-${product.id}',
              flightShuttleBuilder: (flightContext, animation,
                  flightDirection, fromHeroContext, toHeroContext) {
                return AnimatedBuilder(
                  animation: animation,
                  builder: (context, child) {
                    return ClipRRect(
                      borderRadius: BorderRadius.lerp(
                        BorderRadius.circular(12),  // 从列表的圆角
                        BorderRadius.zero,           // 到详情的直角
                        animation.value,
                      )!,
                      child: Image.network(product.imageUrl),
                    );
                  },
                );
              },
              child: ClipRRect(
                borderRadius: BorderRadius.circular(12),
                child: Image.network(product.imageUrl),
              ),
            ),
          );
        },
      ),
    );
  }
}

// 详情页
class ProductDetail extends StatelessWidget {
  final String productId;
  const ProductDetail({super.key, required this.productId});
  
  @override
  Widget build(BuildContext context) {
    final product = findProduct(productId);
    return Scaffold(
      body: Column(
        children: [
          Hero(
            tag: 'product-$productId',
            child: Image.network(product.imageUrl, height: 300, fit: BoxFit.cover),
          ),
          Text(product.name),
        ],
      ),
    );
  }
}

一句话总结:Hero 动画通过匹配两个页面间的 tag,自动计算插值路径实现共享元素转场,是 Flutter 中最具辨识度的动画效果。


四、Lottie 与 Rive 集成

// Lottie(After Effects 导出)
import 'package:lottie/lottie.dart';

Lottie.asset(
  'assets/animations/loading.json',
  width: 200,
  height: 200,
  repeat: true,
  controller: _lottieController,  // 可选:控制播放
  onLoaded: (composition) {
    _lottieController.duration = composition.duration;
  },
)

// Rive(交互式矢量动画)
import 'package:rive/rive.dart';

class RiveButton extends StatefulWidget {
  @override
  _RiveButtonState createState() => _RiveButtonState();
}

class _RiveButtonState extends State<RiveButton> {
  StateMachineController? _controller;
  SMIInput<bool>? _hoverInput;
  
  void _onRiveInit(Artboard artboard) {
    _controller = StateMachineController.fromArtboard(artboard, 'State Machine');
    artboard.addController(_controller!);
    _hoverInput = _controller!.findInput<bool>('hover') as SMIInput<bool>;
  }
  
  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      onEnter: (_) => _hoverInput?.value = true,
      onExit: (_) => _hoverInput?.value = false,
      child: RiveAnimation.asset(
        'assets/animations/button.riv',
        onInit: _onRiveInit,
      ),
    );
  }
}

一句话总结:Lottie 适合展示型动画(加载、成功提示),Rive 适合交互式动画(响应输入的状态机动画)。


五、物理动画与缓动系统

当简单的线性插值无法满足需求时,Flutter 提供了完整的物理模拟系统。物理动画基于真实世界的物理规律,产生更自然、更有质感的交互效果。

5.1 Spring Simulation(弹簧物理)

弹簧动画模拟了胡克定律驱动的振荡系统,是表现弹性交互效果的首选方案:

// 使用 SpringDescription 定义弹簧特性
class SpringDemo extends StatefulWidget {
  @override
  _SpringDemoState createState() => _SpringDemoState();
}

class _SpringDemoState extends State<SpringDemo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 500),
    );
  }
  
  // 使用弹簧模拟器驱动动画
  void _animateWithSpring() {
    final spring = SpringDescription(
      mass: 1.0,           // 质量:影响惯性(1.0 ~ 10.0)
      stiffness: 100.0,    // 刚度:弹簧硬度(50.0 ~ 500.0)
      damping: 10.0,       // 阻尼:振荡衰减(1.0 ~ 30.0)
    );
    
    _controller.animateWith(
      SpringSimulation(
        spring,
        _controller.value,              // 起始值
        1.0,                            // 目标值
        0.0,                            // 初速度
      ),
    );
  }
  
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _animateWithSpring,
      child: AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Transform.scale(
            scale: _controller.value,
            child: child,
          );
        },
        child: Container(
          width: 150,
          height: 150,
          color: Colors.teal,
          child: Center(child: Text('Tap Me', style: TextStyle(color: Colors.white))),
        ),
      ),
    );
  }
}

弹簧参数调优指南:

场景刚度阻尼效果
按钮点击反馈30020短促、干脆
卡片展开10010自然、流畅
弹窗入场50015快速、有弹性
下拉刷新回弹15012柔和、舒适

5.2 Fling 抛掷动画

Fling 动画通过模拟减速过程实现惯性效果,常用于滚动、拖拽后的惯性运动:

void _onDragEnd(DragEndDetails details) {
  final velocity = details.primaryVelocity ?? 0.0;
  
  _controller.animateWith(
    FrictionSimulation(
      0.2,                    // 摩擦力系数(0.1 ~ 0.5)
      _controller.value,      // 起始值
      velocity / 1000,        // 初速度(转换为动画单位)
    ),
  );
}

5.3 自定义 Curves

当内置曲线不满足需求时,可自定义插值函数。Flutter的所有Curve都继承自Curve类,只需实现 double transform(double t) 方法:

// 自定义弹跳曲线:先超出目标,再回弹到位
class BounceOutCurve extends Curve {
  @override
  double transform(double t) {
    if (t < 1 / 2.75) {
      return 7.5625 * t * t;
    } else if (t < 2 / 2.75) {
      t -= 1.5 / 2.75;
      return 7.5625 * t * t + 0.75;
    } else if (t < 2.5 / 2.75) {
      t -= 2.25 / 2.75;
      return 7.5625 * t * t + 0.9375;
    } else {
      t -= 2.625 / 2.75;
      return 7.5625 * t * t + 0.984375;
    }
  }
}

// 自定义曲线通常只需声明一次,性能友好
const BounceOutCurve bounceOut = BounceOutCurve();

六、手势系统

class GestureDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      // 点击
      onTap: () => print('Tap'),
      onDoubleTap: () => print('Double tap'),
      onLongPress: () => print('Long press'),
      
      // 滑动
      onHorizontalDragStart: (details) => print('Drag start'),
      onHorizontalDragUpdate: (details) => print('Delta: ${details.delta}'),
      onHorizontalDragEnd: (details) => print('Velocity: ${details.primaryVelocity}'),
      
      // 捏合缩放
      onScaleStart: (details) => print('Scale start: ${details.focalPoint}'),
      onScaleUpdate: (details) => print('Scale: ${details.scale}'),
      
      child: Container(width: 300, height: 300, color: Colors.blue),
    );
  }
}

// Dismissible:滑动删除
Dismissible(
  key: Key(item.id),
  direction: DismissDirection.endToStart,
  background: Container(color: Colors.red, child: Icon(Icons.delete)),
  confirmDismiss: (direction) async {
    return await showDialog(
      context: context,
      builder: (_) => AlertDialog(
        title: Text('确认删除?'),
        actions: [
          TextButton(onPressed: () => Navigator.pop(context, false), child: Text('取消')),
          TextButton(onPressed: () => Navigator.pop(context, true), child: Text('删除')),
        ],
      ),
    );
  },
  onDismissed: (_) => removeItem(item.id),
  child: ListTile(title: Text(item.title)),
)

FAQ

Q1: Implicit 和 Explicit 动画怎么选?

  • 只需简单属性过渡 → Implicit(AnimatedContainer 等)
  • 需要精确时序、循环、手势联动 → Explicit(AnimationController)
  • 不确定时,先用 Implicit,不够用再升级

Q2: Hero 动画 tag 冲突怎么办?

确保 tag 全局唯一,通常使用 ID:hero-product-${product.id}。列表中允许多个 Hero 使用相同 tag(如网格中的同一产品),Flutter 会自动处理。

Q3: 动画卡顿怎么排查?

打开 DevTools → Performance → Enable “Track widget builds”,检查 build 是否在每帧中被调用太多次。如果动画面板显示红色条,说明动画计算占用了过多的 UI 线程时间。


相关阅读

  • https://plumephp.com/flutter-widgets-layout/ — Widget 体系
  • https://plumephp.com/flutter-performance-optimization/ — 性能优化

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Flutter」更多文章

  1. 崩溃监控与线上可观测性
  2. 蓝牙 BLE 与外设集成
  3. 包体积与启动优化