Flutter挑战之手势冲突

5,986 阅读14分钟

前言

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

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

43F14D9E-F39A-49DF-ADF2-166775E3ACF0.png

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

image.png

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

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

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

1632324441882.gif

接着上一期挑战 Flutter挑战之增大点击范围 - 掘金 (juejin.cn),其实我们已经一窥手势是如何而来的,只是我们还不知道,从引擎传递过来的 raw 的 event 怎么转换成 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,
              ),
            ),

加入竞技场

HorizontalDragGestureRecognizerScaleGestureRecognizer 是什么时候加入的竞技场呢?

F687C9AA-6C7A-40CD-B006-7D7DD89FE6A1.png

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

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

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

手势获胜

HorizontalDragGestureRecognizerScaleGestureRecognizer都是继承于 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);
}

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

  • 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 的时候的日志。

F60E2B94-79B5-4225-B8B1-EDAD00F3B751.png

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 的监听。

截屏2021-09-24 下午5.44.18.png

  @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 掉。关键代码和堆栈信息如下:

截屏2021-09-26 上午9.33.21.png

  • 我的第一反应是,写一个 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

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

修改代码,打印没有符合的 _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');
    }

注意 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 了。我们再思考一下这个场景的一个条件,那就是滚动未停止,是不是这个里面有点门道?

截屏2021-09-26 下午3.07.57.png

其实我在讲解 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);

  • 滚动开始

截屏2021-09-26 下午3.16.03.png

  @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 事件了。 截屏2021-09-26 下午3.19.20.png

解决问题

试试改源码

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

截屏2021-09-26 下午3.34.05.png

类名解释shouldIgnorePointer
HoldScrollActivityDragDown 的时候 ScrollPositionWithSingleContext( ScrollPostion ).hold 方法中生成true
DragScrollActivityDragStart 的时候 ScrollPositionWithSingleContext( ScrollPostion ).drag 方法中生成true
DrivenScrollActivityScrollPositionWithSingleContext( ScrollPostion ).animateTo 使用动画滑动使用true
BallisticScrollActivityScrollPositionWithSingleContext( ScrollPostion ).goBallistic 惯性滑动true
IdleScrollActivityScrollPositionWithSingleContext( ScrollPostion ).goIdle 滑动结束false

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

另一条路

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

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

github.com/fluttercand…

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

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

github.com/fluttercand…

PageView 支持间距

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

8EC856EC3AD0D744ECCED3CABD0F52CD.jpg

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

RenderSliverFillViewport

github.com/flutter/flu…

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

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

RenderSliverFixedExtentBoxAdaptor

github.com/flutter/flu…

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,这样就不好看了。

github.com/flutter/flu…

    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 中的代码来实现滑动一整页的,直接到核心位置。

github.com/flutter/flu…

  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篇挑战,相信大家对于手势系统方面的问题,应该都有一战之力了,希望能给大家带来帮助。

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

Flutter,爱糖果,欢迎加入Flutter Candies,一起生产可爱的Flutter小糖果

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

相关阅读