Flutter挑战之手势冲突


theme: cyanosis
highlight: androidstudio


前言

手势冲突一直是 Flutter 里面一个高频的问题。图片浏览组件,更是该问题的重灾区。
extended_image | Flutter Package (flutter-io.cn) 支持缩放拖拽图片,图片浏览(微信掘金效果),滑动退出页面(微信掘金效果),编辑图片(裁剪旋转翻转),也避免不了手势的问题。

as design ,我是从上一家外企里面学到的词汇。每次有人提到手势冲突的问题,因为懒和认知不足,也都习惯性地回复 as design 。

[图片上传失败...(image-98269-1632709591045)]

但是原生都可以解决,难道 Flutter 就不行吗?当然不是了,只是我们对 Flutter 还不够了解。

[图片上传失败...(image-ea5b63-1632709591046)]

为了跟原生的体验更加接近,需要解决下面的几个问题:

  • 对缩放手势和水平/垂直手势判断不准确
  • 放大状态,缩放手势和水平/垂直手势不能无缝切换
  • PageView 滚动未结束时,无法立刻进行缩放
  • PageView 支持间距

老惯例,先放图,后放代码,小姐姐镇楼。

[图片上传失败...(image-1f239d-1632709591046)]

接着上一期挑战 Flutter挑战之增大点击范围 - 掘金 (juejin.cn),其实我们已经一窥手势是如何而来的,只是我们还不知道,从引擎传递过来的 rawevent 怎么转换成 TaponLongPressScale 等我们熟悉的事件。

对缩放手势和水平/垂直手势判断不准确

代码准备,我们这里以 ScaleHorizontalDrag 为例子( VerticalDrag 也是一样的道理)。

           GestureDetector(
              onScaleStart: (details) {
                print('onScaleStart');
              },
              onScaleUpdate: (details) {
                print('onScaleUpdate');
              },
              onScaleEnd: (details) {
                print('onScaleEnd');
              },
              onHorizontalDragDown: (details) {
                print('onHorizontalDragDown');
              },
              onHorizontalDragStart: (details) {
                print('onHorizontalDragStart');
              },
              onHorizontalDragUpdate: (details) {
                print('onHorizontalDragUpdate');
              },
              onHorizontalDragEnd: (details) {
                print('onHorizontalDragEnd');
              },
              onHorizontalDragCancel: () {
                print('onHorizontalDragCancel');
              },
              child: Container(
                color: Colors.red,
              ),
            ),

加入竞技场

HorizontalDragGestureRecognizer 和 ScaleGestureRecognizer 是什么时候加入的竞技场呢?

[图片上传失败...(image-a89bca-1632709591046)]

RawGestureDetectorState._handlePointerDown 为入口,最终加入到GestureBinding.instance!.gestureArena

  void _handlePointerDown(PointerDownEvent event) {
    assert(_recognizers != null);
    for (final GestureRecognizer recognizer in _recognizers!.values)
      recognizer.addPointer(event);
  }

那么我们现在竞技场里面就有2个手势识别器了。

手势获胜

HorizontalDragGestureRecognizer 和 ScaleGestureRecognizer都是继承于 GestureArenaMember,这2个方法比较重要。

abstract class GestureArenaMember {
  /// Called when this member wins the arena for the given pointer id.
  void acceptGesture(int pointer);


  /// Called when this member loses the arena for the given pointer id.
  void rejectGesture(int pointer);
}

接下来我们要看看 HorizontalDragGestureRecognizer 和 ScaleGestureRecognizer 胜利的条件是什么?

  • HorizontalDragGestureRecognizer
  if (_hasSufficientGlobalDistanceToAccept(event.kind))
      resolve(GestureDisposition.accepted);


  @override
  bool _hasSufficientGlobalDistanceToAccept(PointerDeviceKind pointerDeviceKind) {
    return _globalDistanceMoved.abs() > computeHitSlop(pointerDeviceKind);
  }

阈值: 鼠标 1.0,触摸 18.0

