Flutter交互实战-即刻App探索页下拉&拖拽效果

Flutter最近比较热门,但是Flutter成体系的文章并不多,前期避免不了踩坑;我这篇文章主要介绍如何使用Flutter实现一个比较复杂的手势交互,顺便分享一下我在使用Flutter过程中遇到的一些小坑,减少大家入坑;

先睹为快

本项目支持ios&android运行,效果如下

对了,顺便分享一下生成gif的小窍门,建议用手机自带录屏功能导出mp4文件到电脑,然后电脑端用ffmpeg命令行处理,控制gif的质量和文件大小,我的建议是分辨率控制在270p,帧率在10左右;

交互分析

看文章的小伙伴最好能手持即刻App,亲自体验一下探索页的交互,是黄色Logo黄色主题色的即刻;有人简称‘黄即’;

即刻App原版功能有卡片旋转,卡片撤回和卡片自动移除,时间关系我暂时没有去实现,但是核心功能一点都不会砍;

以我Android开发习惯来看,交互分为可拆分内外两层,外层我们需要一个整体下拉的控件,内层我们需要实现一个上、下、左、右四方向拖拽移动的控件,我们称为卡片控件;同时这两层还需要处理子Widget的布局,再看细节:

下拉控件:

  • 子控件从上到下竖直摆放,顶部菜单默认隐藏在屏幕外
  • 下拉手势所有子控件下移,菜单视觉差效果
  • 支持点击自动展开、收起效果

卡片控件

  • 卡片层叠布局,错落有致
  • 最上层卡片支持手势拖拽
  • 其他卡片相应拖拽小幅位移
  • 松手移除卡片

码上入手

热身

套用App开发伎俩,实现上面的交互无非就是控件布局和手势识别。当然在Flutter中也跑不掉这两点,在Flutter中常用的基本布局有ColumnRowStack等,手势识别有ListenerGestureDetectorRawGestureDetector等,本文的讲解不限于上面这几个Widget,因为Flutter提供的Widget太多了,真是用到啥查啥;

所以下面我们从布局和手势这两个大的技术点,来一一击破功能点;

布局摆放

这里所谓的布局,包括Widget的尺寸大小和位置的控制,一般都是父Widget掌管子Widget的命运,Flutter就是一层一层Widget嵌套,不要担心,下面从外到内具体案例讲解;

下拉控件

首先我们要实现最外层布局,效果是:子Widget竖直摆放,且最上面的Widget默认需要摆放在屏幕外;

如上图所示,红色区域是屏幕范围,header是头部隐藏的菜单布局,content是卡片布局的主体;

先说入的坑

竖直布局我最先想到的是Column,我想要的效果是content高度和父Widget的高度一致,我首先想到是让Expanded包裹content,结果是content的高度永远等于Column高度减header高度,造成现象就是content高度不填充,或者是挤压现象,如果继续使用Colunm可能就得放弃Expanded,手动给content赋值高度,没准是个办法,但我不愿意手动赋值content,我不想为了实现而实现,果断放弃用Column

另一个问题是如何隐藏header,我想到两种方案:

  1. 采用外层Transform包裹整个布局,内层Transform包裹header,然后赋值内层dy = -headerHeight,随着手势下拉动态,并不改变headerTransform,而是改变最外层Transformdy
  2. 动态改变header高度,初始高度为0,随着手势下拉动态计算;

但是上面这两种都有坑,第一种方式会影响控件的点击事件,onTap方法不会被回调;第二种由于高度在不断改变,会影象header内部子Widget的布局,很难做视觉差的控制;

最终方案

最后采用Stack来布局,通过Stack配合Positioned,实现header布局在屏幕外,而且可以做到让content布局填充父Widget;

PullDragWidget

Widget build(BuildContext context) {
  return RawGestureDetector(
      behavior: HitTestBehavior.translucent,
      gestures: _contentGestures,
      child: Stack(
        children: <Widget>[
          Positioned(//content布局
              top: _offsetY,
              bottom: -_offsetY,
              left: 0,
              right: 0,
              child: IgnorePointer(
                ignoring: _opened,
                child: widget.child,
              )),
          Positioned(////header布局
              top: -widget.dragHeight + _offsetY,
              bottom: null,
              left: 0,
              right: 0,
              height: widget.dragHeight,
              child: _headerWidget()),
        ],
      ));
}
复制代码

首先解释一下Positioned的基本用法,topbottomheight控制高度和位置,而且两两配合使用,topbottom可以理解成marginTop和marginBottom,height顾名思义是直接Widget的高度,如果top配置bottom,意味着高度等于parentHeight-top-bottom,如果top/bottom配合height使用,高度一般是固定的,当然topbottom是接受负数的;

