flutter学习第 14 节:动画与过渡效果

14 阅读10分钟

动画是提升用户体验的关键因素,能够使应用界面更加生动、直观,增强用户交互感。Flutter 提供了强大的动画系统,支持各种复杂的动画效果实现。本节课将详细介绍 Flutter 中的动画类型和实现方式,从基础的隐式动画到复杂的自定义显式动画,帮助你掌握为应用添加流畅动画效果的技能。

一、Flutter 动画基础

Flutter 动画系统基于以下核心概念:

  • Animation:一个生成介于 0.0 和 1.0 之间数值的对象,它本身不包含渲染内容,只提供动画数值
  • Curve:定义动画进度的速度变化,如加速、减速等
  • Controller:控制动画的播放、暂停、反向等,管理动画生命周期
  • Tween:定义动画的起始值和结束值,将 Animation 提供的 0-1 数值映射到实际需要的数值范围
  • Animatable:可以生成 Tween 的对象,支持更复杂的数值映射

Flutter 动画主要分为两类:

  • 隐式动画:由 Flutter 自动管理的动画,只需定义起始和结束状态
  • 显式动画:需要手动控制的动画,提供更多自定义选项和控制能力


二、隐式动画

隐式动画(Implicit Animations)是最简单的动画实现方式,Flutter 提供了一系列封装好的隐式动画组件,当组件的某些属性发生变化时,会自动从旧值平滑过渡到新值。

1. AnimatedContainer

AnimatedContainer 是最常用的隐式动画组件,当它的属性(如大小、颜色、边距等)发生变化时,会自动产生过渡动画。

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

  @override
  State<AnimatedContainerDemo> createState() => _AnimatedContainerDemoState();
}

class _AnimatedContainerDemoState extends State<AnimatedContainerDemo> {
  // 控制容器属性的状态变量
  bool _isExpanded = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('AnimatedContainer Demo')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            AnimatedContainer(
              // 动画持续时间
              duration: const Duration(seconds: 1),
              // 动画曲线
              curve: Curves.easeInOut,
              // 动态变化的属性
              width: _isExpanded ? 300 : 100,
              height: _isExpanded ? 300 : 100,
              color: _isExpanded ? Colors.blue : Colors.red,
              padding: _isExpanded
                  ? const EdgeInsets.all(20)
                  : const EdgeInsets.all(10),
              // 容器内的内容
              child: const Center(
                child: Text(
                  'Animate me!',
                  style: TextStyle(color: Colors.white, fontSize: 20),
                ),
              ),
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                // 点击按钮切换状态,触发动画
                setState(() {
                  _isExpanded = !_isExpanded;
                });
              },
              child: const Text('Toggle Animation'),
            ),
          ],
        ),
      ),
    );
  }
}

2. 其他常用隐式动画组件

Flutter 还提供了其他专门用途的隐式动画组件:

AnimatedOpacity

控制组件透明度变化的动画:

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

  @override
  State<AnimatedOpacityDemo> createState() => _AnimatedOpacityDemoState();
}

class _AnimatedOpacityDemoState extends State<AnimatedOpacityDemo> {
  double _opacity = 1.0;

  void _toggleOpacity() {
    setState(() {
      _opacity = _opacity == 1.0 ? 0.0 : 1.0;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('AnimatedOpacity Demo')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            AnimatedOpacity(
              opacity: _opacity,
              duration: const Duration(seconds: 1),
              curve: Curves.fastOutSlowIn,
              child: Container(
                width: 200,
                height: 200,
                color: Colors.green,
                child: const Center(
                  child: Text(
                    'Fade me!',
                    style: TextStyle(color: Colors.white, fontSize: 24),
                  ),
                ),
              ),
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: _toggleOpacity,
              child: const Text('Toggle Opacity'),
            ),
          ],
        ),
      ),
    );
  }
}

AnimatedPositioned

