Flutter Animations:互动式翻页小工具教程

473 阅读10分钟

Flutter提供了一些非常强大而简单的动画API,我们可以用它们来取悦我们的用户。

在本教程中,我们将通过使用AnimationControllerAnimationBuilder 、手势检测器和自定义3D矩阵变换构建一个交互式翻页小部件来详细探索这些API。其结果将是一个自定义的PageFlipBuilder widget,它将所有的复杂性隐藏在一个易于使用的API后面。

最后我还会分享一个新的Flutter包,你可以用它来翻阅任何大小的页面、卡片和小工具。🚀

注意:本教程假定你已经熟悉了Flutter动画的基本知识。如果你是这个话题的新手,请查看Flutter网站上的动画介绍页面。

Flutter翻页过渡

实时Flutter网页演示

在我们开始之前,先玩一下这个实时演示。

您可以向左/右拖动来翻转页面。很酷吧?😎

好了,让我们来看看如何构建这个!

初始项目

我们将重点关注如何实现翻页过渡,而不是页面本身。

注意:我们将要看到的所有代码都使用了null-safety,这在最新的Flutter稳定版中已经可用。

要想继续学习,请抓紧启动项目,该项目包含两个widget类,名为LightHomePageDarkHomePage

而我们将从一个简单的应用程序开始,在一个黑色的Container 内使用LightHomePage() ,作为我们的MaterialApp 的主页。

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Container(
        // add a black background that will prevent flickering on Android when the page flips
        color: Colors.black,
        LightHomePage(),
      ),
    );
  }
}

class LightHomePage extends StatelessWidget {
  const LightHomePage({Key? key, this.onFlip}) : super(key: key);
  final VoidCallback? onFlip;
  ...
}
class DarkHomePage extends StatelessWidget {
  const DarkHomePage({Key? key, this.onFlip}) : super(key: key);
  final VoidCallback? onFlip;
  ...
}

正如我们所看到的,这两个页面都包含一个onFlip 回调,我们将用它来程序化地触发一个翻页。

如果运行这个应用程序,我们应该得到以下结果。

头部页面

我们的目标是创建一个PageFlipBuilder widget,可以在两个页面之间以编程方式(通过回调)和互动方式(通过用户手势)进行翻转。

一个好的开始是决定这个小部件应该有什么API。

PageFlipBuilder的API设计

为了使我们的PageFlipBuilder前面后面的小部件应该作为参数给出。

所以我们可能会想创建一个像这样的API。

PageFlipBuilder(
  front: LightHomePage(),
  back: DarkHomePage(),
)

但是由于在任何时候都只有一个页面在屏幕上可见,使用两个WidgetBuilder 参数并让PageFlipBuilder 调用正确的参数会更有表现力。

PageFlipBuilder(
  frontBuilder: (_) => LightHomePage(),
  backBuilder: (_) => DarkHomePage(),
)

但是,我们的页面的onFlip() 回调呢?

LightHomePage(
  onFlip: /* what goes here? */
)

我们应该使用这个回调来调用PageFlipBuilder 内的某种flip() 方法。

正如我们将看到的,PageFlipBuilder 将是一个StatefulWidget ,所以我们可以使用一个全局键,并像这样在MyApp 中挂钩。

class MyApp extends StatelessWidget {
  // 1. declare a GlobalKey that we will use to reference the PageFlipBuilder state
  final pageFlipKey = GlobalKey<PageFlipBuilderState>();
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Container(
        // add a black background that will prevent flickering on Android when the page flips
        color: Colors.black,
        child: PageFlipBuilder(
          // 2. pass the key
          key: pageFlipKey,
          frontBuilder: (_) => LightHomePage(
            // 3a. call an internal `flip()` method on the state class
            onFlip: () => pageFlipKey.currentState?.flip(),
          ),
          backBuilder: (_) => DarkHomePage(
            // 3b. call an internal `flip()` method on the state class
            onFlip: () => pageFlipKey.currentState?.flip(),
          ),
        ),
      ),
    );
  }
}