/// Determine the appropriate hit slop pixels based on the [kind] of pointer.
double computeHitSlop(PointerDeviceKind kind) {
  switch (kind) {
      // const double kPrecisePointerHitSlop = 1.0; 
      // 等于 1.0
    case PointerDeviceKind.mouse:
      return kPrecisePointerHitSlop;
    case PointerDeviceKind.stylus:
    case PointerDeviceKind.invertedStylus:
    case PointerDeviceKind.unknown:
    case PointerDeviceKind.touch:
      // const double kTouchSlop = 18.0; // Logical pixels   
      // 等于 18.0
      return kTouchSlop;
  }
}
  • ScaleGestureRecognizer
      final double spanDelta = (_currentSpan - _initialSpan).abs();
      final double focalPointDelta = (_currentFocalPoint - _initialFocalPoint).distance;
       // 大于 鼠标 1.0 或者 触摸 18.0
      if (spanDelta > computeScaleSlop(pointerDeviceKind) || 
      // 大于 鼠标 2.0 或者 触摸 36.0
      focalPointDelta > computePanSlop(pointerDeviceKind))
        resolve(GestureDisposition.accepted);
  • spanDelta 多指 Scale 的偏移量,阈值: 鼠标 1.0,触摸 18.0

顺带提下

 spanDelta= (_currentSpan - _initialSpan).abs();
 double get _scaleFactor =>
      _initialSpan > 0.0 ? _currentSpan / _initialSpan : 1.0;
  • focalPointDelta Scale 中心的偏移量,阈值: 鼠标 2.0,触摸 36.0

/// Determine the appropriate pan slop pixels based on the [kind] of pointer.
double computePanSlop(PointerDeviceKind kind) {
  switch (kind) {
    case PointerDeviceKind.mouse:
      // const double kPrecisePointerPanSlop = kPrecisePointerHitSlop * 2.0; 
      // 等于 2.0
      return kPrecisePointerPanSlop;
    case PointerDeviceKind.stylus:
    case PointerDeviceKind.invertedStylus:
    case PointerDeviceKind.unknown:
    case PointerDeviceKind.touch:
      // const double kPanSlop = kTouchSlop * 2.0; 
      // 等于 36.0
      return kPanSlop;
  }
}

/// Determine the appropriate scale slop pixels based on the [kind] of pointer.
double computeScaleSlop(PointerDeviceKind kind) {
  switch (kind) {
    case PointerDeviceKind.mouse:
     //const double kPrecisePointerScaleSlop = kPrecisePointerHitSlop; 
      // 等于 1.0
      return kPrecisePointerScaleSlop;
    case PointerDeviceKind.stylus:
    case PointerDeviceKind.invertedStylus:
    case PointerDeviceKind.unknown:
    case PointerDeviceKind.touch:
      /// The distance a touch has to travel for the framework to be confident that
      // const double kScaleSlop = kTouchSlop; // Logical pixels   
      // 等于 18
      return kScaleSlop;
  }
}

由于 focalPointDelta(Scale) 的阈值为36.0,而 _globalDistanceMoved(HorizontalDrag)的阈值为18.0。如果你双指在水平上的动作 spanDelta(阈值为18.0)增长速度不如水平移动的 _globalDistanceMoved,那么这个动作就会被认定为 HorizontalDrag

看完这些判断,你应该很容易就明白了,为啥双指水平 Scale 的时候经常跟 HorizontalDrag 混淆了? 我打印一下,双指水平 Scale 的时候的日志。

[图片上传失败...(image-dc5330-1632709591046)]

Flutter: 我不要你觉得我要我觉得

优化手势判断

我们应该把手势获胜的条件更加精细化,双指水平 Scale 的时候必然是多指操作,并且多指的方向必然是相反方向。

HorizontalDragGestureRecognizer 中的判断胜利的方法修改为如下:

  if (_hasSufficientGlobalDistanceToAccept(event.kind) && _shouldAccpet())
          resolve(GestureDisposition.accepted);


  bool _shouldAccpet() {
    // 单指获胜
    if (_velocityTrackers.keys.length == 1) {
      return true;
    }

    // 双指判断每个点的运动方法,是否是相反
    // maybe this is a Horizontal/Vertical zoom
    Offset offset = const Offset(1, 1);
    for (final VelocityTracker tracker in _velocityTrackers.values) {
      final Offset delta =
          (tracker as ExtendedVelocityTracker).getSamplesDelta();
      offset = Offset(offset.dx * (delta.dx == 0 ? 1 : delta.dx),
          offset.dy * (delta.dy == 0 ? 1 : delta.dy));
    }

    return !(offset.dx < 0 || offset.dy < 0);
  }

