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

image

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

先睹为快

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



image

image

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

交互分析

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

image

即刻App原版功能有卡片旋转,卡片撤回和卡片自动移除,时间关系暂时没有实现,但核心的功能都在;

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

下拉控件:

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

卡片控件

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

码上入手

热身

套用App开发伎俩,实现上面的交互无非就是控件布局和手势识别。当然Flutter开发也是这些套路,只不过万物皆是Widget,在Flutter中常用的基本布局有ColumnRowStack等,手势识别有ListenerGestureDetectorRawGestureDetector等,这是本文重点讲解的控件,不限于上面这几个Widget,因为Flutter提供的Widget太多了,重点的控件需要牢记外,其他时候真是现用现查;

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

布局摆放

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

下拉控件

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

image

如上图所示,红色区域是屏幕范围,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算是很轻松了;

image

重叠的效果使用Stack很简单,错落有致的效果实在起来可能性就比较多了,比如可以使用Positioned,也可以包裹Container改变margin或者padding,但是考虑到角度的旋转,我选择使用Transform,因为Transform不仅可以玩转位移,还有角度和缩放等,其内部实际上是操作一个矩阵变换;Transform挺好用,但是在Transform多层嵌套的某些特殊情况下,会存在不响应onTap事件的情况,我想这应该是Transform的bug,拖拽事件暂时没有发现问题,这个是不是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的拖拽的行为:当下拉控件没有展开下拉菜单时,卡片控件是可以相应上、左、右三个方向的手势,下拉控件只相应一个向下方向的手势;当下拉菜单展开时,卡片不能相应任何手势,下拉控件可以相应竖直方向的所有事件;

image

上图更加形象解释两种状态下的手势响应,下拉控件是父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();
}

由于DragGestureRecognizer的很多方法是私有的,想重新只能copy一份代码出来,然后重写主要的方法,根据不同入参处理不同的手势逻辑;

注意事项

敲黑板了,在自定义DragGestureRecognizer时:_getDeltaForDetails返回值表示dxdy的偏移量,在只存在水平或者只存在竖直方向的情况下,需要将另一个方向的dxdy置0;

当前Widget树有且只存在一个手势时,手势判断的逻辑_hasSufficientPendingDragDeltaToAccept可能不会被调用,这时候一定要重写_getDeltaForDetails控制返回dxdy

如何使用

自定义的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;
  })
};

手势答疑

  • 为什么不用 onPanDown onPanUpdate onPanEnd 来拖动?

这是掘金评论提的问题,我解答一下:在GestureDetector中有Pan手势和Drag手势,这两个手势都能用处拖拽的场景,但不同的是Drag手势仅限于水平竖直方向的监听,Pan手势不约束方向任意方向都能监听,除此之外触发条件也不一致,Pan手势的触发条件是滑动动屏幕的距离distance大于kTouchSlop*2Drag手势的触发条件是dx或者dy大于kTouchSlopdxdydistance形成勾股定理的三个边长;假设同样在监听竖直滑动这种场景,VerticalDrag总是比Pan先触发;如果下拉控件用VerticalDrag卡片控件用Pan,下拉控件会优先获取向上的拖拽,卡片控件就会失去向上拖拽的机会,这就实现不了交互了,退一步即使Pan的触发条件跟VerticalDrag一样,由于Flutter的事件传递是从内到外的,这会导致外层下拉控件完全失去响应机会。以上我的个人理解,如有误导还请大佬评论指正。

手势小结

分析Flutter手势冒泡的特性,父Widget既没有响应事件的优先权,也没有监听单独方向(leftrightupdown)的手势,只能自己想办法自定义GestureRecognizer,把原本VerticalHorizontal两个方向的手势识别扩展成leftrightupdown四个方向,区分开会产生冲突的手势;

当然也可能有其他的方案来实现该交互的手势识别,条条大路通罗马,我只是抛砖引玉,大家有好的方案可以积极留言提出宝贵意见;

总结

知识点

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

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

最后

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

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

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

项目地址:https://github.com/HitenDev/FlutterDragCard

推荐阅读更多精彩内容