Flutter中的动画

444 阅读10分钟

动画基础类

在Flutter中有哪些类型的动画?

在Flutter中动画分为两类:基于tween或基于物理的。

  • 补间(Tween)动画:在补间动画中,定义了开始点和结束点、时间线以及定义转换时间和速度的曲线。然后由框架计算如何从开始点过渡到结束点。
  • 基于物理的动画:在基于物理的动画中,运动被模拟为与真实世界的行为相似。例如,当你掷球时,它在何处落地,取决于抛球速度有多快、球有多重、距离地面有多远。 类似地,将连接在弹簧上的球落下(并弹起)与连接到绳子上的球放下的方式也是不同。

如何使用动画库中的基础类给widget添加动画?

在为widget添加动画之前,先让我们认识下动画的几个朋友:

  • Animation:是Flutter动画库中的一个核心类,它生成指导动画的值;
  • CurvedAnimation:Animation的一个子类,将过程抽象为一个非线性曲线;
  • AnimationController:Animation的一个子类,用来管理Animation;
  • Tween:在正在执行动画的对象所使用的数据范围之间生成值。例如,Tween可生成从红到蓝之间的色值,或者从0到255;

Animation

在Flutter中,Animation对象本身和UI渲染没有任何关系。Animation是一个抽象类,它拥有其当前值和状态(完成或停止)。其中一个比较常用的Animation类是Animation<double>

Flutter中的Animation对象是一个在一段时间内依次生成一个区间之间值的类。Animation对象的输出可以是线性的、曲线的、一个步进函数或者任何其他可以设计的映射。 根据Animation对象的控制方式,动画可以反向运行,甚至可以在中间切换方向。

  • Animation还可以生成除double之外的其他类型值,如:Animation<Color>Animation<Size>
  • Animation对象有状态。可以通过访问其value属性获取动画的当前值;
  • Animation对象本身和UI渲染没有任何关系;

CurvedAnimation

CurvedAnimation将动画过程定义为一个非线性曲线。

final CurvedAnimation curve = new CurvedAnimation(parent: controller, curve: Curves.easeIn);

注: Curves 类定义了许多常用的曲线,也可以创建自己的,例如:

class ShakeCurve extends Curve {
 @override
 double transform(double t) {
  return math.sin(t * math.PI * 2);
 }
}

AnimationController

AnimationController是一个特殊的Animation对象,在屏幕刷新的每一帧,就会生成一个新的值。默认情况下,AnimationController在给定的时间段内会线性的生成从0.0到1.0的数字。 例如,下面代码创建一个Animation对象:

final AnimationController controller = new AnimationController(duration: const Duration(milliseconds: 2000), vsync: this);

AnimationController派生自Animation<double>,因此可以在需要Animation对象的任何地方使用。 但是,AnimationController具有控制动画的其他方法:

  • forward():启动动画;
  • reverse({double from}):倒放动画;
  • reset():重置动画,将其设置到动画的开始位置;
  • stop({ bool canceled = true }):停止动画;

当创建一个AnimationController时,需要传递一个vsync参数,存在vsync时会防止屏幕外动画消耗不必要的资源,可以将stateful对象作为vsync的值。

注意: 在某些情况下,值(position,值动画的当前值)可能会超出AnimationController的0.0-1.0的范围。例如,fling()函数允许您提供速度(velocity)、力量(force)、position(通过Force对象)。位置(position)可以是任何东西,因此可以在0.0到1.0范围之外。 CurvedAnimation生成的值也可以超出0.0到1.0的范围。根据选择的曲线,CurvedAnimation的输出可以具有比输入更大的范围。例如,Curves.elasticIn等弹性曲线会生成大于或小于默认范围的值。

Tween

默认情况下,AnimationController对象的范围从0.0到1.0。如果您需要不同的范围或不同的数据类型,则可以使用Tween来配置动画以生成不同的范围或数据类型的值。例如,以下示例,Tween生成从-200.0到0.0的值:

final Tween doubleTween = new Tween<double>(begin: -200.0, end: 0.0);