修改之后,我们在进行水平 Scale 的时候几乎不会再跟 HorizontalDrag 产生歧义。

手势失败

这里顺带讲下,当竞技场里面有一个手势获胜的时候,就会将竞技场当中的其他的手势设置为失败,失败的手势将停止获取。

如下堆栈信息,当 HorizontalDrag 胜出的时候,竞技场中的其他竞争者 Scale 的 rejectGesture 方法就会被调用,从而停止对 Pointer 的监听。

[图片上传失败...(image-95a41b-1632709591046)]

  @override
  void rejectGesture(int pointer) {
    stopTrackingPointer(pointer);
  }
  @protected
  void stopTrackingPointer(int pointer) {
    if (_trackedPointers.contains(pointer)) {
      GestureBinding.instance!.pointerRouter.removeRoute(pointer, handleEvent);
      _trackedPointers.remove(pointer);
      if (_trackedPointers.isEmpty)
        didStopTrackingLastPointer(pointer);
    }
  }

放大状态,缩放手势和水平/垂直手势不能无缝切换

这个问题,我们其实已经知道,竞技场里面只能有一个选手胜出,竞技场里面有胜出者的时候,后加入的手势也会被直接 rejectGesture 掉。关键代码和堆栈信息如下:

[图片上传失败...(image-a33399-1632709591046)]

  • 我的第一反应是,写一个 GestureRecognizer 里面直接就包括对 DragScale 手势的支持。但是考虑到这2种手势的独特性,以及 PageViewScrollPositionDragStartDetailsDragUpdateDetailsDragEndDetails 的依赖,不想再修改更多的源码了,最终未采取这种方式。

  • 取巧,在 Scale 大于 1 的状态下,禁止 HorizontalDragGestureRecognizer 胜出。这种方式就相当灵活了,为 HorizontalDragGestureRecognizer 增加一个回调,来判断是否要让它能胜出。

  bool get canDrag =>
      canHorizontalOrVerticalDrag == null || canHorizontalOrVerticalDrag!();

  bool _shouldAccpet() {
    if (!canDrag) {
      return false;
    }
    if (_velocityTrackers.keys.length == 1) {
      return true;
    }

    // if pointers are not the only, check whether they are in the negative
    // maybe this is a Horizontal/Vertical zoom
    Offset offset = const Offset(1, 1);
    for (final VelocityTracker tracker in _velocityTrackers.values) {
      final Offset delta =
          (tracker as ExtendedVelocityTracker).getSamplesDelta();
      offset = Offset(offset.dx * (delta.dx == 0 ? 1 : delta.dx),
          offset.dy * (delta.dy == 0 ? 1 : delta.dy));
    }

    return !(offset.dx < 0 || offset.dy < 0);
  }

这样,在 Scale 大于 1 的状态下,我们就只会触发 Scale 相关的事件。我们只需要在特殊条件下,比如滚动到边界了将要切换上下一页的时候,将下面转换成 Drag 相关即可。

  • ScaleUpdateDetails => DragDownDetails,DragStartDetails, DragUpdateDetails

  • ScaleEndDetails => DragEndDetails

PageView 滚动未结束时,无法立刻进行缩放

场景重现和调试

  1. 在第一页快速滑动
  2. 惯性滑动到第二页(列表未停止),双指立即 Scale 操作。
  • ExtendedImageGesturePageView 注册的 HorizontalDrag 事件。
  • ExtendedGestureDetector(Image) 注册的 Scale 事件。

我在关键位置打上了 log , 我们来看看这个过程中到底发生了什么。

  • 第一页的 Image 中的 ExtendedGestureDetector 中获得 hittest ,并且把 ExtendedScaleGestureRecognizer 增加到竞技场中。

