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;
-
focalPointDeltaScale中心的偏移量,阈值: 鼠标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, DragUpdateDetailsScaleEndDetails => 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。
为了找到真相,我们增加更多的日志
修改代码,打印没有符合的 _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
通过日志我们可以发现:
-
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) 即可。代码比较简单,感兴趣的可以查看。
需要注意的是,如果 Scale 的动作如果比较快,那么就有可能出现同时 Scale 两张图片的情况,毕竟是没法简单的区分出来当前需要 Scale 的图片。
最终我选择增加了一个参数 shouldIgnorePointerWhenScrolling 来控制到底使用哪种方式来处理这个问题。
PageView 支持间距
这个其实是参考了原生系统自带相册的功能,发现每个图片之间都会有一定的间隔,PageView 显然不支持这个。
[图片上传失败...(image-85dfca-1632709591046)]
看过 Sliver 系列的应该对于 Sliver 列表绘制的过程比较了解了。这个功能不难,下面提一下主要修改哪些地方。
RenderSliverFillViewport
PageView 的每一页宽度(水平)相当于 viewport 的宽度。( viewportFraction 自行百度)
@override
double get itemExtent =>
constraints.viewportMainAxisExtent * viewportFraction;
RenderSliverFixedExtentBoxAdaptor
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,这样就不好看了。
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 中的代码来实现滑动一整页的,直接到核心位置。
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小糖果[图片上传失败...(image-a24da6-1632709591046)]QQ群:181398081
最最后放上 Flutter Candies 全家桶,真香。
[图片上传失败...(image-cb2c4-1632709591046)]