Tween是一个无状态(stateless)对象,需要begin和end值。Tween的唯一职责就是定义从输入范围到输出范围的映射。输入范围通常为0.0到1.0,但这不是必须的。

Tween继承自Animatable<T>,而不是继承自Animation<T>。Animatable与Animation相似,不是必须输出double值。例如,ColorTween指定两种颜色之间的过渡。

final Tween colorTween = new ColorTween(begin: Colors.transparent, end: Colors.black54);

Tween对象不存储任何状态。相反,它提供了evaluate(Animation<double> animation)方法将映射函数应用于动画当前值。 Animation对象的当前值可以通过value()方法取到。evaluate函数还执行一些其它处理,例如分别确保在动画值为0.0和1.0时返回开始和结束状态。

Tween.animate

要使用Tween对象,可调用它的animate()方法,传入一个控制器对象。例如,以下代码在500毫秒内生成从0到255的整数值。

final AnimationController controller = new AnimationController(duration: const Duration(milliseconds: 500), vsync: this);
​
Animation<int> alpha = new IntTween(begin: 0, end: 255).animate(controller);

注意animate()返回的是一个Animation,而不是一个Animatable。

以下示例构建了一个控制器、一条曲线和一个Tween:

final AnimationController controller = new AnimationController(duration: const Duration(milliseconds: 500), vsync: this);
​
final Animation curve = new CurvedAnimation(parent: controller, curve: Curves.easeOut);
​
Animation<int> alpha = new IntTween(begin: 0, end: 255).animate(curve);

动画手动开发

  • SingleTickerProviderStateMixin

  • 用于Tricker状态保持。创建AnimationController的state时必须使用单独的AnimationController混入到这个类中。然后通过vsync构造这个类

  • Animation

  • AnimationController

  • <Animation.addListener> addListener

    • 动画的监听器,动画每执行一次调用一下setState达到更新的目的
  • <Animation.addStatusListener> addStatusListener

    • 动画状态的监听器,用于在某种状态下作出对应操作
  • Tween组件

import 'package:flutter/material.dart';
​
void main() {
  runApp(const LogoApp());
}
​
class LogoApp extends StatefulWidget {
  const LogoApp({Key? key}) : super(key: key);
​
  @override
  _LogoAppState createState() => _LogoAppState();
} 
​
class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
  //with是dart的关键字,意思是混入的意思,就是说可以将一个或者多个类的功能添加到自己的类无需继承这些类,避免多重继承导致的问题。可以在https://stackoverflow.com/questions/21682714/with-keyword-in-dart中找到答案
  //为什么是SingleTickerProviderStateMixin呢,因为初始化animationController的时候需要一个TickerProvider类型的参数Vsync参数,所以我们混入了TickerProvider的子类SingleTickerProviderStateMixin
  late Animation<double> animation; //该对象是当前动画的状态,例如动画是否开始,停止,前进,后退。但是绘制再屏幕上的内容不知道
  late AnimationController controller; //该对象管理着animation对象
  late AnimationStatus animationStatus;
  late double animationValue;
​
  @override
  void initState() {
    super.initState();
    controller = AnimationController(vsync: this, duration: const Duration(seconds: 2));
    // curve 曲线是定义动画的动作,也就说动画是非线性运动
    // animation = new CurvedAnimation(parent: animationController, curve: Curves.fastOutSlowIn);
    // tween 是定义动画的动作,也就说动画是线性运动
    animation = Tween<double>(begin: 0, end: 300).animate(controller)
    ..addListener(() {
      setState(() {
        animationValue = animation.value;
      });
    })
    ..addStatusListener((AnimationStatus status) {
      setState(() {
        animationStatus = status;
      });
    });
  }
​
  @override
  void dispose() {
    // 动画需要在组件销毁时销毁,毕竟带着计时器呢
    controller.dispose();
    super.dispose();
  }
​
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        GestureDetector(
          onTap: () {
            controller.reset();
            // 执行动画(非重新执行)。配合reset方法达到重新执行
            controller.forward();
          },
          child: const Text('start', textDirection: TextDirection.ltr,),
        ),
        Text('state' + animationStatus.toString(), textDirection: TextDirection.ltr),
        Text('value' + animationValue.toString(), textDirection: TextDirection.ltr),
        SizedBox(
          height: animation.value,
          width: animation.value,
          child: const FlutterLogo(),
        )
      ],
    );
  }
}
​

