Flutter 手势处理 & Hero 动画

4,723 阅读5分钟

App Store可以说是苹果业内设计的标杆了。

我们就来简单的实现一下 App Store的首页里其中的一个小功能。

先看图:

可以看到,这里有两点需要关注一下:

  1. 在点击这个卡片的时候会缩放,松开或者滑动的时候会回弹回去。
  2. 跳新页面的时候有元素共享。

实现结果:

手势处理

在Flutter中的手势事件分为两层。

第一层有原始指针事件,它描述了屏幕上指针(例如,触摸,鼠标和触控笔)的位置和移动。

第二层有手势,描述由一个或多个指针移动组成的语义动作。

简单的手势处理,我们使用 Flutter 封装好的GestureDetector来处理就完全够用。

我们这里的图片缩放效果就用GestureDetector来处理。

先来看一下GestureDetector 给我们提供了什么样的方法:

  • onTapDown:按下
  • onTap:点击动作
  • onTapUp:抬起
  • onTapCancel:触发了 onTapDown,但并没有完成一个 onTap 动作
  • onDoubleTap:双击
  • onLongPress:长按
  • onScaleStart, onScaleUpdate, onScaleEnd:缩放
  • onVerticalDragDown, onVerticalDragStart, onVerticalDragUpdate, onVerticalDragEnd, onVerticalDragCancel, onVerticalDragUpdate:在竖直方向上移动
  • onHorizontalDragDown, onHorizontalDragStart, onHorizontalDragUpdate, onHorizontalDragEnd, onHorizontalDragCancel, onHorizontalDragUpdate:在水平方向上移动
  • onPanDown, onPanStart, onPanUpdate, onPanEnd, onPanCancel:拖曳(触碰到屏幕、在屏幕上移动)

那我们知道了这些方法,我们就可以来分析一下,哪些适合我们做这个效果:

我们可以看到,当我们的手指触碰到卡片的时候就开始缩放,当开始移动或者抬起的时候回弹。

那我们根据上面 GestureDetector 的方法,可以看到 onPanDown、onPanCancel 似乎非常适合我们的需求。

那我们就可以来试一下:

监听手势的方法有了,那我们下面就来写动画。

如何让Card 进行缩放呢,Flutter 有一个 Widget,ScaleTransition

照例点开源码看注释:

/// Animates the scale of a transformed widget.

对scale进行动画缩放的组件。

那这就结了,直接在 onPanDown、onPanCancel 方法中写上动画就完了:

Widget createItemView(int index) {
  var game = _games[index]; // 获取数据
  // 定义动画控制器
  var _animationController = AnimationController(
    vsync: this,
    duration: Duration(milliseconds: 200),
  );  
  // 定义动画
  var _animation =
    Tween<double>(begin: 1, end: 0.98).animate(_animationController);
  return GestureDetector(
    onPanDown: (details) {
      print('onPanDown');
      _animationController.forward(); // 点击的时候播放动画
    },
    onPanCancel: () {
      print('onPanCancel');
      _animationController.reverse(); // cancel的时候回弹动画
    },

    child: Container(
      height: 450,
      margin: EdgeInsets.symmetric(horizontal: 30, vertical: 10),
      child: ScaleTransition(
        scale: _animation, // 定义动画
        child: Stack( // 圆角图片为背景,上面为text
          children: <Widget>[
            Positioned.fill(
              child: ClipRRect(
                borderRadius: BorderRadius.all(Radius.circular(15)),
                child: Image.asset(
                  game.imageUrl,
                  fit: BoxFit.cover,
                ),
              ),
            ),
            
            Padding(
              padding: const EdgeInsets.all(18.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    game.headText,
                    style: TextStyle(
                      fontSize: 16,
                      color: Colors.grey,
                    ),
                  ),
                  
                  Expanded(
                    child: Text(
                      game.title,
                      style: TextStyle(
                        fontSize: 30,
                        color: Colors.white,
                      ),
                    ),
                  ),
                  
                  Text(
                    game.footerText,
                    style: TextStyle(
                      fontSize: 16,
                      color: Colors.grey,
                    ),
                  ),
                ],
              ),
            )
          ],
        ),
      )),
  );
}

这样就可以完成我们刚上图的动画效果了。