再分析代码,首先_offsetY是下拉距离,是一个改变的量初始值为0,content需要设置top = _offsetYbottom = -_offsetY,改变的是上下位置,高度不会改变;同理,header是采用topheight控制,高度固定,只需要动态改变top即可;

用Flutter写布局真的很简单,我极力推崇使用Stack布局,因为它比较灵活,没有太多的限制,用好Stack主要还得用好Positioned,学好它没错;

卡片控件

卡片实现的效果就是依次层叠,错落有致,这个很容易想到Stack来实现,当然有了上面踩坑,用Stack算是很轻松了;

重叠的效果使用Stack很简单,错落有致的效果实在起来可能性就比较多了,比如可以使用Positioned,也可以包裹Container改变margin或者padding,但是考虑到角度的旋转,我选择使用Transform,因为Transform不仅可以玩转位移,还有角度和缩放等,其实就是一个矩阵变换;but但是我对Transform持有疑问:执行完变换之后,有某些情况是不能正常的相应触摸事件,这可能是Transform的bug;

CardStackWidget

Widget build(BuildContext context) {
  if (widget.cardList == null || widget.cardList.length == 0) {
    return Container();
  }
  List<Widget> children = new List();
  int length = widget.cardList.length;
  int count = (length > widget.cardCount) ? widget.cardCount : length;
  for (int i = 0; i < count; i++) {
    double dx = i == 0 ? _totalDx : -_ratio * widget.offset;
    double dy = i == 0 ? _totalDy : _ratio * widget.offset;
    Widget cardWidget = _CardWidget(
      cardEntity: widget.cardList[i],
      position: i,
      dx: dx,
      dy: dy,
      offset: widget.offset,
    );
    if (i == 0) {
      cardWidget = RawGestureDetector(
        gestures: _cardGestures,
        behavior: HitTestBehavior.deferToChild,
        child: cardWidget,
      );
    }
    children.add(Container(
      child: cardWidget,
      alignment: Alignment.topCenter,
      padding: widget.cardPadding,
    ));
  }
  return Stack(
    children: children.reversed.toList(),
  );
}
复制代码

_CardWidget

Widget build(BuildContext context) {
  return AspectRatio(
    aspectRatio: 0.75,
    child: Transform(
        transform: Matrix4.translationValues(
            dx + (offset * position.toDouble()),
            dy + (-offset * position.toDouble()),
            0),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(10),
          child: Stack(
            fit: StackFit.expand,
            children: <Widget>[
              Image.network(
                cardEntity.picUrl,
                fit: BoxFit.cover,
              ),
              Container(color: const Color(0x5a000000)),
              Container(
                margin: EdgeInsets.all(20),
                alignment: Alignment.center,
                child: Text(
                  cardEntity.text,
                  textAlign: TextAlign.center,
                  style: TextStyle(
                      letterSpacing: 2,
                      fontSize: 22,
                      color: Colors.white,
                      fontWeight: FontWeight.bold),
                  maxLines: 4,
                ),
              )
            ],
          ),
        )),
  );
}
复制代码

简单总结一下卡片布局代码,CardStackWidget是管理卡片Stack的父控件,负责对每个卡片进行布局,_CardWidget是对单独卡片内部进行布局,总体来说没有什么难点,细节控制逻辑是在对上层_CardWidget和底层_CardWidget偏移量的计算;

布局的内容就讲这么多,整体来说还是比较简单,所谓的有些坑也不一定算是坑,只是不适应某些应用场景罢了;

手势识别

Flutter手势识别最常用的是ListenerGestureDetector这两个Widget,其中Listener主要针对原始触摸点进行处理,GestureDetector已经对原始触摸点加工成了不同的手势;这两个类的方法介绍如下;

Listener

Listener({
  Key key,
  this.onPointerDown, //手指按下回调
  this.onPointerMove, //手指移动回调
  this.onPointerUp,//手指抬起回调
  this.onPointerCancel,//触摸事件取消回调
  this.behavior = HitTestBehavior.deferToChild, //在命中测试期间如何表现
  Widget child
})
复制代码

GestureDetector手势回调:

Property/Callback Description
onTapDown 用户每次和屏幕交互时都会被调用
onTapUp 用户停止触摸屏幕时触发
onTap 短暂触摸屏幕时触发
onTapCancel 用户触摸了屏幕,但是没有完成Tap的动作时触发
onDoubleTap 用户在短时间内触摸了屏幕两次
onLongPress 用户触摸屏幕时间超过500ms时触发
onVerticalDragDown 当一个触摸点开始跟屏幕交互,同时在垂直方向上移动时触发
onVerticalDragStart 当触摸点开始在垂直方向上移动时触发
onVerticalDragUpdate 屏幕上的触摸点位置每次改变时,都会触发这个回调
onVerticalDragEnd 当用户停止移动,这个拖拽操作就被认为是完成了,就会触发这个回调
onVerticalDragCancel 用户突然停止拖拽时触发
onHorizontalDragDown 当一个触摸点开始跟屏幕交互,同时在水平方向上移动时触发
onHorizontalDragStart 当触摸点开始在水平方向上移动时触发
onHorizontalDragUpdate 屏幕上的触摸点位置每次改变时,都会触发这个回调
onHorizontalDragEnd 水平拖拽结束时触发
onHorizontalDragCancel onHorizontalDragDown没有成功完成时触发
onPanDown 当触摸点开始跟屏幕交互时触发
onPanStart 当触摸点开始移动时触发
onPanUpdate 屏幕上的触摸点位置每次改变时,都会触发这个回调
onPanEnd pan操作完成时触发
onScaleStart 触摸点开始跟屏幕交互时触发,同时会建立一个焦点为1.0
onScaleUpdate 跟屏幕交互时触发,同时会标示一个新的焦点
onScaleEnd 触摸点不再跟屏幕有任何交互,同时也表示这个scale手势完成

ListenerGestureDetector如何抉择,首先GestureDetector是基于Listener封装,它解决了大部分手势冲突,我们使用GestureDetector就够用了,但是GestureDetector不是万能的,必要时候需要自定义RawGestureDetector

另外一个很重要的概念,Flutter手势事件是一个从内Widget向外Widget的冒泡机制,假设内外Widget同时监听竖直方向的拖拽事件onVerticalDragUpdate,往往都是内层控件获得事件,外层事件被动取消;这样的概念和Android父布局拦截机制就完全不同了;

虽然Flutter没有外层拦截机制,但是似乎还有一线希望,那就是IgnorePointerAbsorbPointerWidget,这俩哥们可以忽略或者阻止子Widget树不响应Event;

手势分析

基本原理介绍完了,接下来分析案例交互,上面说了我把整体布局拆分成了下拉控件和卡片控件,分析即刻App的拖拽的行为:当下拉控件没有展开下拉菜单时,卡片控件是可以相应上、左、右三个方向的手势,下拉控件只相应一个向下方向的手势;当下拉菜单展开时,卡片不能相应任何手势,下拉控件可以相应竖直方向的所有事件;

上图更加形象解释两种状态下的手势响应,下拉控件是父Widget,卡片控件是子Widget,由于子Widget能优先响手势,所以在初始阶段,我们不能让子Widget响应向下的手势;

由于GestureDetector只封装水平和竖直方向的手势,且两种手势不能同时使用,我们从GestureDetector源码来看,能不能封装一个监听不同四个方向的手势,;

GestureDetector

final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};

if (onVerticalDragDown != null ||
    onVerticalDragStart != null ||
    onVerticalDragUpdate != null ||
    onVerticalDragEnd != null ||
    onVerticalDragCancel != null) {
  gestures[VerticalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
    () => VerticalDragGestureRecognizer(debugOwner: this),
    (VerticalDragGestureRecognizer instance) {
      instance
        ..onDown = onVerticalDragDown
        ..onStart = onVerticalDragStart
        ..onUpdate = onVerticalDragUpdate
        ..onEnd = onVerticalDragEnd
        ..onCancel = onVerticalDragCancel;
    },
  );
}

return RawGestureDetector(
  gestures: gestures,
  behavior: behavior,
  excludeFromSemantics: excludeFromSemantics,
  child: child,
);
复制代码

GestureDetector最终返回的是RawGestureDetector,其中gestures是一个map,竖直方向的手势在VerticalDragGestureRecognizer这个类;

VerticalDragGestureRecognizer

class VerticalDragGestureRecognizer extends DragGestureRecognizer {
  /// Create a gesture recognizer for interactions in the vertical axis.
  VerticalDragGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner);

  @override
  bool _isFlingGesture(VelocityEstimate estimate) {
    final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
    final double minDistance = minFlingDistance ?? kTouchSlop;
    return estimate.pixelsPerSecond.dy.abs() > minVelocity && estimate.offset.dy.abs() > minDistance;
  }

  @override
  bool get _hasSufficientPendingDragDeltaToAccept => _pendingDragOffset.dy.abs() > kTouchSlop;

  @override
  Offset _getDeltaForDetails(Offset delta) => Offset(0.0, delta.dy);

  @override
  double _getPrimaryValueFromOffset(Offset value) => value.dy;

  @override
  String get debugDescription => 'vertical drag';
}
复制代码