使用AnimatedWidget和AnimatedBuilder简化动画

AnimatedWidget

简化addListener等操作。将动画封装到具体类中。调用该类并传入animation

import 'package:flutter/material.dart';
​
class LogoApp extends StatefulWidget {
  const LogoApp({Key? key}) : super(key: key);
​
  @override
  _LogoAppState createState() => _LogoAppState();
}
​
class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
  AnimationController? controller;
  Animation<double>? animation;
​
  @override
  void initState() {
    controller = AnimationController(vsync: this, duration: const Duration(seconds: 2));
    animation = Tween<double>(begin: 0, end: 300).animate(controller!);
    controller!.forward();
    super.initState();
  }
​
  @override
  Widget build(BuildContext context) {
    return AnimatedLogo(animation: animation!);
  }
​
}
​
class AnimatedLogo extends AnimatedWidget {
  const AnimatedLogo({Key? key, required Animation<double> animation}) : super(key: key, listenable: animation);
​
  @override
  Widget build(BuildContext context) {
    final Animation<double> animation = listenable as Animation<double>;
    return Center(
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 10),
        height: animation.value,
        width: animation.value,
        child: const FlutterLogo(),
      ),
    );
  }
}
​

AnimatedBuilder

AnimatedBuilder是拆分动画的一个工具类,借助它可以实现将动画和Widget进行分离

以上实例存在的问题:更改动画需要更改显示logo的Widget,更好的解决办法是将职责分离

  • 显示logo
  • 定义Animation对象
  • 渲染过度效果

除了FlutterLogo(),也可以传入其他Widget复用动画

import 'package:flutter/material.dart';
​
class LogoApp extends StatefulWidget {
  const LogoApp({Key? key}) : super(key: key);
​
  @override
  _LogoAppState createState() => _LogoAppState();
}
​
class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
  AnimationController? controller;
  Animation<double>? animation;
​
  @override
  void initState() {
    controller = AnimationController(vsync: this, duration: const Duration(seconds: 2));
    animation = Tween<double>(begin: 0, end: 300).animate(controller!);
    controller!.forward();
    super.initState();
  }
  @override
  void dispose() {
    controller!.dispose();
    super.dispose();
  }
​
  @override
  Widget build(BuildContext context) {
    return GrowTransition(animation: animation!, child: FlutterLogo(),);
  }
}
​
class LogoWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return Container(
      child: FlutterLogo(),
    );
  }
}
​
class GrowTransition extends StatelessWidget {
  GrowTransition({ this.child, this.animation });
​
  final Widget? child;
  final Animation<double>? animation;
​
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(animation: animation!, builder: (context, child) => Container(
      height: animation!.value,
      width: animation!.value,
      child: child,
    ),
      child: child,
    );
  }
}

Hero动画

  • Hero指的是可以在路由(页面)之间“飞行”的widget。
  • 使用Flutter的Hero widget创建hero动画。
  • 将 hero从一个路由飞到另一个路由。
  • 将 hero 的形状从圆形转换为矩形,同时将其从一个路由飞到另一个路由的过程中进行动画处理。
  • Flutter中的Hero widget实现了通常称为 共享元素转换 或 共享元素动画的动画风格。

你可能多次看过 hero 动画。例如,路由显示代表待售物品的缩略图列表。选择一个条目会将其跳转到一个新路由,新页面中包含更多详细信息和“购买”按钮。 在Flutter中将图片从一个路由飞到另一个路由称为hero动画,尽管相同的动作有时也称为 共享元素转换。

Hero动画的基本结构

  • 在不同路由中使用两个 hero widget,但使用匹配的标签来实现动画。
  • 导航器管理包含应用程序路由的栈。
  • 从导航器栈中推入或弹出路由会触发动画。
  • Flutter框架会计算一个补间矩形 ,用于定义在从源路由“飞行”到目标路由时 hero 的边界。在“飞行”过程中, hero 会移动到应用程序上的一个叠加层,以便它出现在两个页面之上。