当然,在这个阶段,这些代码都不能编译,因为我们甚至还没有创建我们的PageFlipBuilder

所以让我们来处理这个问题。

最重要的提示:事先考虑好你希望你的小部件有哪些API。这将节省你以后的时间,并使小组件更容易使用。

PageFlipBuilder小组件

让我们添加一个具有以下内容的page_flip_builder.dart 文件。

import 'package:flutter/material.dart';

class PageFlipBuilder extends StatefulWidget {
  const PageFlipBuilder({
    Key? key,
    required this.frontBuilder,
    required this.backBuilder,
  }) : super(key: key);
  final WidgetBuilder frontBuilder;
  final WidgetBuilder backBuilder;

  @override
  PageFlipBuilderState createState() => PageFlipBuilderState();
}

// Note: there's no underscore here as we want this State subclass to be public.
// This is so that we can call the flip() method from the outside.
class PageFlipBuilderState extends State<PageFlipBuilder> {

  void flip() {
    // TODO: Implement
  }

  @override
  Widget build(BuildContext context) {
    // TODO: Replace with page flip code
    return widget.frontBuilder(context);
  }
}

如果我们在我们的main.dart 文件中导入page_flip_builder.dart ,我们看到代码现在可以编译了,因为PageFlipBuilder 定义了我们所使用的所有参数。

而如果我们运行这个应用程序,我们看到我们仍然显示LightHomePage ,因为build() 方法返回frontBuilder(context)

头部页面

是时候解决如何为事物做动画了。

用AnimationController和AnimatedBuilder做动画

为了使动画工作,我们需要两个成分。

  • 一个AnimationController控制翻转过渡
  • 一个AnimatedBuilder widget,根据动画值,用一个自定义的三维变换来旋转前后页面。

正如我们将看到的,我们可以通过将AnimationController 作为输入传给AnimatedBuilder 来获得我们想要的效果。

AnimationController和AnimatedBuilder之间的互动

所以我们接下来的两个目标是。

  1. 设置一个AnimationController 来控制翻转过渡
  2. 使用AnimatedBuilder 编写一些自定义代码来获得旋转效果

所以让我们看看如何做到这一点,从AnimationController 代码开始。

设置AnimationController

现在,我们要以编程方式翻转页面。在下一个教程中,我们将使用GestureDetector ,使事情变得互动

在这个阶段,我们需要做两件事。

  • 添加一个_showFrontSide 状态变量,它将告诉我们应该显示哪一页
  • 设置我们的AnimationController

这就是我们需要的代码。

class PageFlipBuilderState extends State<PageFlipBuilder>
    // 1. Add SingleTickerProviderStateMixin
    with SingleTickerProviderStateMixin {
  // 2. Add state telling us which page we should show
  bool _showFrontSide = true;
  // 3. Our AnimationController
  late final AnimationController _controller;

  @override
  void initState() {
    // 4. Create the AnimationController
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 500),
    );
    // 5. Add a status listener
    _controller.addStatusListener(_updateStatus);
    super.initState();
  }

  @override
  void dispose() {
    // 6. Clean things up when the widget is removed
    _controller.removeStatusListener(_updateStatus);
    _controller.dispose();
    super.dispose();
  }

  void _updateStatus(AnimationStatus status) {
    // 7. Toggle the state then a forward or reverse animation is complete
    if (status == AnimationStatus.completed ||
        status == AnimationStatus.dismissed) {
      setState(() => _showFrontSide = !_showFrontSide);
    }
  }

  void flip() {
    // 8. Forward or reverse the controller depending on the state
    if (_showFrontSide) {
      _controller.forward();
    } else {
      _controller.reverse();
    }
  }

  @override
  Widget build(BuildContext context) {
    // TODO: Replace with page flip code
    return frontBuilder(context);
  }
}

