前言
手势冲突一直是 Flutter
里面一个高频的问题。图片浏览组件,更是该问题的重灾区。
extended_image | Flutter Package (flutter-io.cn) 支持缩放拖拽图片,图片浏览(微信掘金效果),滑动退出页面(微信掘金效果),编辑图片(裁剪旋转翻转),也避免不了手势的问题。
as design ,我是从上一家外企里面学到的词汇。每次有人提到手势冲突的问题,因为懒和认知不足,也都习惯性地回复 as design 。
但是原生都可以解决,难道 Flutter 就不行吗?当然不是了,只是我们对
Flutter
还不够了解。
为了跟原生的体验更加接近,需要解决下面的几个问题:
- 对缩放手势和水平/垂直手势判断不准确
- 放大状态,缩放手势和水平/垂直手势不能无缝切换
PageView
滚动未结束时,无法立刻进行缩放PageView
支持间距
老惯例,先放图,后放代码,小姐姐镇楼。
接着上一期挑战 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 是什么时候加入的竞技场呢?
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
的时候的日志。
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
的监听。
@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
掉。关键代码和堆栈信息如下:
-
我的第一反应是,写一个
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
。
为了找到真相,我们增加更多的日志
修改代码,打印没有符合的 _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
了。我们再思考一下这个场景的一个条件,那就是滚动未停止
,是不是这个里面有点门道?
其实我在讲解 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);
- 滚动开始
@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
事件了。
解决问题
试试改源码
首先我们是不大可能去修改 Scrollable
的源码的,涉及的代码太多。我们可以尝试从
ScrollPositionWithSingleContext( ScrollPostion )
的源码去尝试。从堆栈信息来看,ScrollActivity.shouldIgnorePointer
是关键。而继承 ScrollActivity
的类有以下
类名 | 解释 | 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
显然不支持这个。
看过 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小糖果
最最后放上 Flutter Candies 全家桶,真香。