Flutter 滑動體系

無若葉發表於2021-07-10

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 是指使用者所觀察到的,如果要用語言簡單區分,它們可以是:

  1. 在 applyPhysicsToUserOffset 中:使用者所觀察到的 overscroll,即需要展示給使用者的
  2. 在 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 規則,但是在一些函式上有了新的實現,從而勝任不同的目標,這些都值得去看。

相關文章