用于 Stack 中,控制子组件位置变化的动画:

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

  @override
  State<AnimatedPositionedDemo> createState() => _AnimatedPositionedDemoState();
}

class _AnimatedPositionedDemoState extends State<AnimatedPositionedDemo> {
  bool _isMoved = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('AnimatedPositioned Demo')),
      body: Stack(
        children: [
          AnimatedPositioned(
            duration: const Duration(seconds: 1),
            curve: Curves.bounceOut,
            left: _isMoved ? 200 : 50,
            top: _isMoved ? 300 : 100,
            width: 100,
            height: 100,
            child: Container(
              color: Colors.purple,
              child: const Center(
                child: Text(
                  'Move me!',
                  style: TextStyle(color: Colors.white),
                ),
              ),
            ),
          ),
          Positioned(
            bottom: 50,
            left: 0,
            right: 0,
            child: Center(
              child: ElevatedButton(
                onPressed: () {
                  setState(() {
                    _isMoved = !_isMoved;
                  });
                },
                child: const Text('Move the Box'),
              ),
            ),
          )
        ],
      ),
    );
  }
}

AnimatedPadding、AnimatedSize 等

还有其他类似的隐式动画组件,使用方式大同小异:

  • AnimatedPadding:控制内边距变化
  • AnimatedSize:根据子组件大小自动调整并产生动画
  • AnimatedTransform:控制变换效果的动画
  • AnimatedDefaultTextStyle:控制文本样式变化的动画

3. 隐式动画的优缺点

优点

  • 使用简单,只需修改状态即可触发动画
  • 无需手动管理动画控制器
  • 适合实现简单的过渡效果

缺点

  • 定制化程度低,无法实现复杂动画
  • 缺乏精细的控制能力(如暂停、反向播放)
  • 多个属性同时动画时难以协调


三、显式动画

显式动画(Explicit Animations)需要手动创建和管理动画控制器,提供了更精细的控制和更大的灵活性,适合实现复杂的动画效果。

1. AnimationController 与 Animation

AnimationController 是显式动画的核心,负责控制动画的时间和状态:

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

  @override
  State<BasicExplicitAnimation> createState() => _BasicExplicitAnimationState();
}

class _BasicExplicitAnimationState extends State<BasicExplicitAnimation>
    with SingleTickerProviderStateMixin {
  // 动画控制器
  late AnimationController _controller;
  // 动画对象
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();

    // 初始化动画控制器
    _controller = AnimationController(
      vsync: this, // 与当前组件生命周期关联
      duration: const Duration(seconds: 2), // 动画持续时间
    );

    // 创建从 0 到 300 的动画
    _animation = Tween<double>(begin: 0, end: 300).animate(_controller)
      // 监听动画值变化,触发重建
      ..addListener(() {
        setState(() {});
      })
      // 监听动画状态变化
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          // 动画完成后反向播放
          _controller.reverse();
        } else if (status == AnimationStatus.dismissed) {
          // 动画回到起点后正向播放
          _controller.forward();
        }
      });

    // 开始动画
    _controller.forward();
  }

  @override
  void dispose() {
    // 释放动画控制器资源
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Basic Explicit Animation')),
      body: Center(
        child: Container(
          width: _animation.value, // 使用动画值
          height: _animation.value,
          color: Colors.orange,
          child: const Center(
            child: Text(
              'Growing Box',
              style: TextStyle(color: Colors.white, fontSize: 18),
            ),
          ),
        ),
      ),
    );
  }
}

注意:SingleTickerProviderStateMixin 提供了一个 vsync 回调,用于防止动画在组件不可见时继续消耗资源。如果需要多个动画控制器,应使用 TickerProviderStateMixin

2. CurvedAnimation 曲线动画

使用 CurvedAnimation 可以为动画添加非线性的速度变化:

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

  @override
  State<CurvedAnimationDemo> createState() => _CurvedAnimationDemoState();
}

