[翻译] Flutter 中的动画 - 简易指南 - 教程

非常感谢 Didier Boelens 同意我将它的一些文章翻译为中文发表,这是其中一篇。

本文通过一个实例详细讲解了 Flutter 中动画的原理。

原文的代码块有行号,对修改的代码有黄色背景。在这里不能对代码添加行号和背景颜色,所以,为方便阅读有些代码块用了截图。

原文 链接

 

Flutter中的动画功能强大而且使用起来非常简单。 通过一个具体的例子,您将学习如何构建自己的动画所需的一切。

难度:中等

今天我们无法想象没有任何动画的移动应用。 当您从一个页面移动到另一个页面时,点击一个按钮(或InkWell)… 就有一个动画。 动画无处不在。

Flutter使动画效果非常容易实现。 用非常简单的话来说,这篇文章讨论了这个主题,早些时候这些事只能留给专家,为了使这篇论文具有吸引力,我采取了用 Flutter 逐步实现以下断头台效果菜单,这个动画是由 Vitaly Rubtsov 在 Dribble 上发布的。

本文的第一部分讲解了动画的理论和主要概念。 第二部分专门用于动画的实现,就如上面的动图所显示的那样。

动画的 3 个支柱

要有 动画 效果,需要存在以下3个元素:

  • 一个 Ticker (断续器)

  • 一个 动作(Animation)

  • 一个 动作控制器 (AnimationController)

断续器 (Ticker)

简单来说,Ticker 是一个几乎定时发送信号的类(大约每秒60次)。 想想你的手表每秒钟都会嘀嗒一声。 在每个滴答处,Ticker 调用回调方法,该方法具有自第一个滴答开始后的持续时间。

重要

即使在不同时间启动,所有的 ticher 也将 始终同步。 这对于同步动画动作非常有用

动画

动画只不过是一个可以在动画的生命周期内改变的值(特定类型)。 这个值在动画时间内的变化方式可以是线性的(如1,2,3,4,5 …),也可以更复杂(参见后面的曲线)。

动画控制器

AnimationController 是一个控制(启动,停止,重复......)动画(或几个动画)的类。 换句话说,它使用速度(=每秒的值变化率)使动画值在特定持续时间内从一个低的边界值(lowerBound) 变为 一个高的边界值(upperBound)。

AnimationController 类

此类可控制动画。 为了更精确,我更愿说“在一个场景”,因为我们稍后会看到,几个不同的动画可以由同一个控制器控制......

因此,使用此 AnimationController 类,我们可以:

  • 向前播放一个场景,反转

  • 停止一个场景

  • 将场景设置为某个值

  • 定义场景的边界值(lowerBound,upperBound)

以下伪代码显示了此类的各种不同的初始化参数:

AnimationController controller = new AnimationController(
	value:		// the current value of the animation, usually 0.0 (= default)
	lowerBound:	// the lowest value of the animation, usually 0.0 (= default)
	upperBound:	// the highest value of the animation, usually 1.0 (= default)
	duration:	// the total duration of the whole animation (scene)
	vsync:		// the ticker provider
	debugLabel:	// a label to be used to identify the controller
			// during debug session
);
复制代码

大多数时候,初始化 AnimationController 时,value,lowerBound,upperBound和debugLabel都没有被提到。

如何将 AnimationController 绑定到 Ticker?

为了起作用,需要将 AnimationController 绑定到 Ticker

通常地,您将生成一个 Ticker,链接到 Stateful Widget的一个实例。

  • 第2行

    告诉 Flutter 你想要一个 新的 单个 Ticker,链接到 MyStateWidget 的这个实例

  • 第8-10行

    控制器初始化。 场景 的总持续时间设置为 1000 毫秒并绑定到 Ticker(vsync:this)。 默认的参数是:lowerBound = 0.0和upperBound = 1.0

  • 第16行

    非常重要,您需要在销毁 MyStateWidget 实例时释放控制器。

TickerProviderStateMixin 或 SingleTickerProviderStateMixin?

如果您有多个 AnimationController 实例并且想要各自具有不同的 Ticker,请将 TickerProviderStateMixin 替换为 SingleTickerProviderStateMixin

OK,我将控制器绑定到 Ticker 但是它有什么帮助?

多亏了每秒约60次的tick,AnimationController在给定的持续时间内线性生成从 lowerBound 到 upperBound 的值。