I[/flutter]() (20180): _handlePointerDown: ExtendedGestureDetector(startBehavior: start)----{DoubleTapGestureRecognizer: DoubleTapGestureRecognizer#e58e1(debugOwner: ExtendedGestureDetector), ExtendedScaleGestureRecognizer: ExtendedScaleGestureRecognizer#56dd2(debugOwner: ExtendedGestureDetector)}

  • ExtendedImageGesturePageView 中获得 hittest ,并且把 ExtendedHorizontalDragGestureRecognizer 增加到竞技场中。

I[/flutter]() (20180): _handlePointerDown: ExtendedImageGesturePageViewState#7e333(ticker inactive)----{ExtendedHorizontalDragGestureRecognizer: ExtendedHorizontalDragGestureRecognizer#33ed0(debugOwner: ExtendedImageGesturePageViewState#7e333(ticker inactive), start behavior: start)}

  • 开始竞争
I[/flutter]() (20180): ExtendedGestureDetector(startBehavior: start)---_TransformedPointerDownEvent

I[/flutter]() (20180): ExtendedGestureDetector(startBehavior: start)---spanDelta: 0.0 focalPointDelta:0.0 ---多指个数: 1

I[/flutter]() (20180): ExtendedGestureDetector(startBehavior: start)---_TransformedPointerMoveEvent

I[/flutter]() (20180): ExtendedGestureDetector(startBehavior: start)---spanDelta: 0.0 focalPointDelta:0.0 ---多指个数: 1

I[/flutter]() (20180): ExtendedImageGesturePageViewState#7e333(ticker inactive)---_globalDistanceMoved: 0.0 ---多指个数: 1

I[/flutter]() (20180): ExtendedGestureDetector(startBehavior: start)---_TransformedPointerMoveEvent

I[/flutter]() (20180): ExtendedGestureDetector(startBehavior: start)---spanDelta: 0.0 focalPointDelta:5.666666666666686 ---多指个数: 1

I[/flutter]() (20180): ExtendedImageGesturePageViewState#7e333(ticker inactive)---_globalDistanceMoved: -5.666666666666686 ---多指个数: 1

I[/flutter]() (20180): ExtendedGestureDetector(startBehavior: start)---_TransformedPointerMoveEvent

I[/flutter]() (20180): ExtendedGestureDetector(startBehavior: start)---spanDelta: 0.0 focalPointDelta:15.666666666666686 ---多指个数: 1

I[/flutter]() (20180): ExtendedImageGesturePageViewState#7e333(ticker inactive)---_globalDistanceMoved: -15.666666666666686 ---多指个数: 1

I[/flutter]() (20180): ExtendedGestureDetector(startBehavior: start)---_TransformedPointerMoveEvent

I[/flutter]() (20180): ExtendedGestureDetector(startBehavior: start)---spanDelta: 0.0 focalPointDelta:29.683515814524238 ---多指个数: 1

I[/flutter]() (20180): ExtendedImageGesturePageViewState#7e333(ticker inactive)---_globalDistanceMoved: -29.666666666666714 ---多指个数: 1
  • Scale 手势输掉

I[/flutter]() (20180): ExtendedGestureDetector(startBehavior: start)---rejectGesture

  • HorizontalDrag 继续

I[/flutter]() (20180): ExtendedImageGesturePageViewState#7e333(ticker inactive)---_globalDistanceMoved: -29.666666666666714 --- 多指个数:1

I[/flutter]() (20180): ExtendedImageGesturePageViewState#7e333(ticker inactive)---_globalDistanceMoved: -29.666666666666714 --- 多指个数:1

  • 滑动到第二页,双指立即做出 Scale 操作。

I[/flutter]() (20180): _handlePointerDown: ExtendedImageGesturePageViewState#7e333(ticker inactive)----{ExtendedHorizontalDragGestureRecognizer: ExtendedHorizontalDragGestureRecognizer#33ed0(debugOwner: ExtendedImageGesturePageViewState#7e333(ticker inactive), start behavior: start)}

ExtendedImageGesturePageView 中获得 hittest ,并且把 ExtendedHorizontalDragGestureRecognizer 增加到竞技场中。

  • 竞技场中只剩下 ExtendedHorizontalDragGestureRecognizer ,直接获胜。

I[/flutter]() (20180): ExtendedImageGesturePageViewState#7e333(ticker inactive)---_globalDistanceMoved: 0.0 --- 多指个数:2

I[/flutter]() (20180): ExtendedImageGesturePageViewState#7e333(ticker inactive)---_globalDistanceMoved: 0.0 --- 多指个数:2

I[/flutter]() (20180): ExtendedImageGesturePageViewState#7e333(ticker inactive)---_globalDistanceMoved: 0.0 --- 多指个数:2

I[/flutter]() (20180): ExtendedImageGesturePageViewState#7e333(ticker inactive)---_globalDistanceMoved: 0.0 --- 多指个数:2

看到这里,我们应该了解到了,这种场景下面,第2页的 Image 中的 ExtendedGestureDetector 中未能获得 hittest

为了找到真相,我们增加更多的日志

  • RenderBox.hitTest

修改代码,打印没有符合的 _size!.contains(position) 元素。

    if (_size!.contains(position)) {
      if (hitTestChildren(result, position: position) ||
          hitTestSelf(position)) {
        result.add(BoxHitTestEntry(this, position));
        return true;
      }
    } else {
      print('hittest is false $debugCreator');
    }
  • RenderProxyBoxWithHitTestBehavior.hitTest

注意 debugOwner 是我自己增加进来的。

  @override
  bool hitTest(BoxHitTestResult result, {required Offset position}) {
    bool hitTarget = false;
    if (size.contains(position)) {
      hitTarget =
          hitTestChildren(result, position: position) || hitTestSelf(position);
      if (hitTarget || behavior == HitTestBehavior.translucent) {
        if (debugOwner != null) {
          print('hittest is true $debugOwner');
        }
        result.add(BoxHitTestEntry(this, position));
      } else {
        if (debugOwner != null) {
          print('hittest is false $debugOwner hitTestChildren is not true ');
        }
      }
    } else {
      if (debugOwner != null) {
        print('hittest is false $debugOwner $size not contains $position');
      }
    }
    return hitTarget;
  }

日志如下:

I[/flutter]() (25134): ExtendedImageGesturePageViewState#9c964(ticker inactive)---_globalDistanceMoved: -17.0 ---多指个数: 1

I[/flutter]() (25134): ExtendedGestureDetector(startBehavior: start)---_TransformedPointerMoveEvent

I[/flutter]() (25134): ExtendedGestureDetector(startBehavior: start)---spanDelta: 0.0 focalPointDelta:30.066592756745816 ---多指个数: 1

I[/flutter]() (25134): ExtendedImageGesturePageViewState#9c964(ticker inactive)---_globalDistanceMoved: -30.0 ---多指个数: 1

I[/flutter]() (25134): ExtendedGestureDetector(startBehavior: start)---rejectGesture

I[/flutter]() (25134): ExtendedImageGesturePageViewState#9c964(ticker inactive)---_globalDistanceMoved: -30.0 --- 多指个数:1

I[/flutter]() (25134): ExtendedImageGesturePageViewState#9c964(ticker inactive)---_globalDistanceMoved: -30.0 --- 多指个数:1

I[/flutter]() (25134): hittest is true ExtendedImageGesturePageViewState#9c964(ticker inactive)

I[/flutter]() (25134): _handlePointerDown: ExtendedImageGesturePageViewState#9c964(ticker inactive)----{ExtendedHorizontalDragGestureRecognizer: ExtendedHorizontalDragGestureRecognizer#82215(debugOwner: ExtendedImageGesturePageViewState#9c964(ticker inactive), start behavior: start)}

I[/flutter]() (25134): hittest is true ExtendedImageGesturePageViewState#9c964(ticker inactive)

I[/flutter]() (25134): _handlePointerDown: ExtendedImageGesturePageViewState#9c964(ticker inactive)----{ExtendedHorizontalDragGestureRecognizer: ExtendedHorizontalDragGestureRecognizer#82215(debugOwner: ExtendedImageGesturePageViewState#9c964(ticker inactive), start behavior: start)}

I[/flutter]() (25134): ExtendedImageGesturePageViewState#9c964(ticker inactive)---_globalDistanceMoved: 0.0 --- 多指个数:2

I[/flutter]() (25134): ExtendedImageGesturePageViewState#9c964(ticker inactive)---_globalDistanceMoved: 0.0 --- 多指个数:2

通过日志我们可以发现:

  • ExtendedImageGesturePageViewhittest 是通过。
  • 没有发现 ExtendedGestureDetector 的相关日志,并且连 print('hittest is false $debugCreator'); 都没有打印过。

我的第一反应就是,有东西阻止它参与 hittest 了。我们再思考一下这个场景的一个条件,那就是滚动未停止,是不是这个里面有点门道?

[图片上传失败...(image-6a157a-1632709591046)]

其实我在讲解 Sliver 系列的时候已经提过一下 Flutter 重识 NestedScrollView - 掘金 (juejin.cn),那就是滚动组件 Scrollable 会在滚动开始之后其 child 将不再接受 PointerEvent 事件,看看官方解释。


  /// Whether the contents of the widget should ignore [PointerEvent] inputs.
  ///
  /// Setting this value to true prevents the use from interacting with the
  /// contents of the widget with pointer events. The widget itself is still
  /// interactive.
  ///
  /// For example, if the scroll position is being driven by an animation, it
  /// might be appropriate to set this value to ignore pointer events to
  /// prevent the user from accidentally interacting with the contents of the
  /// widget as it animates. The user will still be able to touch the widget,
  /// potentially stopping the animation.
  void setIgnorePointer(bool value);

  • 滚动开始

[图片上传失败...(image-d0c5a4-1632709591046)]

  @override
  @protected
  void setIgnorePointer(bool value) {
    if (_shouldIgnorePointer == value) return;
    _shouldIgnorePointer = value;
    if (_ignorePointerKey.currentContext != null) {
      final RenderIgnorePointer renderBox = _ignorePointerKey.currentContext!
          .findRenderObject()! as RenderIgnorePointer;
      renderBox.ignoring = _shouldIgnorePointer;
    }
  }

RenderIgnorePointerignoring 设置为 true,阻止 child 接受 PointerEvent 事件。

    Widget result = _ScrollableScope(
      scrollable: this,
      position: position,
      // TODO(ianh): Having all these global keys is sad.
      child: Listener(
        onPointerSignal: _receivedPointerSignal,
        child: RawGestureDetector(
          key: _gestureDetectorKey,
          gestures: _gestureRecognizers,
          behavior: HitTestBehavior.opaque,
          excludeFromSemantics: widget.excludeFromSemantics,
          child: Semantics(
            explicitChildNodes: !widget.excludeFromSemantics,
            child: IgnorePointer(
              key: _ignorePointerKey,
              ignoring: _shouldIgnorePointer,
              ignoringSemantics: false,
              child: widget.viewportBuilder(context, position),
            ),
          ),
        ),
      ),
    );
  • 滚动结束

再将 RenderIgnorePointerignoring 设置为 false。这就解释了,为啥等列表停止了之后,ExtendedGestureDetector(Image) 又能触发 Scale 事件了。
[图片上传失败...(image-c93982-1632709591046)]

解决问题

试试改源码

首先我们是不大可能去修改 Scrollable 的源码的,涉及的代码太多。我们可以尝试从
ScrollPositionWithSingleContext( ScrollPostion ) 的源码去尝试。从堆栈信息来看,ScrollActivity.shouldIgnorePointer 是关键。而继承 ScrollActivity 的类有以下

[图片上传失败...(image-41b53e-1632709591046)]

类名 解释 shouldIgnorePointer
HoldScrollActivity DragDown 的时候 ScrollPositionWithSingleContext( ScrollPostion ).hold 方法中生成 true
DragScrollActivity DragStart 的时候 ScrollPositionWithSingleContext( ScrollPostion ).drag 方法中生成 true
DrivenScrollActivity ScrollPositionWithSingleContext( ScrollPostion ).animateTo 使用动画滑动使用 true
BallisticScrollActivity ScrollPositionWithSingleContext( ScrollPostion ).goBallistic 惯性滑动 true
IdleScrollActivity ScrollPositionWithSingleContext( ScrollPostion ).goIdle 滑动结束 false

接下来就是苦力活了,把相关代码复制出来,将上面 4个 ScrollActivityshouldIgnorePointer 设置成 false 即可。(稳妥一点其实 DrivenScrollActivity 我们可以不设置成 false,但是图片浏览组件中,应该很少有人会去做动画效果,所以暂时都统一设置成 false)。

另一条路

说实话,用上一个方式解决问题之后,我还是有一些担忧,毕竟,官方在列表滚动设置 shouldIgnorePointertrue 肯定有它的道理(尽管官方只举例了想保护动画不被用户操作终止,但其他情况我们还是未知的)。那么我们有没有其他方式来解决呢?

实际上,我们注意到,ExtendedImageGesturePageView 不管在什么情况下,它都能 hittest 命中,那么我们其实只需要为 ExtendedImageGesturePageView 也注册 Scale 事件,然后传递给 ExtendedGestureDetector(Image) 即可。代码比较简单,感兴趣的可以查看。

https://github.com/fluttercandies/extended_image/blob/de2d604bf3abeb051825d3c7e2bf00b64e594d47/lib/src/gesture/page_view/gesture_page_view.dart#L486

需要注意的是,如果 Scale 的动作如果比较快,那么就有可能出现同时 Scale 两张图片的情况,毕竟是没法简单的区分出来当前需要 Scale 的图片。

最终我选择增加了一个参数 shouldIgnorePointerWhenScrolling 来控制到底使用哪种方式来处理这个问题。

https://github.com/fluttercandies/extended_image/blob/de2d604bf3abeb051825d3c7e2bf00b64e594d47/lib/src/gesture/page_view/widgets/page_controller.dart#L47

PageView 支持间距

这个其实是参考了原生系统自带相册的功能,发现每个图片之间都会有一定的间隔,PageView 显然不支持这个。

[图片上传失败...(image-85dfca-1632709591046)]

看过 Sliver 系列的应该对于 Sliver 列表绘制的过程比较了解了。这个功能不难,下面提一下主要修改哪些地方。

RenderSliverFillViewport

https://github.com/flutter/flutter/blob/31c0291af46bfb9d687186f8c238a70ffac8e4d0/packages/flutter/lib/src/rendering/sliver_fill.dart#L42

PageView 的每一页宽度(水平)相当于 viewport 的宽度。( viewportFraction 自行百度)

  @override
  double get itemExtent =>
      constraints.viewportMainAxisExtent * viewportFraction;

RenderSliverFixedExtentBoxAdaptor

https://github.com/flutter/flutter/blob/a3dc90c4f51523fd3f8df6eee3d79239a3c8de52/packages/flutter/lib/src/rendering/sliver_fixed_extent_list.dart#L187

RenderSliverFixedExtentBoxAdaptorperformLayout 方法中,很容易看出来是根据 itemExtent 来计算每个 child 的位置,以及 layout

    final double itemExtent = this.itemExtent;

    final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
    assert(scrollOffset >= 0.0);
    final double remainingExtent = constraints.remainingCacheExtent;
    assert(remainingExtent >= 0.0);
    final double targetEndScrollOffset = scrollOffset + remainingExtent;

    final BoxConstraints childConstraints = constraints.asBoxConstraints(
      minExtent: itemExtent,
      maxExtent: itemExtent,
    );

    final int firstIndex = getMinChildIndexForScrollOffset(scrollOffset, itemExtent);
    final int? targetLastIndex = targetEndScrollOffset.isFinite ?
        getMaxChildIndexForScrollOffset(targetEndScrollOffset, itemExtent) : null;

对于我们这个场景,child layout 的大小还是应该是 itemExtent。只不过计算下一个 child 的时候位置的时候,我们需要增加间距 pageSpacing 。修改之后的代码如下。

    final double itemExtent = this.itemExtent + pageSpacing;
    final double scrollOffset =
        constraints.scrollOffset + constraints.cacheOrigin;
    assert(scrollOffset >= 0.0);
    final double remainingExtent = constraints.remainingCacheExtent;
    assert(remainingExtent >= 0.0);
    final double targetEndScrollOffset = scrollOffset + remainingExtent;

    final BoxConstraints childConstraints = constraints.asBoxConstraints(
      minExtent: this.itemExtent,
      maxExtent: this.itemExtent,
    );

光是这样,肯定是不行的,这样会知道最后一页,也会有 pageSpacing,这样就不好看了。

https://github.com/flutter/flutter/blob/a3dc90c4f51523fd3f8df6eee3d79239a3c8de52/packages/flutter/lib/src/rendering/sliver_fixed_extent_list.dart#L268

    final int lastIndex = indexOf(lastChild!);
    final double leadingScrollOffset =
        indexToLayoutOffset(itemExtent, firstIndex);
    double trailingScrollOffset =
        indexToLayoutOffset(itemExtent, lastIndex + 1);

可以看到,trailingScrollOffset 的位置,是靠计算最后一个元素的下一个元素的开始位置。那么我们这里就可以修改 trailingScrollOffset 来移除掉最后一个元素的 pageSpacing,代码如下。

    final int lastIndex = indexOf(lastChild!);
    final double leadingScrollOffset =
        indexToLayoutOffset(itemExtent, firstIndex);
    double trailingScrollOffset =
        indexToLayoutOffset(itemExtent, lastIndex + 1);

    if (lastIndex > 0) {
      // lastChild don't need pageSpacing
      trailingScrollOffset -= pageSpacing;
    }

_PagePosition

上面我们把 ui 绘制的位置给搞定了,但是还没有完成全部的工作。我们在拖动 PageView 的时候,是靠 _PagePosition 中的代码来实现滑动一整页的,直接到核心位置。

https://github.com/flutter/flutter/blob/b8a2456737c9645e5f3d7210fba6267f7408486f/packages/flutter/lib/src/widgets/page_view.dart#L372

  double get _initialPageOffset => math.max(0, viewportDimension * (viewportFraction - 1) / 2);

  double getPageFromPixels(double pixels, double viewportDimension) {
    final double actual = math.max(0.0, pixels - _initialPageOffset) / math.max(1.0, viewportDimension * viewportFraction);
    final double round = actual.roundToDouble();
    if ((actual - round).abs() < precisionErrorTolerance) {
      return round;
    }
    return actual;
  }

  double getPixelsFromPage(double page) {
    return page * viewportDimension * viewportFraction + _initialPageOffset;
  }

  @override
  double? get page {
    assert(
      !hasPixels || (minScrollExtent != null && maxScrollExtent != null),
      'Page value is only available after content dimensions are established.',
    );
    return !hasPixels ? null : getPageFromPixels(pixels.clamp(minScrollExtent, maxScrollExtent), viewportDimension);
  }

Page 如何而来?都跟一个叫 viewportDimension 的东西有关系,实际上它就是 viewport 的宽度。那办法就简单了,将 viewportDimension 相关的地方增加上 pageSpacing。一共需要修改 2 个地方,直接上代码。

  // fix viewportDimension
  @override
  double get viewportDimension => super.viewportDimension + pageSpacing;
  @override
  bool applyViewportDimension(double viewportDimension) {
    final double? oldViewportDimensions =
        hasViewportDimension ? this.viewportDimension : null;
    // fix viewportDimension
    if (viewportDimension + pageSpacing == oldViewportDimensions) {
      return true;
    }
    final bool result = super.applyViewportDimension(viewportDimension);
    final double? oldPixels = hasPixels ? pixels : null;
    final double page = (oldPixels == null || oldViewportDimensions == 0.0)
        ? _pageToUseOnStartup
        : getPageFromPixels(oldPixels, oldViewportDimensions!);
    final double newPixels = getPixelsFromPage(page);

    if (newPixels != oldPixels) {
      correctPixels(newPixels);
      return false;
    }
    return result;
  }

结语

通过这2篇挑战,相信大家对于手势系统方面的问题,应该都有一战之力了,希望能给大家带来帮助。

  • Flutter挑战之增大点击范围 - 掘金 (juejin.cn)
  • Flutter挑战之手势冲突 - 掘金 (juejin.cn)

FlutterChallenges qq 群 321954965 喜欢折腾自己的童鞋欢迎加群,欢迎大家提供新的挑战或者解决挑战

Flutter,爱糖果,欢迎加入Flutter Candies,一起生产可爱的Flutter小糖果[图片上传失败...(image-a24da6-1632709591046)]QQ群:181398081

最最后放上 Flutter Candies 全家桶,真香。

[图片上传失败...(image-cb2c4-1632709591046)]

相关阅读

  • Flutter挑战之增大点击范围 - 掘金 (juejin.cn)
  • Flutter挑战之手势冲突 - 掘金 (juejin.cn)
  • Flutter 重识 NestedScrollView - 掘金 (juejin.cn)
  • Flutter Sliver一生之敌 (ScrollView) - 掘金 (juejin.cn)

你可能感兴趣的:(Flutter挑战之手势冲突)