这里有一个需要注意的地方是:

ListView 中必须每一个 item 有一个 动画。

不然所有的item公用一个动画的话,点击其中一个,所有的item 都会执行动画效果。

Hero动画

点击缩放效果我们处理完了,下面就应该来跳转了。

在Android中,5.0以后版本就有了元素共享,可以实现这种效果。

在Flutter当中我们可以使用 Hero 来实现这个效果。

打开官网看介绍:

A widget that marks its child as being a candidate for hero animations.

When a PageRoute is pushed or popped with the Navigator, the entire screen's content is replaced. An old route disappears and a new route appears. If there's a common visual feature on both routes then it can be helpful for orienting the user for the feature to physically move from one page to the other during the routes' transition. Such an animation is called a hero animation. The hero widgets "fly" in the Navigator's overlay during the transition and while they're in-flight they're, by default, not shown in their original locations in the old and new routes.

To label a widget as such a feature, wrap it in a Hero widget. When navigation happens, the Hero widgets on each route are identified by the HeroController. For each pair of Hero widgets that have the same tag, a hero animation is triggered.

If a Hero is already in flight when navigation occurs, its flight animation will be redirected to its new destination. The widget shown in-flight during the transition is, by default, the destination route's Hero's child.

For a Hero animation to trigger, the Hero has to exist on the very first frame of the new page's animation.

Routes must not contain more than one Hero for each tag.

简单来说:

Hero动画就是在路由切换时,有一个共享的Widget可以在新旧路由间切换,由于共享的Widget在新旧路由页面上的位置、外观可能有所差异,所以在路由切换时会逐渐过渡,这样就会产生一个Hero动画。

要触发Hero动画,Hero必须存在于新页面动画的第一帧。

并且一个路由里只能有一个Hero 的 tag。

说了这么多,怎么用?

// Page 1
Hero(
  tag: "avatar", //唯一标记,前后两个路由页Hero的tag必须相同
  child: ClipOval(
    child: Image.asset("images/avatar.png",
                       width: 50.0,),
  ),
),

// Page 2
Center(
  child: Hero(
    tag: "avatar", //唯一标记,前后两个路由页Hero的tag必须相同
    child: Image.asset("images/avatar.png"),
  ),
)

可以看到只需要在你想要共享的widget 前加上 Hero,写上 tag即可。

赶紧试一下:

child: Container(
  height: 450,
  margin: EdgeInsets.symmetric(horizontal: 30, vertical: 10),
  child: ScaleTransition(
    scale: _animation,
    child: Hero(
      tag: 'hero',
      child: Stack(
        children: <Widget>[
          Positioned.fill(
            child: ClipRRect(
              borderRadius: BorderRadius.all(Radius.circular(15)),
              child: Image.asset(
                game.imageUrl,
                fit: BoxFit.cover,
              ),
            ),
          ),
   

运行看下:

直接黑屏了是什么鬼?

看到了报错信息:

There are multiple heroes that share the same tag within a subtree.

多个hero widget 使用了同一个标签

那我们改造一下:

child: Container(
  height: 450,
  margin: EdgeInsets.symmetric(horizontal: 30, vertical: 10),
  child: ScaleTransition(
    scale: _animation,
    child: Hero(
      tag: 'hero${game.title}',
      child: Stack(
        children: <Widget>[
          Positioned.fill(
            child: ClipRRect(
              borderRadius: BorderRadius.all(Radius.circular(15)),
              child: Image.asset(
                game.imageUrl,
                fit: BoxFit.cover,
              ),
            ),
          ),
 // ........代码省略

我们使用 ListView 里的数据来填充tag,这样就不会重复了,运行一下:

这跳转的时候文字下面有两个下划线是什么鬼?

查了一下,是因为跳转的时候,Flutter 把源 Hero 放在了叠加层,而叠加层里是没有 Theme的。

简单理解就是叠加层里没有Scaffold,所以就会出现下划线。

解决办法如下:

在textStyle中加入 decoration: TextDecoration.none,

现在就完全没有问题了:

总结

在初学Flutter 时,我们确实会出现这样那样的问题。

不要心烦,点开源码,或者去 Flutter 官网找到该类,看一下注释和demo,问题分分钟解决。

代码已经传至GitHub:github.com/wanglu1209/…