在这1000毫秒内生成的值的示例:

我们看到值在1000毫秒内从0.0(lowerBound)变到1.0(upperBound)。 生成了51个不同的值。

让我们打开代码以了解如何使用它。

  • 第12行

    这行告诉控制器每次它的值改变时,我们需要重新构建 Widget(通过 setState()

  • 第15行

    Widget 初始化完成后,我们告诉控制器开始计数(forward() ->lowerBoundupperBound

  • 第26行

    我们恢复控制器的值( _controller.value ),并且,在这个例子中,这个值的范围是0.0到1.0(0%到100%),我们得到这个百分比的整数表达式,显示在屏幕中心。

动画的概念

正如我们刚刚看到的那样,控制器返回一系列 十进制值,这些值以 线性 方式变化。 有时,我们希望:

  • 使用其他 类型 的值,例如 Offsetint ...

  • 使用不同于 0.0 到 1.0 的值范围

  • 考虑除线性之外的其他 变化 类型以产生一些效果

使用其他类型的值

为了能够使用其他值类型,Animation 类使用 泛型

换句话说,您可以定义:

Animation<int> integerVariation;
Animation<double> decimalVariation;
Animation<Offset> offsetVariation;
复制代码

使用不同的值变化范围

有时,我们希望在不同于 0.0 和 1.0 的 两个值之间进行变化。

为了定义这样的范围,我们将使用 Tween 类。

为了说明这一点,让我们考虑一下你希望角度从 0 到 π/2 弧度变化的情况。

Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2);
复制代码

变化类型

如前所述,将值从 lowerBound 变为 upperBound 的默认方式是 线性 的,这是控制器的工作方式。

如果要使角度在 0 到 π/2 弧度之间线性变化,请将 Animation 绑定到 AnimationController

Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2).animate(_controller);
复制代码

当你启动动画时(通过 _controller.forward() ),angleAnimation.value 将使用 _controller.value 的值来插入 [0.0; π/2] 的范围。

下图显示了这种线性变化(π/2 = 1.57)

使用 flutter 预定义的 变化曲线

Flutter 提供了一组预定义的变化曲线,列表显示如下:

使用这些曲线:

Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2).animate(
	new CurvedAnimation(
		parent: _controller,
		curve:  Curves.ease,
		reverseCurve: Curves.easeOut
	));
复制代码

这创建了一个值的变化[0; π/2],这个变化使用一下曲线:

  • Curves.ease 当动画从 0.0 -> π/2 (向前)

  • Curves.easeOut 当动画从 π/2 -> 0.0 (反转)

控制动画

AnimationController 是允许您通过 API 控制动画的类。(这是最常用的 API):

  • _controller.forward({ double from })

    要求控制器开始 lowerBound - > upperBound 的值的变化。可选参数 from 可用于强制控制器从另一个值开始“计数”而不是 lowerBound

  • _controller.reverse({ double from })

    要求控制器开始 upperBound - > lowerBound 的值变化。可选参数 from 可用于强制控制器从 upperBound 以外的另一个值开始“计数”

  • _controller.stop({ bool canceled: true })

    停止运行动画

  • _controller.reset()

    将动画重置为 lowerBound

  • _controller.animateTo(double target, { Duration duration, Curve curve: Curves.linear })

    将动画从其当前值驱动到目标值

  • _controller.repeat({ double min, double max, Duration period })

    开始向前执行动画,并在完成时重复执行动画。 如果定义了 min 和 max 值,则 min 和 max 限制重复发生的次数。

让我们安全……

由于动画可能会意外停止(例如屏幕被退出),因此在使用这些 API 时,添加 “.orCancel” 会更安全:

__controller.forward().orCancel;
复制代码

由于这个小技巧,如果在销毁 _controller 之前取消 Ticker,不会抛出任何异常。

场景的概念

官方文档中不存在 “场景(scene)” 这个词,但就个人而言,我发现它更接近现实。 让我解释。

正如我所说,一个 AnimationController 管理动画。 但是,我们可能会将 “动画(Animation)” 这个词理解为一系列需要按顺序或重叠播放的子动画。关于 如何将子动画链接在一起的定义,我就称之为“场景”。

考虑以下情况,其中动画的整个持续时间为10秒,我们希望:

  • 前2秒,球从左侧移动到屏幕中间

  • 然后,同一个球需要3秒钟从屏幕的中心移动到顶部中心

  • 最后,球需要5秒淡出。