class _CurvedAnimationDemoState extends State<CurvedAnimationDemo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
    );

    // 创建曲线动画
    final curve = CurvedAnimation(
      parent: _controller,
      curve: Curves.bounceOut, // 弹跳效果
      reverseCurve: Curves.bounceIn, // 反向时的曲线
    );

    // 使用曲线动画
    _animation = Tween<double>(begin: 50, end: 300).animate(curve)
      ..addListener(() {
        setState(() {});
      })
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          _controller.reverse();
        } else if (status == AnimationStatus.dismissed) {
          _controller.forward();
        }
      });

    _controller.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Curved Animation')),
      body: Center(
        child: Container(
          width: _animation.value,
          height: 100,
          color: Colors.pink,
          child: const Center(
            child: Text(
              'Bouncy!',
              style: TextStyle(color: Colors.white, fontSize: 20),
            ),
          ),
        ),
      ),
    );
  }
}

Flutter 提供了多种预定义的曲线,如:

  • Curves.linear:线性动画
  • Curves.easeInCurves.easeOutCurves.easeInOut:缓入、缓出、缓入缓出
  • Curves.bounceInCurves.bounceOut:弹跳效果
  • Curves.elasticInCurves.elasticOut:弹性效果

3. Tween 与多属性动画

Tween 定义了动画的取值范围,可以是任意类型。多个 Tween 可以组合实现多属性动画:

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

  @override
  State<MultiPropertyAnimation> createState() => _MultiPropertyAnimationState();
}

class _MultiPropertyAnimationState extends State<MultiPropertyAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _sizeAnimation;
  late Animation<double> _opacityAnimation;
  late Animation<Color?> _colorAnimation;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 3),
    );

    // 大小动画
    _sizeAnimation = Tween<double>(
      begin: 50,
      end: 250,
    ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));

    // 透明度动画
    _opacityAnimation = Tween<double>(begin: 0.2, end: 1.0).animate(
      CurvedAnimation(
        parent: _controller,
        curve: const Interval(0.3, 1.0),
      ), // 延迟开始
    );

    // 颜色动画
    _colorAnimation = ColorTween(begin: Colors.blue, end: Colors.green).animate(
      CurvedAnimation(
        parent: _controller,
        curve: const Interval(0.0, 0.7),
      ), // 提前结束
    );

    // 监听动画值变化
    _controller.addListener(() {
      setState(() {});
    });

    // 循环播放
    _controller.repeat(reverse: true);
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Multi-property Animation')),
      body: Center(
        child: Opacity(
          opacity: _opacityAnimation.value,
          child: Container(
            width: _sizeAnimation.value,
            height: _sizeAnimation.value,
            decoration: BoxDecoration(
              color: _colorAnimation.value,
              // 使用 borderRadius 属性(已修复)
              borderRadius: BorderRadius.circular(_sizeAnimation.value / 10),
            ),
            child: const Center(
              child: Text(
                'Fancy!',
                style: TextStyle(color: Colors.white, fontSize: 24),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Interval 可以为不同的动画属性设置不同的时间区间,实现更复杂的动画编排。

4. AnimatedBuilder 优化重建性能

使用 addListener 配合 setState 会导致整个组件重建,使用 AnimatedBuilder 可以只重建需要动画的部分,提高性能:

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

  @override
  State<AnimatedBuilderDemo> createState() => _AnimatedBuilderDemoState();
}

class _AnimatedBuilderDemoState extends State<AnimatedBuilderDemo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _rotationAnimation;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 5),
    );

    _rotationAnimation = Tween<double>(
      begin: 0,
      end: 2 * 3.14159,
    ).animate(CurvedAnimation(parent: _controller, curve: Curves.linear));

    _controller.repeat();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('AnimatedBuilder Demo')),
      body: Center(
        // 使用 AnimatedBuilder 包裹需要动画的部分
        child: AnimatedBuilder(
          animation: _rotationAnimation,
          // builder 方法只重建动画相关部分
          builder: (context, child) {
            return Transform.rotate(
              angle: _rotationAnimation.value,
              child: child, // 传入静态子组件,避免重复构建
            );
          },
          // 静态子组件,只会构建一次
          child: Container(
            width: 200,
            height: 200,
            decoration: BoxDecoration(
              color: Colors.amber,
              // 使用 borderRadius 属性(已修复)
              borderRadius: BorderRadius.circular(20),
            ),
            child: const Center(
              child: Text(
                'Spinning!',
                style: TextStyle(color: Colors.black, fontSize: 24),
              ),
            ),
          ),
        ),
      ),
    );
  }
}


