跳到正文
Willh的个人博客 记录 · 思考 · 分享
《Flutter实战·第二版》

9.4 Hero动画

自实现Hero动画

比如现在有一个头像组件,初始的时候是一个圆形的小图,我们想实现点击后查看大图的功能,为了有较好的体验,小图变成大图和大图变回小图时我们分别执行一个“飞行”过渡动画,效果如下所示:

Hero动画

要实现上面的动画效果,最简单的方式就是使用 Flutter 的 Hero 动画,但是为了让读者理解 Hero 动画原理,我先不使用Hero 动画,而是通过之前章节所学的知识来实现一下这个效果。

简单分析后有一个思路:首先我们先确定小图和大图的位置和大小,动画的话用一个Stack,然后通过 Positioned 来设置每一帧的组件位置和大小,实现如下:

1
2class CustomHeroAnimation extends StatefulWidget {
3  const CustomHeroAnimation({Key? key}) : super(key: key);
4
5  
6  _CustomHeroAnimationState createState() => _CustomHeroAnimationState();
7}
8
9class _CustomHeroAnimationState extends State<CustomHeroAnimation>
10    with SingleTickerProviderStateMixin {
11  late AnimationController _controller;
12
13  bool _animating = false;
14  AnimationStatus? _lastAnimationStatus;
15  late Animation _animation;
16
17  //两个组件在Stack中的rect
18  Rect? child1Rect;
19  Rect? child2Rect;
20
21  
22  void initState() {
23    _controller =
24        AnimationController(vsync: this, duration: Duration(milliseconds: 200));
25    //应用curve
26    _animation = CurvedAnimation(
27      parent: _controller,
28      curve: Curves.easeIn,
29    );
30
31    _controller.addListener(() {
32      if (_controller.isCompleted || _controller.isDismissed) {
33        if (_animating) {
34          setState(() {
35            _animating = false;
36          });
37        }
38      } else {
39        _lastAnimationStatus = _controller.status;
40      }
41    });
42    super.initState();
43  }
44
45  
46  void dispose() {
47    _controller.dispose();
48    super.dispose();
49  }
50
51  
52  Widget build(BuildContext context) {
53    //小头像
54    final Widget child1 = wChild1();
55    //大头像
56    final Widget child2 = wChild2();
57
58    //是否展示小头像;只有在动画执行时、初始状态或者刚从大图变为小图时才应该显示小头像
59    bool showChild1 =
60        !_animating && _lastAnimationStatus != AnimationStatus.forward;
61
62    // 执行动画时的目标组件;如果是从小图变为大图,则目标组件是大图;反之则是小图
63    Widget targetWidget;
64    if (showChild1 || _controller.status == AnimationStatus.reverse) {
65      targetWidget = child1;
66    } else {
67      targetWidget = child2;
68    }
69
70    return LayoutBuilder(builder: (context, constraints) {
71      return SizedBox(
72        //我们让Stack 填满屏幕剩余空间
73        width: constraints.maxWidth,
74        height: constraints.maxHeight,
75        child: Stack(
76          alignment: AlignmentDirectional.topCenter,
77          children: [
78            if (showChild1)
79              AfterLayout( 
80                //获取小图在Stack中占用的Rect信息
81                callback: (value) => child1Rect = _getRect(value),
82                child: child1,
83              ),
84            if (!showChild1)
85              AnimatedBuilder(
86                animation: _animation,
87                builder: (context, child) {
88                  //求出 rect 插值
89                  final rect = Rect.lerp(
90                    child1Rect,
91                    child2Rect,
92                    _animation.value,
93                  );
94                  // 通过 Positioned 设置组件大小和位置
95                  return Positioned.fromRect(rect: rect!, child: child!);
96                },
97                child: targetWidget,
98              ),
99            // 用于测量 child2 的大小,设置为全透明并且不能响应事件
100            IgnorePointer(
101              child: Center(
102                child: Opacity(
103                  opacity: 0,
104                  child: AfterLayout(
105                    //获取大图在Stack中占用的Rect信息
106                    callback: (value) => child2Rect = _getRect(value),
107                    child: child2,
108                  ),
109                ),
110              ),
111            ),
112          ],
113        ),
114      );
115    });
116  }
117
118  Widget wChild1() {
119    //点击后执行正向动画
120    return GestureDetector(
121      onTap: () {
122        setState(() {
123          _animating = true;
124          _controller.forward();
125        });
126      },
127      child: SizedBox(
128        width: 50,
129        child: ClipOval(child: Image.asset("imgs/avatar.png")),
130      ),
131    );
132  }
133
134  Widget wChild2() {
135    // 点击后执行反向动画
136    return GestureDetector(
137      onTap: () {
138        setState(() {
139          _animating = true;
140          _controller.reverse();
141        });
142      },
143      child: Image.asset("imgs/avatar.png", width: 400),
144    );
145  }
146
147  Rect _getRect(RenderAfterLayout renderAfterLayout) {
148    //我们需要获取的是AfterLayout子组件相对于Stack的Rect
149    return renderAfterLayout.localToGlobal(
150          Offset.zero,
151          //找到Stack对应的 RenderObject 对象
152          ancestor: context.findRenderObject(),
153        ) &
154        renderAfterLayout.size;
155  }
156}