正如您可能已经想象的那样,我们必须考虑 3 种不同的动画:

///
/// _controller 定义,整个持续时间为10秒
///
AnimationController _controller = new AnimationController(
	duration: const Duration(seconds: 10), 
	vsync: this
);

///
/// 第一个动画,将球从左侧移动到中心
///
Animation<Offset> moveLeftToCenter = new Tween(
	begin: new Offset(0.0, screenHeight /2), 
	end: new Offset(screenWidth /2, screenHeight /2)
).animate(_controller);

///
/// 第二个动画,将球从中心移动到顶部
///
Animation<Offset> moveCenterToTop = new Tween(
	begin: new Offset(screenWidth /2, screenHeight /2), 
	end: new Offset(screenWidth /2, 0.0)
).animate(_controller);

///
/// 第三个动画,改变球的不透明度,使其消失
///
Animation<double> disappear = new Tween(
	begin: 1.0, 
	end: 0.0
).animate(_controller);
复制代码

现在问题是,我们如何链接(或编排)子动画?

间隔(Interval)的概念

答案是通过使用 Interval 类来给出的。 但什么是 间隔(Interval)

可能与我们脑袋里冒出的一个想法相反,一个 间隔时间间隔 无关,而与 一系列值 有关。

如果你考虑 _controller,你必须记住 它是使一个值从 lowerBound 变为 upperBound

通常,这两个值分别保持在 lowerBound = 0.0upperBound = 1.0,这使事情更容易考虑,因为 [0.0 -> 1.0] 只不过是从0%到100%的变化。 因此,如果一个场景的总持续时间是10秒,那么在5秒之后,相应的_controller.value 将非常接近0.5(= 50%)。

如果我们在时间轴上放置这 3 个不同的动画,我们会得到:

如果我们现在考虑值的区间,对于3个动画中的每一个,我们得到:

  • 从左移动到中心

    持续时间:2秒,从0秒开始,以2秒结束=>范围= [0; 2] =>百分比:从整个场景的0%到20%=> [0.0; 0.20]

  • 从中心移动到顶部

    持续时间:3秒,从2秒开始,在第 5 秒结束=>范围= [2; 5] =>百分比:从整个场景的20%到50%=> [0.20;0.50]

  • 消失

    持续时间:5秒,从5秒开始,以10秒结束=>范围= [5; 10] =>百分比:从整个场景的50%到100%=> [0.50; 1.0]

现在我们有这些百分比,我们可以更新每个动画的定义,如下所示:

///
/// _controller 定义,整个持续时间为10秒
///
AnimationController _controller = new AnimationController(
	duration: const Duration(seconds: 10), 
	vsync: this
);

///
/// 第一个动画,将球从左侧移动到中心
///
Animation<Offset> moveLeftToCenter = new Tween(
	begin: new Offset(0.0, screenHeight /2), 
	end: new Offset(screenWidth /2, screenHeight /2)
	).animate(
            new CurvedAnimation(
                parent: _controller,
                curve:  new Interval(
                    0.0,
                    0.20,
                    curve: Curves.linear,
                ),
            ),
        );

///
/// 第二个动画,将球从中心移动到顶部
///
Animation<Offset> moveCenterToTop = new Tween(
	begin: new Offset(screenWidth /2, screenHeight /2), 
	end: new Offset(screenWidth /2, 0.0)
	).animate(
            new CurvedAnimation(
                parent: _controller,
                curve:  new Interval(
                    0.20,
                    0.50,
                    curve: Curves.linear,
                ),
            ),
        );

///
/// 第三个动画,改变球的不透明度,使其消失
///
Animation<double> disappear = new Tween(begin: 1.0, end: 0.0)
        .animate(
            new CurvedAnimation(
                parent: _controller,
                curve:  new Interval(
                    0.50,
                    1.0,
                    curve: Curves.linear,
                ),
            ),
        );
复制代码

这就是定义场景(或一系列动画)所需的全部内容。 当然,没有什么能阻止你重叠子动画……

响应动画状态

有时,知道动画(或场景)的状态很有用。

动画有4种不同的状态:

  • 搁置(dismissed):动画在开始处停止(或尚未开始)

  • 向前(forward):动画从开始到结束

  • 反向(reverse):动画反向运行,从结束到开始

  • 已完成(completed):动画在结束时停止