在这个阶段,我们的应用程序看起来仍然是一样的。但是我们可以添加一个监听器,在initState() 里面打印我们的AnimationController 的值。

// TODO: Temporary code, remove me
_controller.addListener(() {
  print('value: ${_controller.value}');
});

如果我们热重启并按下LightHomePage 上的翻转按钮,我们会得到以下控制台输出。

flutter: value: 0.0
flutter: value: 0.066666
flutter: value: 0.099998
flutter: value: 0.133334
...
many more lines
...
flutter: value: 0.933332
flutter: value: 0.966666
flutter: value: 0.999998
flutter: value: 1.0

由于我们在动画结束时在_updateStatus() 内切换了_showFrontSide ,我们可以再次按下翻转按钮,看到以相反的顺序打印的值(从1.0到0.0)。

这证实了AnimationController 正在做正确的事情,我们可以用它来驱动我们的翻页动画。

所以我们可以删除临时监听器代码并继续前进。

添加翻页旋转代码

早些时候我们说过,我们将使用一个AnimatedBuilder 来获得我们想要的效果。

虽然我们可以在我们的build() 方法中直接返回一个AnimatedBuilder ,但我们应该遵循单一责任原则,创建一个单独的widget来代替。

所以让我们这样做吧。

class AnimatedPageFlipBuilder extends StatelessWidget {
  const AnimatedPageFlipBuilder({
    Key? key,
    required this.animation,
    required this.showFrontSide,
    required this.frontBuilder,
    required this.backBuilder,
  }) : super(key: key);
  final Animation<double> animation;
  final bool showFrontSide; // we'll see how to use this later
  final WidgetBuilder frontBuilder;
  final WidgetBuilder backBuilder;

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: animation,
      builder: (context, _) {
        // TODO: implement me
      },
    );
  }
}

这个新的widget类与我们的frontBuilderbackBuilder 参数相同,PageFlipBuilder

但是它也有一个Animation<double> animation 变量,在build() 方法中作为输入传给AnimatedBuilder

有了这个,让我们更新一下PageFlipBuilderStatebuild() 方法。

@override
Widget build(BuildContext context) {
  return AnimatedPageFlipBuilder(
    animation: _controller,
    showFrontSide: _showFrontSide,
    frontBuilder: widget.frontBuilder,
    backBuilder: widget.backBuilder,
  );
}

我们可以看到,_controller 被传递给animation 参数,其类型为Animation<double> 。这是允许的,因为AnimationController 在Flutter SDK中是这样定义的。

class AnimationController extends Animation<double> {}

使用Transform和Matrix4的旋转代码

我们将在一分钟内更新AnimatedBuilder 的代码。

但首先,让我们来计算一下数学问题。

在我们目前的设置中,我们的动画总是有一个介于0.0和1.0之间的值,这对应于一个介于0.0和pi (180度)的旋转值。

为了得到我们想要的页面效果,我们必须这样做。

  • 在动画值介于0.0和0.5之间时显示前页
  • 在动画值介于0.5和1.0之间时显示后页

为了达到这个目的,我们需要在AnimatedBuilder

AnimatedBuilder(
  animation: animation,
  builder: (context, _) {
    // this boolean tells us if we're on the first or second half of the animation
    final isAnimationFirstHalf = animation.value.abs() < 0.5;
    // decide which page we need to show
    final child = isAnimationFirstHalf ? frontBuilder(context) : backBuilder(context);
    // map values between [0, 1] to values between [0, pi]
    final rotationValue = animation.value * pi;
    // calculate the correct rotation angle depening on which page we need to show
    final rotationAngle = animation.value > 0.5 ? pi - rotationValue : rotationValue;
    return Transform(
      transform: Matrix4.rotationY(rotationAngle),
      child: child,
      alignment: Alignment.center,
    );
  },
)

