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),其实我们已经一窥手势是如何而来的,只是我们还不知道,从引擎传递过来的 raw
的 event
怎么转换成 Tap
,onLongPress
,Scale
等我们熟悉的事件。
对缩放手势和水平/垂直手势判断不准确
代码准备,我们这里以 Scale
和 HorizontalDrag
为例子( 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
里面直接就包括对Drag
和Scale
手势的支持。但是考虑到这2种手势的独特性,以及PageView
中ScrollPosition
对DragStartDetails
,DragUpdateDetails
,DragEndDetails
的依赖,不想再修改更多的源码了,最终未采取这种方式。取巧,在
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
滚动未结束时,无法立刻进行缩放
场景重现和调试
- 在第一页快速滑动
- 惯性滑动到第二页(列表未停止),双指立即
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
通过日志我们可以发现:
-
ExtendedImageGesturePageView
的hittest
是通过。 - 没有发现
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;
}
}
将 RenderIgnorePointer
的 ignoring
设置为 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),
),
),
),
),
);
- 滚动结束
再将 RenderIgnorePointer
的 ignoring
设置为 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个 ScrollActivity
的 shouldIgnorePointer
设置成 false
即可。(稳妥一点其实 DrivenScrollActivity
我们可以不设置成 false
,但是图片浏览组件中,应该很少有人会去做动画效果,所以暂时都统一设置成 false
)。
另一条路
说实话,用上一个方式解决问题之后,我还是有一些担忧,毕竟,官方在列表滚动设置 shouldIgnorePointer
为 true
肯定有它的道理(尽管官方只举例了想保护动画不被用户操作终止,但其他情况我们还是未知的)。那么我们有没有其他方式来解决呢?
实际上,我们注意到,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
RenderSliverFixedExtentBoxAdaptor
的 performLayout
方法中,很容易看出来是根据 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)