要获得这些状态,我们需要通过以下方式监听动画状态改变:

    myAnimation.addStatusListener((AnimationStatus status){
        switch(status){
            case AnimationStatus.dismissed:
                ...
                break;

            case AnimationStatus.forward:
                ...
                break;

            case AnimationStatus.reverse:
                ...
                break;

            case AnimationStatus.completed:
                ...
                break;
        }
    });
复制代码

一个典型的用法是,如果此状态是往复切换。 例如,一旦动画完成,我们想要反转它。 为达到这个效果:

    myAnimation.addStatusListener((AnimationStatus status){
        switch(status){
            ///
            /// 当动画开始时,我们强制播放动画
            ///
            case AnimationStatus.dismissed:
                _controller.forward();
                break;

            ///
            /// 当动画结束时,我们强制动画反转执行
            ///
            case AnimationStatus.completed:
                _controller.reverse();
                break;
        }
    });
复制代码

理论已经足够了,让我们现在实践!

既然已经介绍了理论,那么现在是时候实践了……

正如我在本文开头所提到的,我现在将通过实现一个名为 “断头台” 的动画来实现动画的概念。

分析动画和初始骨架

为了获得这种 断头台 效应,我们首先需要考虑:

  • 页面内容本身

  • 菜单栏,当我们点击 菜单(或汉堡包)图标时旋转

  • 旋转 进来 时,菜单会重叠页面内容并填充整个屏幕窗口

  • 一旦菜单完全可见,我们再次点击菜单图标,菜单就会旋转 出去,以便回到原来的位置和尺寸

从这些观察中,我们可以立即推断出我们不能使用带有 AppBar 的普通 Scaffold (因为后者是固定的)。

我们相反地会使用两层的 Stack

  • 页面内容(下层)

  • 菜单(上层)

让我们首先构建这个骨架:

class MyPage extends StatefulWidget {
    @override
    _MyPageState createState() => new _MyPageState();
}

class _MyPageState extends State<MyPage>{
  @override
  Widget build(BuildContext context){
      return SafeArea(
        top: false,
        bottom: false,
        child: new Container(
          child: new Stack(
            alignment: Alignment.topLeft,
            children: <Widget>[
              new Page(),
              new GuillotineMenu(),
            ],
          ),
        ),
      );
  }
}

class Page extends StatelessWidget {
    @override
    Widget build(BuildContext context){
        return new Container(
            padding: const EdgeInsets.only(top: 90.0),
            color: Color(0xff222222),
        );
    }
}

class GuillotineMenu extends StatefulWidget {
    @override
    _GuillotineMenuState createState() => new _GuillotineMenuState();
}

class _GuillotineMenuState extends State<GuillotineMenu> {

    @overrride
    Widget build(BuildContext context){
        return new Container(
            color: Color(0xff333333),
        );
    }
}
复制代码

这段代码的效果是一个黑屏,只显示了 GuillotineMenu,覆盖了整个视口。

分析菜单本身

如果你仔细看视频,可以看到当菜单完全打开时,它完全覆盖了屏幕。 当它刚刚打开时,只能看到像 AppBar 这样的东西。

没有什么能阻止我们以不同的方式看待事物……如果 GuillotineMenu 最初会被旋转,当我们点击菜单按钮时,我们将其旋转π/ 2,如下图所示?

然后我们可以按如下方式重写 _GuillotineMenuState 类:(没有给出关于创建布局方法的解释,因为这不是本文的目的)

class _GuillotineMenuState extends State<GuillotineMenu> {
   double rotationAngle = 0.0;

    @override
    Widget build(BuildContext context){
        MediaQueryData mediaQueryData = MediaQuery.of(context);
		double screenWidth = mediaQueryData.size.width;
		double screenHeight = mediaQueryData.size.height;

		return new Transform.rotate(
				angle: rotationAngle,
				origin: new Offset(24.0, 56.0),
				alignment: Alignment.topLeft,
				child: Material(
					color: Colors.transparent,
					child: Container(
					width: screenWidth,
					height: screenHeight,
					color: Color(0xFF333333),
					child: new Stack(
						children: <Widget>[
							_buildMenuTitle(),
							_buildMenuIcon(),
							_buildMenuContent(),
						],
					),
				),
			),
		);
    }