四、页面过渡动画

Flutter 允许自定义页面之间的切换动画,通过 PageRouteBuilder 可以实现各种过渡效果。

1. 淡入淡出过渡

class FadeTransitionDemo extends StatelessWidget {
  const FadeTransitionDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Fade Transition Demo')),
      body: Center(
        child: ElevatedButton(
          child: const Text('Go to Second Screen'),
          onPressed: () {
            // 导航到第二个页面,使用自定义过渡动画
            Navigator.push(
              context,
              PageRouteBuilder(
                transitionDuration: const Duration(seconds: 1), // 过渡时间
                // 构建页面内容
                pageBuilder: (context, animation, secondaryAnimation) {
                  return const SecondScreen();
                },
                // 构建过渡效果
                transitionsBuilder:
                    (context, animation, secondaryAnimation, child) {
                      // 淡入淡出过渡
                      return FadeTransition(opacity: animation, child: child);
                    },
              ),
            );
          },
        ),
      ),
    );
  }
}

class SecondScreen extends StatelessWidget {
  const SecondScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Second Screen'),
        backgroundColor: Colors.green,
      ),
      body: Center(
        child: ElevatedButton(
          child: const Text('Go Back'),
          onPressed: () {
            Navigator.pop(context);
          },
        ),
      ),
    );
  }
}

2. 滑动过渡

// 在导航时使用这个过渡
PageRouteBuilder(
  transitionDuration: const Duration(milliseconds: 500),
  pageBuilder: (context, animation, secondaryAnimation) {
    return const SecondScreen();
  },
  transitionsBuilder: (context, animation, secondaryAnimation, child) {
    // 滑动过渡 - 从右侧滑入
    const begin = Offset(1.0, 0.0); // 起始位置(右侧)
    const end = Offset.zero; // 结束位置(屏幕内)
    const curve = Curves.easeInOut;
    
    // 创建从 begin 到 end 的动画
    var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
    var offsetAnimation = animation.drive(tween);
    
    return SlideTransition(
      position: offsetAnimation,
      child: child,
    );
  },
)

3. 缩放过渡

PageRouteBuilder(
  transitionDuration: const Duration(milliseconds: 500),
  pageBuilder: (context, animation, secondaryAnimation) {
    return const SecondScreen();
  },
  transitionsBuilder: (context, animation, secondaryAnimation, child) {
    // 缩放过渡
    return ScaleTransition(
      scale: animation.drive(
        Tween(begin: 0.0, end: 1.0).chain(
          CurveTween(curve: Curves.bounceOut)
        )
      ),
      child: child,
    );
  },
)

4. 组合过渡效果

可以将多种过渡效果组合使用:

PageRouteBuilder(
  transitionDuration: const Duration(milliseconds: 700),
  pageBuilder: (context, animation, secondaryAnimation) {
    return const SecondScreen();
  },
  transitionsBuilder: (context, animation, secondaryAnimation, child) {
    // 组合缩放和淡入效果
    return ScaleTransition(
      scale: animation.drive(
        Tween(begin: 0.8, end: 1.0).chain(
          CurveTween(curve: Curves.easeInOut)
        )
      ),
      child: FadeTransition(
        opacity: animation.drive(
          Tween(begin: 0.0, end: 1.0).chain(
            CurveTween(curve: const Interval(0.2, 1.0))
          )
        ),
        child: child,
      ),
    );
  },
)