VerticalDragGestureRecognizer继承DragGestureRecognizer,大部分逻辑都在DragGestureRecognizer中,我们只关注重写的方法:

  • _hasSufficientPendingDragDeltaToAccept方法是关键逻辑,控制是否接受该拖拽手势
  • _getDeltaForDetails返回拖拽进度的dx、dy偏移量
  • _getPrimaryValueFromOffset返回单方向手势value,不同方向(同时拥有水平和竖直)的可以传null
  • _isFlingGesture是否该手势的Fling行为

自定义DragGestureRecognizer

想实现接受三个方向的手势,自定义DragGestureRecognizer是一个好的思路;我希望接受上、下、左、右四个方向的参数,根据参数不同监听不同的手势行为,照葫芦画瓢自定义一个接受方向的GestureRecognizer

DirectionGestureRecognizer

class DirectionGestureRecognizer extends _DragGestureRecognizer {
  int direction;
  //接受中途变动
  ChangeGestureDirection changeGestureDirection;
  //不同方向
  static int left = 1 << 1;
  static int right = 1 << 2;
  static int up = 1 << 3;
  static int down = 1 << 4;
  static int all = left | right | up | down;

  DirectionGestureRecognizer(this.direction,
      {Object debugOwner})
      : super(debugOwner: debugOwner);

  @override
  bool _isFlingGesture(VelocityEstimate estimate) {
    if (changeGestureDirection != null) {
      direction = changeGestureDirection();
    }
    final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
    final double minDistance = minFlingDistance ?? kTouchSlop;
    if (_hasAll) {
      return estimate.pixelsPerSecond.distanceSquared > minVelocity &&
          estimate.offset.distanceSquared > minDistance;
    } else {
      bool result = false;
      if (_hasVertical) {
        result |= estimate.pixelsPerSecond.dy.abs() > minVelocity &&
            estimate.offset.dy.abs() > minDistance;
      }
      if (_hasHorizontal) {
        result |= estimate.pixelsPerSecond.dx.abs() > minVelocity &&
            estimate.offset.dx.abs() > minDistance;
      }
      return result;
    }
  }

  bool get _hasLeft => _has(DirectionGestureRecognizer.left);

  bool get _hasRight => _has(DirectionGestureRecognizer.right);

  bool get _hasUp => _has(DirectionGestureRecognizer.up);

  bool get _hasDown => _has(DirectionGestureRecognizer.down);
  bool get _hasHorizontal => _hasLeft || _hasRight;
  bool get _hasVertical => _hasUp || _hasDown;

  bool get _hasAll => _hasLeft && _hasRight && _hasUp && _hasDown;

  bool _has(int flag) {
    return (direction & flag) != 0;
  }

  @override
  bool get _hasSufficientPendingDragDeltaToAccept {
    if (changeGestureDirection != null) {
      direction = changeGestureDirection();
    }
    // if (_hasAll) {
    //   return _pendingDragOffset.distance > kPanSlop;
    // }
    bool result = false;
    if (_hasUp) {
      result |= _pendingDragOffset.dy < -kTouchSlop;
    }
    if (_hasDown) {
      result |= _pendingDragOffset.dy > kTouchSlop;
    }
    if (_hasLeft) {
      result |= _pendingDragOffset.dx < -kTouchSlop;
    }
    if (_hasRight) {
      result |= _pendingDragOffset.dx > kTouchSlop;
    }
    return result;
  }

  @override
  Offset _getDeltaForDetails(Offset delta) {
    if (_hasAll || (_hasVertical && _hasHorizontal)) {
      return delta;
    }

    double dx = delta.dx;
    double dy = delta.dy;

    if (_hasVertical) {
      dx = 0;
    }
    if (_hasHorizontal) {
      dy = 0;
    }
    Offset offset = Offset(dx, dy);
    return offset;
  }

  @override
  double _getPrimaryValueFromOffset(Offset value) {
    return null;
  }

  @override
  String get debugDescription => 'orientation_' + direction.toString();
}
复制代码

重写主要的识别方法,根据不同的参数处理不同的手势逻辑;

注意事项

但是这里有一些注意事项:_getDeltaForDetails返回水平竖直方向的偏移量,在手势交叉方向的偏移量适情况需要置0;