	///
	/// Menu Title
	///
	Widget _buildMenuTitle(){
		return new Positioned(
			top: 32.0,
			left: 40.0,
			width: screenWidth,
			height: 24.0,
			child: new Transform.rotate(
				alignment: Alignment.topLeft,
				origin: Offset.zero,
				angle: pi / 2.0,
				child: new Center(
				child: new Container(
					width: double.infinity,
					height: double.infinity,
					child: new Opacity(
					opacity: 1.0,
					child: new Text('ACTIVITY',
						textAlign: TextAlign.center,
						style: new TextStyle(
							color: Colors.white,
							fontSize: 20.0,
							fontWeight: FontWeight.bold,
							letterSpacing: 2.0,
						)),
					),
				),
			)),
		);
	}

	///
	/// Menu Icon
	/// 
	Widget _buildMenuIcon(){
		return new Positioned(
			top: 32.0,
			left: 4.0,
			child: new IconButton(
				icon: const Icon(
					Icons.menu,
					color: Colors.white,
				),
				onPressed: (){},
			),
		);
	}

	///
	/// Menu content
	///
	Widget _buildMenuContent(){
		final List<Map> _menus = <Map>[
			{
			"icon": Icons.person,
			"title": "profile",
			"color": Colors.white,
			},
			{
			"icon": Icons.view_agenda,
			"title": "feed",
			"color": Colors.white,
			},
			{
			"icon": Icons.swap_calls,
			"title": "activity",
			"color": Colors.cyan,
			},
			{
			"icon": Icons.settings,
			"title": "settings",
			"color": Colors.white,
			},
		];

		return new Padding(
			padding: const EdgeInsets.only(left: 64.0, top: 96.0),
			child: new Container(
				width: double.infinity,
				height: double.infinity,
				child: new Column(
					mainAxisAlignment: MainAxisAlignment.start,
					children: _menus.map((menuItem) {
						return new ListTile(
							leading: new Icon(
							menuItem["icon"],
							color: menuItem["color"],
							),
							title: new Text(
							menuItem["title"],
							style: new TextStyle(
								color: menuItem["color"],
								fontSize: 24.0),
							),
						);
					}).toList(),
				),
			),
		);
	}
}
复制代码
  • 第10-13行

    这几行定义断头台菜单围绕一个旋转中心旋转,(菜单图标的位置)

现在这段代码的结果给出了一个未旋转的菜单屏幕(因为 rotationAngle = 0.0),它显示了垂直显示的标题。

为菜单添加动画效果

如果更新 rotationAngle 的值(在 -π/2 和 0 之间),您将看到按相应角度旋转的菜单。

让我们做一些动画

如前所述,我们需要

  • 一个 SingleTickerProviderStateMixin,因为我们只有一个场景

  • 一个 AnimationController

  • 具有角度变化的动画

代码变成这样了:

class _GuillotineMenuState extends State<GuillotineMenu>
	with SingleTickerProviderStateMixin {

    AnimationController animationControllerMenu;
    Animation<double> animationMenu;

	///
	/// Menu Icon, onPress() handling
	///
    _handleMenuOpenClose(){
        animationControllerMenu.forward();
    }

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

	///
    	/// Initialization of the animation controller
    	///
        animationControllerMenu = new AnimationController(
			duration: const Duration(milliseconds: 1000), 
			vsync: this
		)..addListener((){
            setState((){});
        });

	///
    	/// Initialization of the menu appearance animation
    	///
        _rotationAnimation = new Tween(
			begin: -pi/2.0, 
			end: 0.0
		).animate(animationControllerMenu);
    }

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

    @override
    Widget build(BuildContext context){
        MediaQueryData mediaQueryData = MediaQuery.of(context);
		double screenWidth = mediaQueryData.size.width;
		double screenHeight = mediaQueryData.size.height;
		double angle = animationMenu.value;
		
		return new Transform.rotate(
			angle: angle,
			origin: new Offset(24.0, 56.0),
			alignment: Alignment.topLeft,
			child: Material(
				color: Colors.transparent,
				child: Container(
					width: screenWidth,
					height: screenHeight,
					color: Color(0xFF333333),
					child: new Stack(
						children: <Widget>[
							_buildMenuTitle(),
							_buildMenuIcon(),
							_buildMenuContent(),
						],
					),
				),
			),
		);
    }

	...
	///
	/// Menu Icon
	/// 
	Widget _buildMenuIcon(){
		return new Positioned(
			top: 32.0,
			left: 4.0,
			child: new IconButton(
				icon: const Icon(
					Icons.menu,
					color: Colors.white,
				),
				onPressed: _handleMenuOpenClose,
			),
		);
	}
	...
}
复制代码

