Hero组件妙用,强大的图片浏览器

912 阅读6分钟

最近在学习Flutter动画方面的知识,看到一个Hero组件,它能干啥呢?不了解的同学一起来看个小Demo。

我们先在页面1中新建一个黄色方块。然后打开第二个页面,可以看到黄色方块有一个平滑过渡效果,这就是Hero组件的作用。我们要做的只需用相同tagHero包裹需要做过渡效果的子组件即可。

// 页面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,
          )
          )
        ],
      )

flutter_image_box_hero.gif 可以看到使用非常简单,效果却异常强大。而我们常见的图片浏览器也有类似的动画,于是我想是不是可以用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组件,它有个opaquebarrierColor属性,前者是控制是否不透明,默认是true,而barrierColor控制新页面底部的颜色。如果我们仅设置barrierColor的颜色,肯定是不生效的,因为opaque告诉导航新页面的底部不透明,哪怕背景色设置为透明也是黑色,需要手动设置opaquefalse,这样才可以透过半透明(或透明)的背景色看到上一个页面的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,
          )
...

至此,九宫格和大图浏览交互不一致的问题就解决了。

最终效果展示:

flutter_image_box.gif

占个坑:后续版本将解决大图浏览缩放和手势拖拽问题。

项目地址:image_box

依赖引入:image_box: ^0.02