什么是Hero动画?

​​

​​

在 Flutter中可以用 Hero widget创建这个动画。当 hero 通过动画从源页面飞到目标页面时,目标页面逐渐淡入视野。通常, hero 是用户界面的一小部分,如图片,它通常在两个页面都有。从用户的角度来看, hero 在页面之间“飞翔”。接下来我们一起来学习如何创建Hero动画:

实现标准hero动画

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show timeDilation;
​
class PhotoHero extends StatelessWidget {
 const PhotoHero({ Key key, this.photo, this.onTap, this.width }) : super(key: key);
​
 final String photo;
 final VoidCallback onTap;
 final double width;
​
 Widget build(BuildContext context) {
  return SizedBox(
   width: width,
   child: Hero(
•    tag: photo,
•    child: Material(
•     color: Colors.transparent,
•     child: InkWell(
•      onTap: onTap,
•      child: Image.network(
•       photo,
•       fit: BoxFit.contain,
•      ),
•     ),
•    ),
   ),
  );
 }
}
​
class HeroAnimation extends StatelessWidget {
 Widget build(BuildContext context) {
  // 播放速度
  timeDilation = 10.0; *// 1.0 means normal animation speed.*
​
  return Scaffold(
   appBar: AppBar(
•    title: const Text('Basic Hero Animation'),
   ),
   body: Center(
•    child: PhotoHero(
•     photo: 'https://raw.githubusercontent.com/flutter/website/master/examples/_animation/hero_animation/images/flippers-alpha.png',
•     width: 300.0,
•     onTap: () {
•      Navigator.of(context).push(MaterialPageRoute<void>(
•        builder: (BuildContext context) {
•         return Scaffold(
•          appBar: AppBar(
•           title: const Text('Flippers Page'),
•          ),
•          body: Container(
•           *// Set background to blue to emphasize that it's a new route.*
•           color: Colors.lightBlueAccent,
•           padding: const EdgeInsets.all(16.0),
•           alignment: Alignment.topLeft,
•           child: PhotoHero(
•            photo: 'https://raw.githubusercontent.com/flutter/website/master/examples/_animation/hero_animation/images/flippers-alpha.png',
•            width: 100.0,
•            onTap: () {
•             Navigator.of(context).pop();
•            },
•           ),
•          ),
•         );
•        }
•      ));
•     },
•    ),
   ),
  );
 }
}
​
void main() {
 // Navigator必须放置于root上的 statefulWidget组件内,使用MaterialApp组件包裹即可使用
 runApp(MaterialApp(home: HeroAnimation()));
}

Hero的函数原型

const Hero({
  Key key,
  @required this.tag,
  this.createRectTween,
  this.flightShuttleBuilder,
  this.placeholderBuilder,
  this.transitionOnUserGestures = false,
  @required this.child,
 }) : assert(tag != null),
•    assert(transitionOnUserGestures != null),
•    assert(child != null),
•    super(key: key);
  • tag:[必须]用于关联两个Hero动画的标识;
  • createRectTween:[可选]定义目标Hero的边界,在从起始位置到目的位置的“飞行”过程中该如何变化;
  • child:[必须]定义动画所呈现的widget;

实现径向hero动画

透明度变化

static Interval opacityCurve = const Interval(0.0, 0.75, curve: Curves.fastOutSlowIn);
​
PageRouteBuilder<void>(
    pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
        return AnimatedBuilder(
            animation: animation,
            builder: <Widget>(BuildContext context, Widget child) {
                return Opacity(
                    opacity: opacityCurve.transform(animation.value),
                    child: _buildPage(context, imageName, description),
                );
            }
        );
    },
),

完整实例 Video_2022-05-20_113206.gif

