跳转到正文
YGJoon 技术博客

Flutter 动画曲线详解

发布于:
·1.6k 字 · 约 4 分钟·作者:
曲线决定动画的节奏感:easeInOut 缓入缓出、elastic 弹性、bounce 弹跳分别适合什么场景,附常见曲线对照 demo 与自定义曲线写法
Flutter 动画曲线详解文章封面
目录

简介

动画曲线(Curves)定义了动画在执行过程中的变化速率。合适的动画曲线可以让动画效果更加自然和流畅。

内置曲线

常用曲线

// 线性
curve: Curves.linear

// 缓入
curve: Curves.easeIn
curve: Curves.easeInQuad
curve: Curves.easeInCubic

// 缓出
curve: Curves.easeOut
curve: Curves.easeOutQuad
curve: Curves.easeOutCubic

// 缓入缓出
curve: Curves.easeInOut
curve: Curves.easeInOutQuad
curve: Curves.easeInOutCubic

// 弹性
curve: Curves.elasticIn
curve: Curves.elasticOut
curve: Curves.elasticInOut

// 弹跳
curve: Curves.bounceIn
curve: Curves.bounceOut
curve: Curves.bounceInOut

曲线示例

class CurvesDemo extends StatefulWidget {
  @override
  _CurvesDemoState createState() => _CurvesDemoState();
}

class _CurvesDemoState extends State<CurvesDemo> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  final List<Curve> curves = [
    Curves.linear,
    Curves.easeIn,
    Curves.easeOut,
    Curves.easeInOut,
    Curves.elasticIn,
    Curves.elasticOut,
    Curves.bounceIn,
    Curves.bounceOut,
  ];

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Curves Demo')),
      body: ListView.builder(
        itemCount: curves.length,
        itemBuilder: (context, index) {
          return Padding(
            padding: EdgeInsets.all(8.0),
            child: Column(
              children: [
                Text(curves[index].toString()),
                SizedBox(height: 8),
                SlideTransition(
                  position: Tween(
                    begin: Offset(-1, 0),
                    end: Offset(1, 0),
                  ).animate(CurvedAnimation(
                    parent: _controller,
                    curve: curves[index],
                  )),
                  child: Container(
                    width: 50,
                    height: 50,
                    color: Colors.blue,
                  ),
                ),
              ],
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          if (_controller.status == AnimationStatus.completed) {
            _controller.reverse();
          } else {
            _controller.forward();
          }
        },
        child: Icon(Icons.play_arrow),
      ),
    );
  }
}

自定义曲线

基本实现

class CustomCurve extends Curve {
  @override
  double transform(double t) {
    // t 从 0.0 到 1.0
    // 返回值也应该从 0.0 到 1.0
    return t * t; // 示例:平方曲线
  }
}

复杂曲线

import 'dart:math'; // pow、sin、pi 需要这个导入

class ElasticOutCurve extends Curve {
  final double period;

  const ElasticOutCurve([this.period = 0.4]);

  @override
  double transform(double t) {
    if (t == 0.0 || t == 1.0) {
      return t;
    }

    double s = period / 4.0;
    t = t - 1.0;
    return pow(2.0, -10.0 * t) * 
           sin((t - s) * (pi * 2.0) / period) + 1.0;
  }
}

组合曲线

Interval

CurvedAnimation(
  parent: controller,
  curve: Interval(
    0.0,
    0.5,
    curve: Curves.easeOut,
  ),
)

曲线序列

class SequenceCurve extends Curve {
  final List<Curve> curves;
  final List<double> weights;

  SequenceCurve(this.curves, this.weights) {
    assert(curves.length == weights.length);
    assert(weights.reduce((a, b) => a + b) == 1.0);
  }

  @override
  double transform(double t) {
    double total = 0.0;
    for (int i = 0; i < curves.length; i++) {
      double weight = weights[i];
      if (t < total + weight) {
        double localT = (t - total) / weight;
        return curves[i].transform(localT);
      }
      total += weight;
    }
    return t;
  }
}

完整示例

class AnimationCurvesDemo extends StatefulWidget {
  @override
  _AnimationCurvesDemoState createState() => _AnimationCurvesDemoState();
}

