最近在学习Flutter动画方面的知识,看到一个Hero组件,它能干啥呢?不了解的同学一起来看个小Demo。
我们先在页面1中新建一个黄色方块。然后打开第二个页面,可以看到黄色方块有一个平滑过渡效果,这就是Hero组件的作用。我们要做的只需用相同tag的Hero包裹需要做过渡效果的子组件即可。
// 页面1中一个黄色方块
Positioned(
left: 20,
bottom: 500,
child: Hero(
tag: 'av',
child: Container(
color: Colors.yellow,
width: 40,
height: 40,
)))
// 页面二中也有个黄色小块
Column(
children: [
Container(
color: Colors.black,
width: 100,
height: 100,
),
SizedBox(height: 100,),
Hero(tag: 'av', child: Container(
color: Colors.yellow,
width: 100,
height: 100,
)
)
],
)
可以看到使用非常简单,效果却异常强大。而我们常见的图片浏览器也有类似的动画,于是我想是不是可以用Hero组件来实现一个图片浏览器呢?说干就干。
组件结构
整个组件分成两部分:图片九宫格,大图预览。
图片九宫格
九宫格这个好实现,直接使用GridView就能实现布局。我们看下代码:
GridView.count(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
crossAxisCount: 3,
crossAxisSpacing: widget.crossAxisSpacing,
mainAxisSpacing: widget.mainAxisSpacing,
padding: widget.padding,
childAspectRatio: 1.0,
children: _heros);
为了在点击九宫格中任意图片后都能出现Hero过渡效果,我们需要给九宫格中的每一个图片都包裹一个Hero组件,就像这样。
for (int i = 0; i < widget.children.length; i++) {
Widget current = widget.children[i];
String tag = 'ImagesBox_$i';
current = Hero(tag: tag, child: current);
...
}
大图预览
大图预览,并且支持左右滑动,不用说,PageView肯定是首选。注意PageView的每一个child都需要用Hero组件包裹,并且tag要和九宫格中的小图一一对应。
List<Widget> heros = [];
for (int i = 0; i < widget.children.length; i++) {
Widget current = widget.children[i];
String tag = 'ImagesBox_$i';
current = Hero(
tag: tag,
child: current,
);
...
}
PageView(
controller: widget._controller,
onPageChanged: onPageChange,
children: heros,
)
完成以上操作,一个简单的九宫格和大图浏览器就完成了。
细节打磨
但是这样好了吗?很显然还不够,我们还没加入手势,我们还不能自定义大图浏览页面的展示。接下来就一起看下如何处理这些问题。
手势拖拽
在flutter中,要想接收手势事件,可以使用GestureDetector组件包裹,它可以监听滑动,缩放,点击,双击,长按等手势。这里我们监听滑动手势和单击手势,来实现边拖拽,边移动缩放的效果。当向下移动到一定距离后,推出大图浏览。
GestureDetector(
child: current,
onTap: () {
// 单击手势
_dismiss(context);
},
onPanCancel: () {
// 滑动取消
_dragEnd(context);
},
onPanStart: (DragStartDetails details) {
// 滑动开始
},
onPanUpdate: (DragUpdateDetails details) {
// 滑动更新
Offset preparOffset = _offset + details.delta;
// 不处理向上滑动事件
if (preparOffset.dy <= 0) return;
setState(() {
// 更新并记录滑动坐标
_offset += details.delta;
double scaleHeight = MediaQuery.of(context).size.height * 2;
double scale = (scaleHeight - _offset.dy) / scaleHeight;
// 更新缩放比例
_scale = scale < 0 ? 0 : scale;
// 更新背景透明度
_opacity = (scaleHeight - _offset.dy) / scaleHeight;
});
},
onPanEnd: (DragEndDetails details) {
// 滑动结束
_dragEnd(context);
},
);
在Flutter中,要对组件做移动或缩放,我们可以使用Transform组件。在上面的滑动手势中我们已经计算出移动距离和缩放比例,因此搭配Transform组件,我们可以实现边滑边缩放的效果。
Transform.scale(
scale: _scale,
child: Transform.translate(
offset: _offset / _scale, // 注意这里需要除上缩放比例,才是真正的移动距离
child: current,
),
这样是不是好了?你会发现,背景色没变,一直都显示为黑色。好,那么我们再加上背景色变化的代码。
Container(
color: Colors.black.withOpacity(_opacity),
child: PageView(
controller: widget._controller,
onPageChanged: onPageChange,
children: heros,
),
)
这时,随着我们的手势逐渐向下,也就是把图片向下拖,会发现背景色还是黑色,并没有显示出上一个页面的UI,也就是九宫格的哪个页面,似乎一点变化都没有。只有在完全退出大图浏览后,才会显示九宫格页面。这是什么原因,其实是因为当导航push出一个新页面的时候,相当于在窗口上覆盖了一层Overlay,并且把上一个页面给隐藏了,因此底部才是黑色的,其实是没有颜色。
那么这问题如何处理?其实解决方法很简单,这个是在路由那边设置的,我们在push大图浏览器页面的时候,需要传给Navigator一个PageRouter。我这边使用的是PageRouterBuilder组件,它有个opaque和barrierColor属性,前者是控制是否不透明,默认是true,而barrierColor控制新页面底部的颜色。如果我们仅设置barrierColor的颜色,肯定是不生效的,因为opaque告诉导航新页面的底部不透明,哪怕背景色设置为透明也是黑色,需要手动设置opaque为false,这样才可以透过半透明(或透明)的背景色看到上一个页面的UI。
Navigator.of(context).push(PageRouteBuilder(
opaque: false,
barrierColor: Colors.transparent,
barrierDismissible: true,
pageBuilder: ((context, animation, secondaryAnimation) {
return FadeTransition(
opacity: animation,
child: ImagePreview(
children: children,
initializeIndex: index,
onPageChange: (page) {
_currentPage = page;
setState(() {});
},
coverBuilder: widget.coverBuilder),
);
})));
当完成上述操作后,手势拖拽时,背景才能显示九宫格的页面。这样,手势拖拽功能也就完成了。
图片左右滑和九宫格隐藏图没对应上
我们在大图浏览的情况下,通常会左右滑动切换图片查看。我这里举个场景,我在九宫格页面点击下标为1的图,通过Hero动画平滑打开大图浏览后,此时的大图的下标也是1。当我左滑切换到图2时,那么九宫格那边对应的小图也应该是图2。此时,我们手指向下拖拽大图2,会发现一个bug。也就是九宫格隐藏的还是图1,图2仍然显示在页面上。这个体验就非常差了。我们的用户期望是我切换到图2时,九宫格的小图也应该是图2隐藏,其他图片展示,九宫格的图片和大图图片的交互展示需要以一对应。
遗憾的是,Hero组件并没有提供对应的接口来处理这个问题。也就是说需要我们自己来处理两者交互不一致的问题。
考虑到这个问题出现的时机是在每次大图切换的时候,因此我们可以监听PageView的页面切换,又因为九宫格图片的隐藏是Hero组件内部自己实现的操作,我们只要不用Hero包裹目标组件,那么我们的目标组件自然而然的就不会被动隐藏了。于是我们写出如下代码:
for (int i = 0; i < widget.children.length; i++) {
Widget current = widget.children[i];
if (_previewing) {
String tag = 'ImagesBox_$i';
current = Hero(tag: tag, child: current);
}
...
上诉代码表示在大图浏览的时候,九宫格不使用Hero组件。然而我们会发现,在大图浏览返回时,平移动画效果消失了。原因就是九宫格的小图没用到Hero组件,自然没有动画了。
那么我们就不充一个判断,让目标小图(正在预览的大图对应的小图)被Hero组件包裹,并且主动让它隐藏,隐藏我们使用Offstage组件。
for (int i = 0; i < widget.children.length; i++) {
Widget current = widget.children[i];
if ((_previewing && _currentPage == i) || !_previewing) {
String tag = 'ImagesBox_$i';
current = Hero(tag: tag, child: current);
}
current = Offstage(
offstage: _currentPage == i,
child: current,
)
...
至此,九宫格和大图浏览交互不一致的问题就解决了。
最终效果展示:
占个坑:后续版本将解决大图浏览缩放和手势拖拽问题。
项目地址:image_box
依赖引入:image_box: ^0.02