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 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 适合交互式动画(响应输入的状态机动画)。


五、手势系统

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. Widget 体系与布局系统
  2. Flutter 状态管理全解析
  3. Flutter 测试策略与自动化