class _AnimationCurvesDemoState extends State<AnimationCurvesDemo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation1;
  late Animation<double> _animation2;
  late Animation<double> _animation3;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 3),
      vsync: this,
    );

    // 基本曲线
    _animation1 = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    );

    // 自定义曲线
    _animation2 = CurvedAnimation(
      parent: _controller,
      curve: CustomCurve(),
    );

    // 组合曲线
    _animation3 = CurvedAnimation(
      parent: _controller,
      curve: SequenceCurve(
        [Curves.easeOut, Curves.elasticOut],
        [0.3, 0.7],
      ),
    );
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Animation Curves')),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          _buildAnimationDemo('Ease In Out', _animation1),
          _buildAnimationDemo('Custom Curve', _animation2),
          _buildAnimationDemo('Sequence Curve', _animation3),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          if (_controller.status == AnimationStatus.completed) {
            _controller.reverse();
          } else {
            _controller.forward();
          }
        },
        child: Icon(Icons.play_arrow),
      ),
    );
  }

  Widget _buildAnimationDemo(String title, Animation<double> animation) {
    return Column(
      children: [
        Text(title),
        SizedBox(height: 8),
        AnimatedBuilder(
          animation: animation,
          builder: (context, child) {
            return Transform.translate(
              offset: Offset(200 * animation.value - 100, 0),
              child: Container(
                width: 50,
                height: 50,
                decoration: BoxDecoration(
                  color: Colors.blue,
                  borderRadius: BorderRadius.circular(8),
                ),
              ),
            );
          },
        ),
      ],
    );
  }
}

最佳实践

  1. 入场/位移动画默认用 Curves.easeOut(快进慢收),符合手指滑动后内容”滑入”的直觉;元素离开/收起用 easeIn;需要对称感的缩放(如对话框弹出)用 easeInOut 或 fastOutSlowIn(Material 默认的强调曲线)。
  2. elastic/bounce 只用在”点状强调”:收藏按钮点按的那一下回弹、开关拨动的抖动,位移范围限制在几十 dp 内;用在大段位移(页面转场、抽屉滑出)上会显得拖沓廉价。
  3. 曲线和时长是绑定的:easeOut 类 200400ms 足够;elasticOut 自带多次回弹,总时长要给到 600800ms,否则回弹被截断、动画看起来”没播完”——只调曲线不调时长等于白调。
  4. 自定义 Curve 的 transform 里只做纯数值计算,sin/pow 级运算可以,但别做 IO、别分配大对象、别读 MediaQuery——它每帧对每个动画都会被调用,高频调用下这些都是帧率杀手。

注意事项

  1. 曲线的输入范围是 0.0 到 1.0,但输出可以超调(elastic、bounce 类曲线的返回值会大于 1 或小于 0),写自定义 Curve 时不要假设输出也被夹在 0~1
  2. 拼接曲线时检查拼接点的连续性:分段函数在 t=0/t=1 或拼接处左右值对不上,动画起止帧会肉眼可见地”跳一下”;transform(0) 必须返回 0、transform(1) 必须返回 1。
  3. 关键动效至少在 60Hz 真机上看一遍:高刷屏(120Hz)上同样的 duration 看起来比 60Hz 更快,动画”感觉”不一样;低端机掉帧会让弹性曲线卡成三段——模拟器和开发机说了不算。
  4. 嵌套曲线别超过两层:Interval 里再包 easeOut 已经是极限,再往里塞自定义曲线,出了节奏问题根本说不清是哪一层在捣乱——一层 CurvedAnimation 解决不了再加。

实战:曲线选错比没动画更伤

曲线这东西,选错了比不做动画更伤体验。我的经验就两条:第一,页面进入、列表 item 出现这类”位移”动画,用 Curves.easeOut(快进慢出)最保险,符合手指滑动的直觉;第二,elastic、bounce 这类弹性曲线只适合小范围的强调——比如收藏按钮被点的那一下回弹,用在大面积位移上会显得廉价、拖沓。

另外要纠正这篇正文里的一个说法:Curve 的输入确实是 0.01.0,但输出不一定被夹在这个区间——elasticOut 在中间过程返回值会大于 1(超调),这就是弹性动画的 widget 会”冲过头”再弹回来的原因。写自定义 Curve 时记住这点,别在 transform 里假设输出也被限制在 01,否则弹性效果会被你亲手掐掉。

参考:Flutter 官方文档《Animations》(docs.flutter.dev/ui/animations)

总结

动画曲线是创建流畅自然动画的关键。通过合理使用内置曲线、自定义曲线和组合曲线,可以实现各种丰富的动画效果。理解并掌握动画曲线的使用对于开发高质量的 Flutter 动画很有帮助。


分享这篇文章:


相关文章

上一篇
Flutter Widget 测试详解
下一篇
Flutter 组合动画详解