五、实例:按钮点击缩放效果

实现一个带有点击反馈的按钮,点击时会产生缩放效果:

class BounceButton extends StatefulWidget {
  final Widget child;
  final VoidCallback onPressed;
  final double scaleFactor;
  final Duration duration;

  const BounceButton({
    super.key,
    required this.child,
    required this.onPressed,
    this.scaleFactor = 0.8,
    this.duration = const Duration(milliseconds: 300),
  });

  @override
  State<BounceButton> createState() => _BounceButtonState();
}

class _BounceButtonState extends State<BounceButton>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(vsync: this, duration: widget.duration);

    _scaleAnimation = Tween<double>(
      begin: 1.0,
      end: widget.scaleFactor,
    ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
  }

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

  // 处理按下 - 不直接执行forward,由onTapUp统一控制
  void _onTapDown(TapDownDetails details) {
    print("按下:准备启动动画");
  }

  // 处理释放 - 统一控制动画流程,确保完整执行
  void _onTapUp(TapUpDetails details) {
    print("释放:开始动画流程");
    // 先执行完整的正向动画,再执行反向动画,最后触发回调
    _controller.forward().then((_) {
      _controller.reverse().then((_) {
        widget.onPressed();
      });
    });
  }

  // 处理取消
  void _onTapCancel() {
    print("取消:反向动画");
    _controller.reverse();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _scaleAnimation,
      builder: (context, child) {
        return Transform.scale(scale: _scaleAnimation.value, child: child);
      },
      child: GestureDetector(
        onTapDown: _onTapDown,
        onTapUp: _onTapUp,
        onTapCancel: _onTapCancel,
        behavior: HitTestBehavior.opaque,
        child: widget.child,
      ),
    );
  }
}

// 使用示例
class BounceButtonDemo extends StatelessWidget {
  const BounceButtonDemo({super.key});

  void _handlePress() {
    print('Button pressed!');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Bounce Button Demo')),
      body: Center(
        child: BounceButton(
          onPressed: _handlePress,
          scaleFactor: 0.7,
          child: Container(
            padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 20),
            decoration: BoxDecoration(
              color: Colors.blue,
              borderRadius: BorderRadius.circular(12),
              boxShadow: const [
                BoxShadow(
                  color: Colors.black26,
                  blurRadius: 10,
                  offset: Offset(0, 4),
                ),
              ],
            ),
            child: const Text(
              'Click Me',
              style: TextStyle(
                color: Colors.white,
                fontSize: 20,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

说明

  • 在 _onTapDown 中不直接执行 _controller.forward(),只标记按钮被按下状态
  • 在 _onTapUp 中统一控制动画流程,先执行完整的正向动画(按钮缩小),再执行反向动画(按钮恢复),最后才触发业务回调


六、动画性能优化

动画性能对用户体验至关重要,以下是一些优化建议:

  1. 使用 AnimatedBuilder 减少重建范围:只重建需要动画的部分,避免整个页面重建。
  2. 避免在动画过程中执行复杂计算:动画回调中应尽量简洁,复杂计算会导致卡顿。
  3. 使用硬件加速:大多数动画会自动使用硬件加速,但应避免使用会触发软件渲染的操作(如使用 saveLayer)。
  4. 控制动画帧率:大多数情况下,60fps 已足够,更高的帧率会消耗更多资源。
  5. 合理设置动画时长:一般动画时长在 200-300ms 之间,过长会让用户感到延迟。
  6. 使用 RepaintBoundary 隔离重绘区域:对于频繁重绘的动画组件,使用 RepaintBoundary 包裹,避免影响其他组件。
RepaintBoundary(
  child: AnimatedWidget(...),
)
  1. 避免透明度动画与阴影同时使用:这两种效果同时使用会降低性能,可以在动画期间暂时移除阴影。