ScrollView
可滑動檢視的父類,ListView,CustomScrollView 和 GridView 都是它的子類,它們通過實現 buildSlivers 函式為 ScrollView 提供子檢視,同時將 ScrollController,ScrollPhysics,ViewportBuilder 和 children 等傳遞給 Scrollable。
在 ScrollView 的 build 中,一些 ScrollView 的引數,如 dragStartBehavior,controller 以及 buildSlivers 這些函式,會用來生成一個 Scrollable,它對 ScrollView 的一些東西進行收攏(比如 ScrollView 不同子類的實現),然後專注於實現滑動這一功能。
Viewport
Viewport 負責計算 ScrollView 的大小,一般有兩種,ShrinkWrappingViewport 和 Viewport,它們的區別在於 ScrollView 大小的計算方式,Viewport 在 performResize 階段就可以確定自己的大小,即父 widget 提供的最大空間,而 ShrinkWrappingViewport 要在 performLayout 階段才能確定,因為它的大小依賴於自己的子 widget,需要先統計子 view 的大小,再確定自身的大小。
所以,當我們使用 ScrollView 時,一般我們需要給它一定有限大小的 constraints,它才能正確計算自己的大小,當我們無法提供這樣一個環境,就可以將它的 shrinkWrap 設定為 true,這樣它會給自己計算一個合適的大小。
ScrollConfiguration
這是一個 InheritedWidget,它的作用是給 Scrollable 傳遞 ScrollBehavior,而它的確定,在很早之前就確定了,且一般一個 app 只有一個(自己單獨宣告使用的另算),比如在 _CupertinoAppState 中:
Widget build(BuildContext context) {
final CupertinoThemeData effectiveThemeData = widget.theme ?? const CupertinoThemeData();
return ScrollConfiguration(
behavior: _AlwaysCupertinoScrollBehavior(),
child: CupertinoUserInterfaceLevel(
data: CupertinoUserInterfaceLevelData.base,
child: CupertinoTheme(
data: effectiveThemeData,
child: HeroControllerScope(
controller: _heroController,
child: Builder(
builder: _buildWidgetApp,
),
),
),
),
);
}
複製程式碼
給整個 app 指定了一個全域性的 ScrollBehavior _AlwaysCupertinoScrollBehavior,這個會在 Scrollable 執行滑動的時候用到。
ScrollBehavior
ScrollBehavior 本身在 flutter 的設計中是一個平臺相關的 Widget,它會根據當前的平臺,選擇一個合適的 ScrollPhysics,如下:
ScrollPhysics getScrollPhysics(BuildContext context) {
switch (getPlatform(context)) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return _bouncingPhysics;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return _clampingPhysics;
}
}
複製程式碼
而 ScrollPhysics 的定位,可以從名字上理解,控制滑動過程的物理特性,定義瞭如當滑動到頂部的時候的表現、滑過頭了之後的回彈方式等。
Scrollable
ScrollView 中 build 主要返回的 widget,一個通用的滑動模型,它是滑動功能的載體,與 ScrollController、ScrollPhysics 一起實現了一個可滑動的控制元件。而它也只算是一個載體,一箇中介,它最主要的作用,就是利用身為 widget 的優勢,從整個檢視體系中拿到觸控事件,而剩下的功能,交給其他人就好。
Viewport 負責決定滑動檢視的大小,ScrollPosition 決定滑動的位置,ScrollPhysics 決定滑動的物理屬性,ScrollController 可以支援外部使用者控制滑動過程。還有其他的一些,比如 ScrollActivity 表示了滑動過程中的某一階段等。在這樣一個體系中,ScrollPosition 更像是一個 controller,它直接從 Scrollable 中拿到未處理的觸控事件,根據事件型別計算出自己當前的狀態。
Scrollable 對應的 state 為 ScrollableState,在它的 build 中,返回的 child 中,有 Listener、RawGestureDetector 和 Viewport 等,Listener 用於監聽 PointerScrollEvent 事件,一般這個事件應該是在滾動滑動條時觸發的,此時它會計算出一個滑動位置,並直接呼叫 ScrollPosition.jumpTo 滾動到對應位置。
而 RawGestureDetector 會監聽一些滑動手勢,比如 dragDown、dragStart 等,ScrollPosition 根據這些手勢資訊更新狀態,計算滑動。
Viewport 就是在 ScrollView 中生成的。
ScrollController
ScrollController 可以從 ScrollView 中設定,並一路傳遞到 Scrollable 中,它雖然名為 controller,但並不是一個 center controller,而是一個 user controller,即給使用者提供控制滑動狀態的一種途徑,但本身在滑動體系中作用不大,只是一個將使用者的命令傳達到 ScrollPosition 中的角色,不過它還有一點權利,就是可能決定建立的 ScrollPosition,當然這個最終還是將權利傳遞到外部使用者的手上而已。
它有諸如 adjumpTo、animateTo 等函式,通過呼叫 position 的同名函式實現。另外,ScrollController 可以繫結多個 ScrollPosition,可以據此實現多個檢視同步滑動的能力。
ScrollPosition
ScrollPosition 承擔著滑動過程中的主要責任,上承 ScrollController,Scrollable,下啟 ScrollActivity、ScrollPhysics 等。
首先,ScrollPosition 是 ViewportOffset 的一個子類,這是一個 widget 向概念,它表示的是 Viewport 這個 widget 的偏移量,由於 Viewport 本身就是用於承載滑動檢視的 widget,在很多情況下,它的 children 的整體長度要大於它自身,所以就需要有一個 offset 屬性,控制當前應該顯示的內容。另外,ViewportOffset 中也通過 applyViewportDimension 等函式,接收來自 widget 的資訊,及時根據當前的佈局,更改顯示內容。
其次,ScrollPosition 作為接收觸控事件者,它還完成了對觸控事件的分發功能,以及進一步,將處理過的觸控事件轉換成檢視滑動(其中有一些複雜操作,比如需要考慮檢視滑動範圍、滑動物理屬性等),最終檢視更新。
舉個例子,當一個滑動事件發生時,它會建立一個Drag 處理後續的滑動事件,Drag 後續對原始的滑動事件進行第一次加工之後,再給到 ScrollPosition,然後 ScrollPosition 還會再將這個資料拿給 ScrollPhysics 進行一些類似邊界問題的判斷,完了之後,將最終結果給到 ViewportOffset 的 pixels 屬性,最後通知 Viewport 進行重新 layout,由此完成一次滑動。更具體的流程,在最後詳細說明。
ScrollPyhsics
ScrollPhysic 描述的是一個滑動檢視,也就是 Viewport 的內容,在執行滑動過程中的一些物理屬性,比如是否可以 overscroll,在一個給定的 ScrollMetrics 和理論偏移值下計算一個實際的偏移值等。再看下它的一些成員變數:
-
spring,SpringDescription,描述了滑動的一些物理特性,會在建立 Simulation 時傳遞過去
-
tolerance,Tolerance,定義了一些可忽略的距離、速度、時間等
-
flingDistance,定義了最小的可被認定為 fling 手勢的距離
-
flingVelocity,定義了最小的可被認定為 fling 手勢的速度,和最大的 fling 速度
-
dragStartDistanceMotionThreshold,定義了開始滑動時,可被認定為是滑動手勢的最小距離
-
allowImplicitScrolling,這是一個來自 ViewportOffset 的變數
ScrollActivity
ScrollActivity 可以表示滑動過程中的一個階段,只是記錄了當前的狀態,比如是否是滑動中、當前的滑動速度等。它的幾個基本引數分別為:
- delegate,ScrollActivityDelegate,有著更新滑動位置的實現,一般就是 ScrollPosition 及其子類
- shouldIgnorePointer,是否忽略觸控事件,這裡的主體,是 Scrollable 的子 widget,也就是 Viewport,而在 Scrollable 中用於接收手勢滑動的 RawGestureDetector 在它之上,也就是說,這個引數並不是控制是否檢測滑動手勢,而是待滑動的內容是否可以接收事件,所以,在眾多 ScrollActivity 中,只有 HoldScrollActivity 和 IdleScrollActivity 中它的值才為 true
- isScrolling,當前是否處於滑動狀態
- velocity,如果是滑動狀態,當前的滑動速度,當然,這個值也只有在 BallisticScrollActivity 和 DrivenScrollActivity 中才不為 0
它大致可以分為兩種型別,滑動和不滑動。
non-scroll
其中表示不滑動的有兩個 ScrollActivity,HoldScrollActivity 和 IdleScrollActivity。
HoldScrollActivity
HoldScrollActivity 會在手指按下的瞬間生成,它有表示一種蓄勢待發的狀態,是為了下一刻的滑動,所以在啟動 HoldScrollActivity 的時候,會儲存下來當前的滑動速度,然後在開始滑動時,會在一個初始速度上接著滑動。
ScrollHoldController hold(VoidCallback holdCancelCallback) {
final double previousVelocity = activity!.velocity;
// ...
}
複製程式碼
Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
final ScrollDragController drag = ScrollDragController(
delegate: this,
details: details,
onDragCanceled: dragCancelCallback,
carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity),
motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold,
);
// ...
}
複製程式碼
IdleScrollActivity
而 IdleScrollActivity 只是表示這是一個靜止狀態,此時 ScrollPosition 不進行滑動,也基本不處理事件。不過換句話說,ScrollPosition 也只是處理兩種事件,在 dragDown 時將狀態切換至 HoldScrollActivity,當 dargStart 時,生成 Drag 並將狀態切換至 DragScrollActivity,至於 dragUpdate 事件,則是直接交給 Drag 來處理的。
scroll
表示滑動狀態的 ScrollActivity 有三種,分別是事件驅動、速度驅動和動畫驅動。
driven by draging - DragScrollActivity
所謂事件驅動,就是滑動過程是根據外部傳進來的滑動事件,來決定是否以及如何更新檢視。這個就是在基本的滑動過程中,ScrollPosition 接收到 dragStart 事件時,進入的滑動狀態,與之關聯的 ScrollDragController,它會被傳遞迴 Scrollable,在 dragUpdate 事件到來時直接處理事件。
void _handleDragStart(DragStartDetails details) {
// It's possible for _hold to become null between _handleDragDown and
// _handleDragStart, for example if some user code calls jumpTo or otherwise
// triggers a new activity to begin.
assert(_drag == null);
_drag = position.drag(details, _disposeDrag);
assert(_drag != null);
assert(_hold == null);
}
複製程式碼
void _handleDragUpdate(DragUpdateDetails details) {
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_drag?.update(details);
}
複製程式碼
driven by velocity - BallisticScrollActivity
當 drag 系列事件結束後,會留下一個滑動速度,此時滑動並不會停止,而是在基於這個速度下,做減速滑動,直到速度為 0,或者滑動到邊界,這個階段,對應的就是 BallisticScrollActivity。
deiven by animation - DrivenScrollActivity
當我們直接通過 ScrollController 控制 Scrollable 進行滑動時,一般就是呼叫 animateTo,會建立一個 DrivenScrollActivity,根據當前給出的 duration、curve 等,建立一個動畫並執行。
Simulation
在 BallisticScrollActivity 執行過程中,用於決定滑動位置的就是 Simulation,
void goBallistic(double velocity) {
assert(hasPixels);
final Simulation? simulation = physics.createBallisticSimulation(this, velocity);
if (simulation != null) {
beginActivity(BallisticScrollActivity(this, simulation, context.vsync));
} else {
goIdle();
}
}
複製程式碼
Simulation 由 ScrollPhysics 建立,在一定程度上是平臺相關的,本身也算是 ScrollPhysics 功能組成的一部分,主要是用於控制拖拽滑動結束後的過程,比如在 ios 中預設使用的 BouncingScrollPhysics 會建立一個 BouncingScrollSimulation,建立 BouncingScrollSimulation 的時候,給了它一個初速度、滑動範圍等,然後就由它來確定滑動的距離以及停止的時間。
完整的滑動過程
下面從一次完整的滑動過程再次分析下 flutter 中 Scrollable 的滑動體系,以 ScrollPositionWithSingleContext 和 BouncingScrollPhysics 為例。
drag
首先,當 Scrollable 建立完成之後,它會利用 RawGestureDetector 監聽當前的手勢操作,主要監聽的操作就是 drag 事件相關的,比如 dragDown、dragStart、dragUpdat、dragCancel 等,在這些過程中,主要涉及的只有 DragScrollActivity 這個。
onDown
onDown 用於處理 dragDown 事件,
void _handleDragDown(DragDownDetails details) {
assert(_drag == null);
assert(_hold == null);
_hold = position.hold(_disposeHold);
}
複製程式碼
很簡單,這裡只是呼叫 ScrollPosition 的 hold,建立一個 HoldScrollActivity,如上所介紹的,為下一步的滑動作準備。這裡有一點,就是在建立 HoldScrollActivity 的時候,同時傳進去了一個 dispose 回撥,在這個回撥中,會將 _hold 置空,當然這裡的考慮並不是釋放空間這麼簡單,_hold 本身還是一種狀態,當它不為空的時候,就意味著當前處於 HoldScrollActivity 所管轄的狀態,_drag 也是同理,會有 assert 對當前的狀態進行判斷。
onStart
onStart 處理 dragStart 事件,
void _handleDragStart(DragStartDetails details) {
// It's possible for _hold to become null between _handleDragDown and
// _handleDragStart, for example if some user code calls jumpTo or otherwise
// triggers a new activity to begin.
assert(_drag == null);
_drag = position.drag(details, _disposeDrag);
assert(_drag != null);
assert(_hold == null);
}
複製程式碼
跟 onDown 類似,此時建立了 _drag,在 position.drag 中,
Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
final ScrollDragController drag = ScrollDragController(
delegate: this,
details: details,
onDragCanceled: dragCancelCallback,
carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity),
motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold,
);
beginActivity(DragScrollActivity(this, drag));
assert(_currentDrag == null);
_currentDrag = drag;
return drag;
}
複製程式碼
ScrollDragController 會被建立並進入到 DragScrollActivity 狀態。在 ScrollPosition 中,每次啟用一個 ScrollActivity 時,都是使用 beginActivity 進行狀態切換的。
void beginActivity(ScrollActivity? newActivity) {
if (newActivity == null)
return;
bool wasScrolling, oldIgnorePointer;
if (_activity != null) {
oldIgnorePointer = _activity!.shouldIgnorePointer;
wasScrolling = _activity!.isScrolling;
if (wasScrolling && !newActivity.isScrolling)
didEndScroll(); // notifies and then saves the scroll offset
_activity!.dispose();
} else {
oldIgnorePointer = false;
wasScrolling = false;
}
_activity = newActivity;
if (oldIgnorePointer != activity!.shouldIgnorePointer)
context.setIgnorePointer(activity!.shouldIgnorePointer);
isScrollingNotifier.value = activity!.isScrolling;
if (!wasScrolling && _activity!.isScrolling)
didStartScroll();
}
複製程式碼
一個比較常規的切換邏輯,有一點就是,在 beginActivity 執行時,會判斷一下切換前後的滑動狀態和是否可以接收事件,併產生相應的通知。
比如 didStartScroll,
void didStartScroll() {
activity!.dispatchScrollStartNotification(copyWith(), context.notificationContext);
}
void dispatchScrollStartNotification(ScrollMetrics metrics, BuildContext? context) {
ScrollStartNotification(metrics: metrics, context: context).dispatch(context);
}
void dispatch(BuildContext? target) {
// The `target` may be null if the subtree the notification is supposed to be
// dispatched in is in the process of being disposed.
target?.visitAncestorElements(visitAncestor);
}
bool visitAncestor(Element element) {
if (element is StatelessElement) {
final StatelessWidget widget = element.widget;
if (widget is NotificationListener<Notification>) {
if (widget._dispatch(this, element)) // that function checks the type dynamically
return false;
}
}
return true;
}
複製程式碼
這個過程會建立一個 ScrollStartNotification,並沿著 widget 樹向上傳遞,通過 visitAncestor,傳遞給上層第一個接收消費掉此通知的 NotificationListener。(只有當 NotificationListener 的 NotificationListenerCallback 返回 true 才是消費此通知,否則通知會一直向上傳遞)
onUpdate
onUpdate 會處理 dragUpdate 事件,也就是手指滑動時的事件,此時這個事件會直接交給 ScrollDragController 處理,
void _handleDragUpdate(DragUpdateDetails details) {
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_drag?.update(details);
}
void update(DragUpdateDetails details) {
assert(details.primaryDelta != null);
_lastDetails = details;
double offset = details.primaryDelta!;
if (offset != 0.0) {
_lastNonStationaryTimestamp = details.sourceTimeStamp;
}
// By default, iOS platforms carries momentum and has a start threshold
// (configured in [BouncingScrollPhysics]). The 2 operations below are
// no-ops on Android.
_maybeLoseMomentum(offset, details.sourceTimeStamp);
offset = _adjustForScrollStartThreshold(offset, details.sourceTimeStamp);
if (offset == 0.0) {
return;
}
if (_reversed) // e.g. an AxisDirection.up scrollable
offset = -offset;
delegate.applyUserOffset(offset);
}
複製程式碼
在 update 中,儲存下來的 _lastDetails 是為了在之後傳送通知的時候,加上這個滑動事件資訊,比如 dispatchScrollUpdateNotification,
void dispatchScrollUpdateNotification(ScrollMetrics metrics, BuildContext context, double scrollDelta) {
final dynamic lastDetails = _controller!.lastDetails;
assert(lastDetails is DragUpdateDetails);
ScrollUpdateNotification(metrics: metrics, context: context, scrollDelta: scrollDelta, dragDetails: lastDetails as DragUpdateDetails).dispatch(context);
}
複製程式碼
然後就是一個關於是否損失動量的判斷,
void _maybeLoseMomentum(double offset, Duration? timestamp) {
if (_retainMomentum &&
offset == 0.0 &&
(timestamp == null || // If drag event has no timestamp, we lose momentum.
timestamp - _lastNonStationaryTimestamp! > momentumRetainStationaryDurationThreshold)) {
// If pointer is stationary for too long, we lose momentum.
_retainMomentum = false;
}
}
複製程式碼
這個過程的目的,是為了判斷是否損失動量,我們知道,一般在 ios 的滑動中,連續快速滑動的時候,速度是會積累的,所以後面會越滑越快,而 flutter 為了保持這一特性,就有了動量積累這樣一個功能,目前也只在 BouncingScrollPhysics 中才有。關於這個就要從 HoldScrollActivity 開始說起,之前 HoldScrollActivity 有提到,當 dragDown 事件發生時,ScrollPosition 會記錄下當前的滑動速度(如果當前還在滑動中),然後在 dragStart 時,將之前的滑動速度傳遞給 ScrollDragController,不過需要經過 ScrollPhysics 再過濾,而只有 BouncingScrollPhysics 才會提供這個初速度,不過也是經過計算的:
double carriedMomentum(double existingVelocity) {
return existingVelocity.sign *
math.min(0.000816 * math.pow(existingVelocity.abs(), 1.967).toDouble(), 40000.0);
}
複製程式碼
然後就是在 ScrollDragController 結束時,它會在滑動速度的基礎上,再把初速度加上去,構成滑動後的速度。而在滑動後是否加上初速度也是需要判斷的,就是通過 _maybeLoseMomentum,如果是滑動太慢或者有懸停的話,就認為這不能進行動量積累,也就不會把初速度再加上去。
然後,_adjustForScrollStartThreshold 會就開始滑動的距離做些處理,大體就是,當本次滑動距離超過某個閾值的時候,才真正開始滑動,否則就當作誤差忽略掉。當然這個邏輯也是可以通過 ScrollPhysics 控制的,就是它的 dragStartDistanceMotionThreshold,目前也只是 BouncingScrollPhysics 才有。
double _adjustForScrollStartThreshold(double offset, Duration? timestamp) {
if (timestamp == null) {
// If we can't track time, we can't apply thresholds.
// May be null for proxied drags like via accessibility.
return offset;
}
if (offset == 0.0) {
if (motionStartDistanceThreshold != null &&
_offsetSinceLastStop == null &&
timestamp - _lastNonStationaryTimestamp! > motionStoppedDurationThreshold) {
// Enforce a new threshold.
_offsetSinceLastStop = 0.0;
}
// Not moving can't break threshold.
return 0.0;
} else {
if (_offsetSinceLastStop == null) {
// Already in motion or no threshold behavior configured such as for
// Android. Allow transparent offset transmission.
return offset;
} else {
_offsetSinceLastStop = _offsetSinceLastStop! + offset;
if (_offsetSinceLastStop!.abs() > motionStartDistanceThreshold!) {
// Threshold broken.
_offsetSinceLastStop = null;
if (offset.abs() > _bigThresholdBreakDistance) {
// This is heuristically a very deliberate fling. Leave the motion
// unaffected.
return offset;
} else {
// This is a normal speed threshold break.
return math.min(
// Ease into the motion when the threshold is initially broken
// to avoid a visible jump.
motionStartDistanceThreshold! / 3.0,
offset.abs(),
) * offset.sign;
}
} else {
return 0.0;
}
}
}
}
複製程式碼
在這個函式中,大體分為了幾種判斷標準:
- 有沒有時間資訊
- 滑動距離為不為 0
- 是否有 motionStartDistanceThreshold
當沒有時間資訊和 motionStartDistanceThreshold 沒有值的時候,這個函式可以認為不工作狀態,都是直接返回原滑動距離就完了。主要還是看 motionStartDistanceThreshold 不為空的情況。
首先,當已經開始滑動,但滑動過程中有懸停時,_offsetSinceLastStop 會歸零,重新開始計算。當開始滑動時,會逐漸積累 _offsetSinceLastStop,這個過程中不會有實際滑動,直到它大於 motionStartDistanceThreshold 時,閾值到達,此時 _offsetSinceLastStop 置空,開始實際滑動。
不過這還只是從 ScrollDragController 的角度,認為可以滑動的距離,但真正反饋到 Viewport 之前,ScrollPhysics 也要來表現一下,
void applyUserOffset(double delta) {
updateUserScrollDirection(delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse);
setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta));
}
複製程式碼
首先就是 applyPhysicsToUserOffset,也是隻有 BouncingScrollPhysics 有實現,原因是因為它是一個允許 overscroll 的 ScrollPhysics,在這個函式中,主要是就 overscroll 的情況,通過改變實際移動的距離,新增一種類似“阻力”的概念,即在 overscroll 狀態下,實際的滑動距離要小於手勢滑動距離。
double setPixels(double newPixels) {
if (newPixels != pixels) {
final double overscroll = applyBoundaryConditions(newPixels);
final double oldPixels = pixels;
_pixels = newPixels - overscroll;
if (_pixels != oldPixels) {
notifyListeners();
didUpdateScrollPositionBy(pixels - oldPixels);
}
if (overscroll != 0.0) {
didOverscrollBy(overscroll);
return overscroll;
}
}
return 0.0;
}
double applyBoundaryConditions(double value) {
final double result = physics.applyBoundaryConditions(this, value);
return result;
}
複製程式碼
然後在 setPixels 中,繼續呼叫 ScrollPhysics 的 applyBoundaryConditions 判斷當前的 overscroll,當然這裡的 overscroll 與上面 applyPhysicsToUserOffset 中所說的不太一樣,上面說的 overscroll 是指使用者所觀察到的,如果要用語言簡單區分,它們可以是:
- 在 applyPhysicsToUserOffset 中:使用者所觀察到的 overscroll,即需要展示給使用者的
- 在 applyBoundaryConditions 中:ScrollPosition 所觀察到的 overscroll,是不能讓使用者看到的
所以可以看到,只有 ClampingScrollPhysics 對其有實現,而因為 BouncingScrollPhysics 是始終可以滑動的狀態(通過阻力表達滑動到邊界),所以它在這裡的 overscroll 是始終為 0。
完了之後才能得到真正的需要偏移的數值,此時一次 dragUpdate 完成。
onEnd
接下來就是 dragEnd,滑動手勢結束的時候觸發。
void end(DragEndDetails details) {
assert(details.primaryVelocity != null);
// We negate the velocity here because if the touch is moving downwards,
// the scroll has to move upwards. It's the same reason that update()
// above negates the delta before applying it to the scroll offset.
double velocity = -details.primaryVelocity!;
if (_reversed) // e.g. an AxisDirection.up scrollable
velocity = -velocity;
_lastDetails = details;
// Build momentum only if dragging in the same direction.
if (_retainMomentum && velocity.sign == carriedVelocity!.sign)
velocity += carriedVelocity!;
delegate.goBallistic(velocity);
}
複製程式碼
此時就是計算一下當前的滑動速度,以便後面進入 BallisticScrollActivity 階段,也就是 goBallistic 呼叫。
void goBallistic(double velocity) {
assert(hasPixels);
final Simulation? simulation = physics.createBallisticSimulation(this, velocity);
if (simulation != null) {
beginActivity(BallisticScrollActivity(this, simulation, context.vsync));
} else {
goIdle();
}
}
BallisticScrollActivity(
ScrollActivityDelegate delegate,
Simulation simulation,
TickerProvider vsync,
) : super(delegate) {
_controller = AnimationController.unbounded(
debugLabel: kDebugMode ? objectRuntimeType(this, 'BallisticScrollActivity') : null,
vsync: vsync,
)
..addListener(_tick)
..animateWith(simulation)
.whenComplete(_end); // won't trigger if we dispose _controller first
}
複製程式碼
函式本身很簡單,首先通過 ScrollPhysics 建立一個 Simulation,然後將其傳給 BallisticScrollActivity。從 BallisticScrollActivity 的建構函式可以看到,本質上我們也可以將其看作是一個由動畫驅動的滑動過程,只不過這個動畫是根據一個給定的初始速度建立的。
BallisticScrollActivity 與 DrivenScrollActivity 的相似度很高,它們都是在建構函式中先根據提供的資訊(simulation,duration、curve等)建立一個 AnimationController,然後監聽更新和結束事件,在 _tick 中更新偏移值,在 _end 中結束自己。
ballistic
當滑動手勢結束時,遠不意味著整個滑動的結束,為了使用者體驗,我們賦予滑動速度的概念,那它的滑動也就有動量,所以停止不能只是戛然而止,需要一個慢慢停下來的過程,所以就有了 BallisticScrollActivity 所代表的減速過程,而這個過程的主要控制者,實際為 ScrollPhysics 所生成的 Simulation,不同的 Simulation 相距甚遠。這裡就以較為複雜的 BouncingScrollSimulation 為例說明。
BouncingScrollSimulation({
required double position,
required double velocity,
required this.leadingExtent,
required this.trailingExtent,
required this.spring,
Tolerance tolerance = Tolerance.defaultTolerance,
}) : assert(position != null),
assert(velocity != null),
assert(leadingExtent != null),
assert(trailingExtent != null),
assert(leadingExtent <= trailingExtent),
assert(spring != null),
super(tolerance: tolerance) {
if (position < leadingExtent) {
_springSimulation = _underscrollSimulation(position, velocity);
_springTime = double.negativeInfinity;
} else if (position > trailingExtent) {
_springSimulation = _overscrollSimulation(position, velocity);
_springTime = double.negativeInfinity;
} else {
// Taken from UIScrollView.decelerationRate (.normal = 0.998)
// 0.998^1000 = ~0.135
_frictionSimulation = FrictionSimulation(0.135, position, velocity);
final double finalX = _frictionSimulation.finalX;
if (velocity > 0.0 && finalX > trailingExtent) {
_springTime = _frictionSimulation.timeAtX(trailingExtent);
_springSimulation = _overscrollSimulation(
trailingExtent,
math.min(_frictionSimulation.dx(_springTime), maxSpringTransferVelocity),
);
assert(_springTime.isFinite);
} else if (velocity < 0.0 && finalX < leadingExtent) {
_springTime = _frictionSimulation.timeAtX(leadingExtent);
_springSimulation = _underscrollSimulation(
leadingExtent,
math.min(_frictionSimulation.dx(_springTime), maxSpringTransferVelocity),
);
assert(_springTime.isFinite);
} else {
_springTime = double.infinity;
}
}
assert(_springTime != null);
}
複製程式碼
首先看它的建構函式,從引數來看,有滑動速度、當前位置、滑動範圍和 spring 資訊(質量、剛度、摩擦等),然後,在初始化的時候,又分三種情況,underscroll、overscroll 和其他。三種情況對應的三種不同的滑動方式。
首先,整體來說,在 BouncingScrollSimulation 中滑動也是分階段的,因為它對應的 BouncingScrollPhysics 是一個允許 overscroll 的 ScrollPhysics,所以這就導致它的減速過程也變得複雜,需要考慮是否是 overscroll 狀態下的減速,以及減速過程中是否會產生 overscroll。所以在 BouncingScrollSimulation 中,_springSimulation 負責由 overscroll 狀態下回滾到邊界的過程,_frictionSimulation 才是負責減速過程。
下面就直接看先減速後回彈的情況,首先,是根據當前速度建立 _frictionSimulation 並判斷它是否會 overscroll,如果會,就再計算到達邊界的時間,然後再約過邊界的瞬間,啟用 _springSimulation。而 _springTime 就是區分的中界線,
double x(double time) => _simulation(time).x(time - _timeOffset);
Simulation _simulation(double time) {
final Simulation simulation;
if (time > _springTime) {
_timeOffset = _springTime.isFinite ? _springTime : 0.0;
simulation = _springSimulation;
} else {
_timeOffset = 0.0;
simulation = _frictionSimulation;
}
return simulation..tolerance = tolerance;
}
複製程式碼
對於任何一個函式,它都是需要通過 _simulation 先拿到當前使用的 Simulation 再計算。從這個角度上看,BouncingScrollSimulation 只是一個代理,它的所有實現都是通過 _springSimulation 和 _frictionSimulation 完成的。
直到動畫結束,一個完整的滑動過程也基本結束了。
擴充套件
上面介紹的還只是滑動體系的一部分,除此之外,還有更多不同的 ScrollPhysics,不同的 ScrollPosition,當然基本的邏輯都是如此。
不同的 ScrollPhysics 代表著不同的滑動方式,比如 NeverScrollableScrollPhysics,表示不可滑動,比如 PageScrollPhysics,將滑動固定在頁與頁之間。又比如 _NestedScrollPosition,專門用於控制 NestedScrollView 中,多層 view 同時滑動的邏輯,比如 _PagePosition,為 PageView 細化了一些滑動規則等等,這些都是基於當前所描述的 ScrollPosition 規則,但是在一些函式上有了新的實現,從而勝任不同的目標,這些都值得去看。