运行后点击头像就可以实现上图中的动画效果,注意,我们是通过自定义的 AfterLayout 组件来获取组件的 Rect 信息的,该组件在第四章介绍过,我们将在后面 Flutter 布局原理相关章节中详细介绍该组件原理。

可以看到,整个飞行动画的实现还是比较复杂的,但由于这种飞行动画在交互上会经常被用到,因此 Flutter 在框架层抽象了上述实现飞行动画的逻辑,提供了一种通用且简单的实现 Hero 动画的方式。

Flutter Hero动画

Hero 指的是可以在路由(页面)之间“飞行”的 widget,简单来说 Hero 动画就是在路由切换时,有一个共享的widget 可以在新旧路由间切换。由于共享的 widget 在新旧路由页面上的位置、外观可能有所差异,所以在路由切换时会从旧路逐渐过渡到新路由中的指定位置,这样就会产生一个 Hero 动画。

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

为什么要将这种可飞行的共享组件称为hero(英雄),有一种说法是说美国文化中的超人是可以飞的,那是美国人心中的大英雄,还有漫威中的超级英雄基本上都是会飞的,所以Flutter开发人员就对这种“会飞的widget”就起了一个富有浪漫主义的名字hero。当然这种说法并非官方解释,但却很有意思。

示例

假设有两个路由A和B,他们的内容交互如下:

A:包含一个用户头像,圆形,点击后跳到B路由,可以查看大图。

B:显示用户头像原图,矩形。

在AB两个路由之间跳转的时候,用户头像会逐渐过渡到目标路由页的头像上,接下来我们先看看代码,然后再解析:

1class HeroAnimationRoute extends StatelessWidget {
2  
3  Widget build(BuildContext context) {
4    return Container(
5      alignment: Alignment.topCenter,
6      child: Column(
7        children: <Widget>[
8          InkWell(
9            child: Hero(
10              tag: "avatar", //唯一标记,前后两个路由页Hero的tag必须相同
11              child: ClipOval(
12                child: Image.asset(
13                  "imgs/avatar.png",
14                  width: 50.0,
15                ),
16              ),
17            ),
18            onTap: () {
19              //打开B路由
20              Navigator.push(context, PageRouteBuilder(
21                pageBuilder: (
22                  BuildContext context,
23                  animation,
24                  secondaryAnimation,
25                ) {
26                  return FadeTransition(
27                    opacity: animation,
28                    child: Scaffold(
29                      appBar: AppBar(
30                        title: Text("原图"),
31                      ),
32                      body: HeroAnimationRouteB(),
33                    ),
34                  );
35                },
36              ));
37            },
38          ),
39          Padding(
40            padding: const EdgeInsets.only(top: 8.0),
41            child: Text("点击头像"),
42          )
43        ],
44      ),
45    );
46  }
47}

路由B:

1class HeroAnimationRouteB extends StatelessWidget {
2  
3  Widget build(BuildContext context) {
4    return Center(
5      child: Hero(
6        tag: "avatar", //唯一标记,前后两个路由页Hero的tag必须相同
7        child: Image.asset("imgs/avatar.png"),
8      ),
9    );
10  }
11}

我们可以看到,实现 Hero 动画只需要用Hero组件将要共享的 widget 包装起来,并提供一个相同的 tag 即可,中间的过渡帧都是 Flutter 框架自动完成的。必须要注意, 前后路由页的共享Hero的 tag 必须是相同的,Flutter 框架内部正是通过 tag 来确定新旧路由页widget的对应关系的。

Hero 动画的原理比较简单,Flutter 框架知道新旧路由页中共享元素的位置和大小,所以根据这两个端点,在动画执行过程中求出过渡时的插值(中间态)即可,而感到幸运的是,这些事情不需要我们自己动手,Flutter 已经帮我们做了,实际上,Flutter Hero 动画的实现原理和我们在本章开始自实现的原理是差不多的,读者有兴趣可以去看 Hero 动画相关的源码。