import 'dart:math' as math;
​
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show timeDilation;
​
class Photo extends StatelessWidget {
  Photo({ Key? key, required this.photo, required this.color, required this.onTap }) : super(key: key);
​
  final String photo;
  final Color color;
  final VoidCallback onTap;
​
  Widget build(BuildContext context) {
    return Material(
      // Slightly opaque color appears where the image has transparency.
      color: Theme.of(context).primaryColor.withOpacity(0.25),
      child: InkWell(
        onTap: onTap,
        child: LayoutBuilder(
          builder: (BuildContext context, BoxConstraints size) {
            return Image.network(
              photo,
              fit: BoxFit.contain,
            );
          },
        ),
      ),
    );
  }
}
​
class RadialExpansion extends StatelessWidget {
  const RadialExpansion({
    Key? key,
    required this.maxRadius,
    required this.child,
  }) : clipRectSize = 2.0 * (maxRadius / math.sqrt2),
        super(key: key);
​
  final double maxRadius;
  final clipRectSize;
  final Widget child;
​
  @override
  Widget build(BuildContext context) {
    return ClipOval(
      child: Center(
        child: SizedBox(
          width: clipRectSize,
          height: clipRectSize,
          child: ClipRect(
            child: child,
          ),
        ),
      ),
    );
  }
}
​
class RadialExpansionDemo extends StatelessWidget {
  static double kMinRadius = 32.0;
  static double kMaxRadius = 128.0;
  static Interval opacityCurve = const Interval(0.0, 0.75, curve: Curves.fastOutSlowIn);
​
  const RadialExpansionDemo({Key? key}) : super(key: key);
​
  static RectTween _createRectTween(Rect begin, Rect end) {
    return MaterialRectCenterArcTween(begin: begin, end: end);
  }
​
  static Widget _buildPage(BuildContext context, String imageName, String description) {
    return Container(
      color: Theme.of(context).canvasColor,
      child: Center(
        child: Card(
          elevation: 8.0,
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              SizedBox(
                width: kMaxRadius * 2.0,
                height: kMaxRadius * 2.0,
                child: Hero(
                  createRectTween: _createRectTween as CreateRectTween,
                  tag: imageName,
                  child: RadialExpansion(
                    maxRadius: kMaxRadius,
                    child: Photo(
                      photo: imageName,
                      color: Colors.transparent,
                      onTap: () {
                        Navigator.of(context).pop();
                      },
                    ),
                  ),
                ),
              ),
              Text(
                description,
                style: TextStyle(fontWeight: FontWeight.bold),
                textScaleFactor: 3.0,
              ),
              const SizedBox(height: 16.0),
            ],
          ),
        ),
      ),
    );
  }
​
  Widget _buildHero(BuildContext context, String imageName, String description) {
    return Container(
      width: kMinRadius * 2.0,
      height: kMinRadius * 2.0,
      child: Hero(
        createRectTween: _createRectTween  as CreateRectTween,
        tag: imageName,
        child: RadialExpansion(
          maxRadius: kMaxRadius,
          child: Photo(
            photo: imageName,
            onTap: () {
              Navigator.of(context).push(
                PageRouteBuilder<void>(
                  pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
                    return AnimatedBuilder(
                        animation: animation,
                        builder: <Widget>(BuildContext context, Widget child) {
                          return Opacity(
                            opacity: opacityCurve.transform(animation.value),
                            child: _buildPage(context, imageName, description),
                          );
                        }
                    );
                  },
                ),
              );
            }, color: Colors.transparent,
          ),
        ),
      ),
    );
  }
​
  @override
  Widget build(BuildContext context) {
    timeDilation = 5.0; // 1.0 is normal animation speed.
​
    return Scaffold(
      appBar: AppBar(
        title: const Text('Radial Transition Demo'),
      ),
      body: Container(
        padding: const EdgeInsets.all(32.0),
        alignment: FractionalOffset.bottomLeft,
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            _buildHero(context, 'https://raw.githubusercontent.com/flutter/website/master/examples/_animation/radial_hero_animation/images/chair-alpha.png', 'Chair'),
            _buildHero(context, 'https://raw.githubusercontent.com/flutter/website/master/examples/_animation/radial_hero_animation/images/binoculars-alpha.png', 'Binoculars'),
            _buildHero(context, 'https://raw.githubusercontent.com/flutter/website/master/examples/_animation/radial_hero_animation/images/beachball-alpha.png', 'Beach ball'),
          ],
        ),
      ),
    );
  }
}

\