这里有很多东西需要解读。

  • 我们使用isAnimationFirstHalf 来决定调用哪个构建器**(正面背面**),并获得将被呈现的child widget
  • 我们计算旋转角度并将Matrix4.rotationY(rotationAngle) 传递给一个Transform widget,以应用围绕Y轴的3D旋转。

如果我们现在运行应用程序,我们可以前后翻转页面。

无深度的翻页效果

不幸的是,这个结果缺乏 "深度",看起来不像是一个三维旋转。

为了解决这个问题,我们需要计算并应用一个 "倾斜 "值。

这个值在动画的开始和结束时应该是0.0。而前面后面的小部件应该有一个相反的倾斜值。

让我们把一切放在一起。

AnimatedBuilder(
  animation: animation,
  builder: (context, _) {
    // this boolean tells us if we're on the first or second half of the animation
    final isAnimationFirstHalf = animation.value.abs() < 0.5;
    // decide which page we need to show
    final child = isAnimationFirstHalf ? frontBuilder(context) : backBuilder(context);
    // map values between [0, 1] to values between [0, pi]
    final rotationValue = animation.value * pi;
    // calculate the correct rotation angle depening on which page we need to show
    final rotationAngle = animation.value > 0.5 ? pi - rotationValue : rotationValue;
    // calculate tilt
    var tilt = (animation.value - 0.5).abs() - 0.5;
    // make this a small value (positive or negative as needed)
    tilt *= isAnimationFirstHalf ? -0.003 : 0.003;
    return Transform(
      transform: Matrix4.rotationY(rotationAngle)
        // apply tilt value
        ..setEntry(3, 0, tilt),
      child: child,
      alignment: Alignment.center,
    );
  },
)

通过最新的变化,我们计算出倾斜度,并通过..setEntry(3, 0, tilt) ,改变Matrix4 对象的一个值来应用它。

用于我们的变换的结果Matrix4 ,看起来像这样。

带有Y旋转和倾斜的变换矩阵

你可以阅读这篇关于Matrix4和透视变换的文章,了解更多关于我们刚刚使用的数学知识。

如果我们现在运行这个应用程序,我们可以看到翻页的效果更有说服力了。

带有倾斜效果的翻页

很好的东西,我们现在可以在按下翻页按钮的时候向前和向翻页了。

是时候拍拍自己的肩膀了。

总结

本教程的第一部分到此结束。

一路走来,我们已经学会了如何。

  • 将一个AnimationController 作为输入传给一个AnimatedBuilder widget
  • 使用AnimatedBuilderTransform 小组件来实现三维旋转
  • 将本地小部件的状态与AnimationController 的值同步。

虽然PageFlipBuilder内部相当复杂,但我们已经设计了一个API,使它从外部易于使用

下一步是什么?

第二部分中,我们将通过添加一个GestureDetector ,使我们的PageFlipBuilder 具有交互性

如果你等不及了,想在你的应用程序中使用它,你可以到pub.dev去,在那里我已经把最终产品作为Flutter包发布了。

当然,您也可以在GitHub上查看源代码。

除了我们在这些教程中所涉及的内容外,page_flip_builder 包还提供了一些额外的功能。

  • 围绕水平或垂直轴进行翻转
  • 用它来翻转任何尺寸的小部件
  • 可定制的倾斜缩放参数

在Y轴和X轴上进行卡片翻转过渡

如果你最终使用它,请在Twitter上告诉我你的想法。

鸣谢

Flutter社区的这些文章帮助我了解了一些我的PageFlipBuilder

翻转动画文章使用了一个AnimatedSwicher widget,如果你不需要处理拖动手势,这使生活更容易。

但我想通过使动画具有互动性而使事情更进一步,并创建一个具有漂亮的API的包,你可以在你的应用程序中使用。

还有一件事

AnimationController,AnimatedBuilder, 和GestureDetector 一起使用时非常强大。

但在Flutter中,你可以用动画做的事情还有很多。

编码愉快!