当前Widget树只纯在一个手势时,手势判断的逻辑_hasSufficientPendingDragDeltaToAccept可能不会被调用,一定要重写_getDeltaForDetails控制返回结果;

如何使用

自定义的DirectionGestureRecognizer可以配置leftrightupdown四个方向的手势,而且支持不同的组合;

比如我们只想监听竖直向下方向,就创建DirectionGestureRecognizer(DirectionGestureRecognizer.down)的手势识别;

想监听上、左、右的手势,创建DirectionGestureRecognizer(DirectionGestureRecognizer.left | DirectionGestureRecognizer.right | DirectionGestureRecognizer.up)的手势识别;

DirectionGestureRecognizer就像一把磨刀石,刀已经磨锋利,砍材就很轻松了,下面进行控件的手势实现;

下拉控件手势

PullDragWidget

_contentGestures = {
//向下的手势
  DirectionGestureRecognizer:
      GestureRecognizerFactoryWithHandlers<DirectionGestureRecognizer>(
          () => DirectionGestureRecognizer(DirectionGestureRecognizer.down),
          (instance) {
    instance.onDown = _onDragDown;
    instance.onStart = _onDragStart;
    instance.onUpdate = _onDragUpdate;
    instance.onCancel = _onDragCancel;
    instance.onEnd = _onDragEnd;
  }),
  //点击的手势
  TapGestureRecognizer:
      GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
          () => TapGestureRecognizer(), (instance) {
    instance.onTap = _onContentTap;
  })
};

Widget build(BuildContext context) {
  return RawGestureDetector(//返回RawGestureDetector
      behavior: HitTestBehavior.translucent,
      gestures: _contentGestures,//手势在此
      child: Stack(
        children: <Widget>[
          Positioned(
              top: _offsetY,
              bottom: -_offsetY,
              left: 0,
              right: 0,
              child: IgnorePointer(
                ignoring: _opened,
                child: widget.child,
              )),
          Positioned(
              top: -widget.dragHeight + _offsetY,
              bottom: null,
              left: 0,
              right: 0,
              height: widget.dragHeight,
              child: _headerWidget()),
        ],
      ));
}
复制代码

PullDragWidget是下拉拖拽控件,根Widget是一个RawGestureDetector用来监听手势,其中gestures支持向下拖拽和点击两个手势;当下拉控件处于_opened状态说header已经拉下来,此时配合IgnorePointer,禁用子Widget所有的事件监听,自然内部的卡片就相应不了任何事件;

卡片控件手势

同下拉控件一样,卡片控件只需要监听其余三个方向的手势,即可完成任务:

CardStackWidget

_cardGestures = {
  DirectionGestureRecognizer://监听上左右三个方向
      GestureRecognizerFactoryWithHandlers<DirectionGestureRecognizer>(
          () => DirectionGestureRecognizer(DirectionGestureRecognizer.left |
              DirectionGestureRecognizer.right |
              DirectionGestureRecognizer.up), (instance) {
    instance.onDown = _onPanDown;
    instance.onUpdate = _onPanUpdate;
    instance.onEnd = _onPanEnd;
  }),
  TapGestureRecognizer:
      GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
          () => TapGestureRecognizer(), (instance) {
    instance.onTap = _onCardTap;
  })
};
复制代码

小结

根据Flutter手势冒泡的特性,父Widget既没有响应事件的优先权,也没有监听单独方向的手势,只能自己想办法自定义GestureRecognizer,把原本VerticalHorizontal两个方向的手势识别扩展成leftrightupdown四个方向,分开监听可能会冲突的手势;当然也可能有其他的方案来实现手势的监听,希望大家能提出宝贵意见;

总结

知识点

由于篇幅有限并没有完全介绍该交互的所有内容,我归纳一下代码中用到的知识点:

  • ColumnRowExpandedStackPositionedTransform等Widget;
  • GestureDetectorRawGestureDetectorIgnorePointer等Widget;
  • 自定义GestureRecognizer实现自定义手势识别;
  • AnimationControllerTween等动画的使用;
  • EventBus的使用;

最后

上面章节主要介绍在当前场景下用Flutter布局和手势的实战技巧,其中更深层次手势竞技和分发的源码级分析,有机会再做深入学习和分享;

另外本篇并不是循序渐进的零基础入门,对刚接触的同学可能感觉有点懵,但是没有关系,建议你clone一份代码跑起来效果,没准就能提起自己学习的兴趣;

最最后,本篇所有代码都是开源的,你的点赞是对我最大的鼓励。

项目地址:github.com/HitenDev/Fl…

猜你喜欢

转载自juejin.im/post/5c7c72de5188253ec91e3093