OK,当我们点击菜单按钮时,菜单会打开,但是当我们再次按下按钮时不会关闭。 这就是AnimationStatus 的作用。

让我们添加一个监听器,并根据 AnimationStatus 决定是向前还是向后运行动画。

///
/// Menu animation status
///
enum _GuillotineAnimationStatus { closed, open, animating }

class _GuillotineMenuState extends State<GuillotineMenu>
	with SingleTickerProviderStateMixin {
    AnimationController animationControllerMenu;
    Animation<double> animationMenu;
    _GuillotineAnimationStatus menuAnimationStatus = _GuillotineAnimationStatus.closed;

    _handleMenuOpenClose(){
        if (menuAnimationStatus == _GuillotineAnimationStatus.closed){
            animationControllerMenu.forward().orCancel;
        } else if (menuAnimationStatus == _GuillotineAnimationStatus.open) {
            animationControllerMenu.reverse().orCancel;
        }
    }

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

	///
    	/// Initialization of the animation controller
    	///
        animationControllerMenu = new AnimationController(
			duration: const Duration(milliseconds: 1000), 
			vsync: this
		)..addListener((){
            setState((){});
        })..addStatusListener((AnimationStatus status) {
            if (status == AnimationStatus.completed) {
		///
		/// When the animation is at the end, the menu is open
		///
              menuAnimationStatus = _GuillotineAnimationStatus.open;
            } else if (status == AnimationStatus.dismissed) {
		///
		/// When the animation is at the beginning, the menu is closed
		///
              menuAnimationStatus = _GuillotineAnimationStatus.closed;
            } else {
		///
		/// Otherwise the animation is running
		///
              menuAnimationStatus = _GuillotineAnimationStatus.animating;
            }
          });

	...
    }
...
}
复制代码

菜单现在按预期打开或关闭,但视频向我们显示了一个开放/关闭动作,它不是线性的,看起来像一个弹跳效果。 让我们添加这个效果。

为此,我将选择以下两种效果:

  • bounceOut 菜单打开时

  • bounceIn 菜单关闭时

在这个实现中仍然有一些遗漏的东西......打开菜单时标题消失,关闭菜单时又显示出来了。 这是一个 淡出/淡入 效果,也可以作为动画处理。 我们加上吧。

class _GuillotineMenuState extends State<GuillotineMenu>
    with SingleTickerProviderStateMixin {
  AnimationController animationControllerMenu;
  Animation<double> animationMenu;
  Animation<double> animationTitleFadeInOut;
  _GuillotineAnimationStatus menuAnimationStatus;

...
  @override
  void initState(){
	...
    ///
    /// Initialization of the menu title fade out/in animation
    /// 
    animationTitleFadeInOut = new Tween(
		begin: 1.0, 
		end: 0.0
	).animate(new CurvedAnimation(
      	parent: animationControllerMenu,
      	curve: new Interval(
        	0.0,
        	0.5,
        	curve: Curves.ease,
      	),
    ));
  }
...
  ///
  /// Menu Title
  ///
  Widget _buildMenuTitle(){
    return new Positioned(
	  top: 32.0,
	  left: 40.0,
	  width: screenWidth,
	  height: 24.0,
	  child: new Transform.rotate(
		alignment: Alignment.topLeft,
		origin: Offset.zero,
		angle: pi / 2.0,
		child: new Center(
	  	  child: new Container(
			width: double.infinity,
			height: double.infinity,
		  	  child: new Opacity(
			    opacity: animationTitleFadeInOut.value,
				child: new Text('ACTIVITY',
					textAlign: TextAlign.center,
					style: new TextStyle(
						color: Colors.white,
						fontSize: 20.0,
						fontWeight: FontWeight.bold,
						letterSpacing: 2.0,
					)),
				),
			),
		)),
	);
  }
...
}
复制代码

结果

这是我获得的结果,它与原版非常接近,不是吗?

源代码

本文的完整源代码可以在 GitHub 上找到。

小结

就像你看到的这样,构建动画非常简单,甚至是复杂的动画。

我希望这篇很长的文章成功地揭开了 Flutter 动画的神秘面纱。

请继续关注下一篇文章,顺祝编码愉快。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值