Flutter完整開發實戰詳解(十8、 神奇的ScrollPhysics與Simulation)

做爲系列文章的第十八篇,本篇將經過 ScrollPhysics 和 Simulation ,帶你深刻走進 Flutter 的滑動新世界,爲你打開 Flutter 滑動操做的另外一扇窗。android

前文:git

1、前言

以下圖所示,Flutter 默認的可滑動 Widget,在 Android 和 iOS 上具有不一樣的 滑動與邊緣拖拽效果 ,這是由於在不一樣平臺上,默認使用了不一樣的 ScrollPhysicsSimulation ,後面咱們將逐步介紹這兩大主角的實現原理,最終讓你對 Flutter 世界的滑動拖拽進階到 「隨心所欲」 的境界。github

下方開始高能幹貨,請自帶茶水食用。算法

2、 ScrollPhysics

首先介紹 ScrollPhysics ,在 Flutter 官方的介紹中,ScrollPhysics 的做用是 肯定可滾動控件的物理特性, 常見的有如下四大金剛:spring

  • BouncingScrollPhysics :容許滾動超出邊界,但以後內容會反彈回來。
  • ClampingScrollPhysics : 防止滾動超出邊界,夾住
  • AlwaysScrollableScrollPhysics :始終響應用戶的滾動。
  • NeverScrollableScrollPhysics不響應用戶的滾動。

在開發過程當中,通常會經過以下代碼進行設置:bash

CustomScrollView(physics: const BouncingScrollPhysics())
 ListView.builder(physics: const AlwaysScrollableScrollPhysics())
 GridView.count(physics: NeverScrollableScrollPhysics())
複製代碼

但在通常咱們都不會主動去設置 physics 屬性, 那麼默認狀況下,爲何在 Flutter 中的 ListViewCustomScrollViewScrollable 控件,在 Android 和 iOS 平臺的滾動和邊界拖拽效果,會有以下圖所示的平臺區別呢?app

這裏的關鍵就在於 ScrollConfigurationScrollBehaviorless

2.一、ScrollConfiguration 和 ScrollBehavior

咱們知道,全部的滑動控件都是經過 Scrollable 進行滑動的。ide

以下代碼所示,在 Scrollable 內的 _updatePosition 方法裏,當 widget.physics == null 時,_physics 默認是從 ScrollConfiguration.of(context)getScrollPhysics(context) 方法獲取 ,而 ScrollConfiguration.of(context) 返回的是一個 ScrollBehavior 對象。佈局

// Only call this from places that will definitely trigger a rebuild.
  void _updatePosition() {
    _configuration = ScrollConfiguration.of(context);
    _physics = _configuration.getScrollPhysics(context);
    if (widget.physics != null)
      _physics = widget.physics.applyTo(_physics);
    final ScrollController controller = widget.controller;
    final ScrollPosition oldPosition = position;
    if (oldPosition != null) {
      controller?.detach(oldPosition);
      scheduleMicrotask(oldPosition.dispose);
    }
    _position = controller?.createScrollPosition(_physics, this, oldPosition)
      ?? ScrollPositionWithSingleContext(physics: _physics, context: this, oldPosition: oldPosition);
    assert(position != null);
    controller?.attach(position);
  }
複製代碼

因此默認狀況下 ,ScrollPhysics 是和 ScrollConfigurationScrollBehavior 有關係。

那麼 ScrollBehavior 是這麼工做的?

查看 ScrollBehavior 的源碼可知,它的 getScrollPhysics 方法中,默認實現了平臺返回了不一樣的 ScrollPhysics ,因此默認狀況下,在不一樣平臺上的滾動和邊緣推拽,會出現不同的效果:

ScrollPhysics getScrollPhysics(BuildContext context) {
    switch (getPlatform(context)) {
      case TargetPlatform.iOS:
        return const BouncingScrollPhysics();
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
        return const ClampingScrollPhysics();
    }
    return null;
  }
複製代碼

前面說過, ScrollPhysics 是肯定可滾動控件的物理特性 ,那麼如前圖所示,Android 平臺上拖拽溢出的藍色半圓的怎麼來的?ScrollConfigurationScrollBehavior 是在何時被設置的?

查看 ScrollConfiguration 的源碼咱們得知, ScrollConfigurationThemeLocalizations 等同樣是 InheritedWidget,那麼它應該是從上層往下共享的。

因此查看 MaterialApp 的源碼,獲得以下代碼,能夠看到 ScrollConfiguration 是在 MaterialApp 內默認嵌套的,而且經過 _MaterialScrollBehavior 設置了 ScrollBehavior, 其 override 的buildViewportChrome 方法,就是實現了Android 上溢出拖拽的半圓效果, 其中 GlowingOverscrollIndicator 就是半圓效果的繪製控件。

@override
Widget build(BuildContext context) {
   ····
    return ScrollConfiguration(
      behavior: _MaterialScrollBehavior(),
      child: result,
    );
}
class _MaterialScrollBehavior extends ScrollBehavior {
  @override
  TargetPlatform getPlatform(BuildContext context) {
    return Theme.of(context).platform;
  }
  @override
  Widget buildViewportChrome(BuildContext context, Widget child, AxisDirection axisDirection) {
    switch (getPlatform(context)) {
      case TargetPlatform.iOS:
        return child;
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
        return GlowingOverscrollIndicator(
          child: child,
          axisDirection: axisDirection,
          color: Theme.of(context).accentColor,
        );
    }
    return null;
  }
}
複製代碼

到這裏咱們就知道了,在默認狀況下可滑動控件的 ScrollPhysics 是如何配置的:

  • 一、ScrollConfiguration 是一個 InheritedWidget
  • 二、MaterialApp 內部利用 ScrollConfiguration 並共享了一個 ScrollBehavior 的子類 _MaterialScrollBehavior
  • 三、ScrollBehavior 默認根據平臺返回了特定的 BouncingScrollPhysicsClampingScrollPhysics 效果。
  • 四、_MaterialScrollBehavior 中針對 Android 平臺實現了 buildViewportChrome 的藍色半球拖拽溢出效果。

ps :咱們能夠經過實現本身的 ScrollBehavior , 實現自定義的拖拽溢出效果。

3、ScrollPhysics 工做原理

那麼 ScrollPhysics 是怎麼實現滾動和邊緣拖拽的呢? ScrollPhysics 默認是沒有什麼代碼邏輯的,它的主要定義方法以下所示:

/// [position] 當前的位置, [offset] 用戶拖拽距離
/// 將用戶拖拽距離 offset 轉爲須要移動的 pixels
double applyPhysicsToUserOffset(ScrollMetrics position, double offset)

/// 返回 overscroll ,若是返回 0 ,overscroll 就一直是0
/// 返回邊界條件
double applyBoundaryConditions(ScrollMetrics position, double value)

///建立一個滾動的模擬器
Simulation createBallisticSimulation(ScrollMetrics position, double velocity)  

///最小滾動數據
 double get minFlingVelocity

///傳輸動量,返回重複滾動時的速度
double carriedMomentum(double existingVelocity)

///最小的開始拖拽距離
double get dragStartDistanceMotionThreshold

///滾動模擬的公差
///指定距離、持續時間和速度差應視爲平等的差別的結構。
Tolerance get tolerance
複製代碼

上方代碼標註了 ScrollPhysics 各個方法的大體做用,而在前面 《十3、全面深刻觸摸和滑動原理》 中,咱們深刻解析過觸摸和滑動的原理,大體流程從觸摸開始往下傳遞, 最終觸發 layout 實現滑動的現象:

ScrollPhysics 的工做原理就穿插在其中,其流程以下圖所示, 主要的邏輯在於紅色標註的的三個方法:

  • applyPhysicsToUserOffset :經過 physics 將用戶拖拽距離 offset 轉化爲 setPixels(滾動) 的增量。
  • applyBoundaryConditions :經過 physics 計算當前滾動的邊界條件。
  • createBallisticSimulation : 建立自動滑動的模擬器。

這三個方法的觸發時機在於 _handleDragUpdate_handleDragCancel_handleDragEnd ,也就是拖動過程和拖動結束的時機:

  • applyPhysicsToUserOffsetapplyBoundaryConditions 是在 _handleDragUpdate 時被觸發的。
  • createBallisticSimulation 是在 _handleDragCancel_handleDragEnd 時被觸發的。

因此默認的 BouncingScrollPhysicsClampingScrollPhysics 最大的差別也在這個三個方法。

3.一、applyPhysicsToUserOffset

ClampingScrollPhysics 默認是沒有重載 applyPhysicsToUserOffset 方法的,parent == null 時,用戶的滑動 offset 是什麼就返回什麼:

double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
    if (parent == null)
      return offset;
    return parent.applyPhysicsToUserOffset(position, offset);
  }
複製代碼

BouncingScrollPhysics 中對 applyPhysicsToUserOffset 方法進行了 override ,其中 用戶沒有達到邊界前,依舊返回默認的 offset,當用戶到達邊界時,經過算法來達到模擬溢出阻尼效果。

///摩擦因子
 double frictionFactor(double overscrollFraction) => 0.52 * math.pow(1 - overscrollFraction, 2);

 @override
  double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
    assert(offset != 0.0);
    assert(position.minScrollExtent <= position.maxScrollExtent);

    if (!position.outOfRange)
      return offset;

    final double overscrollPastStart = math.max(position.minScrollExtent - position.pixels, 0.0);
    final double overscrollPastEnd = math.max(position.pixels - position.maxScrollExtent, 0.0);
    final double overscrollPast = math.max(overscrollPastStart, overscrollPastEnd);
    final bool easing = (overscrollPastStart > 0.0 && offset < 0.0)
        || (overscrollPastEnd > 0.0 && offset > 0.0);

    final double friction = easing
        // Apply less resistance when easing the overscroll vs tensioning.
        ? frictionFactor((overscrollPast - offset.abs()) / position.viewportDimension)
        : frictionFactor(overscrollPast / position.viewportDimension);
    final double direction = offset.sign;

    return direction * _applyFriction(overscrollPast, offset.abs(), friction);
  }
複製代碼

3.二、applyBoundaryConditions

ClampingScrollPhysicsapplyBoundaryConditions 方法中,在計算邊界條件值的時候,滑動值會和邊界值相減獲得相反的數據,使得滑動邊界相對靜止,從而達到「夾住」的做用 ,也就是動態邊界 ,因此默認請下 Android 上滾動到了邊界就會中止響應。

@override
  double applyBoundaryConditions(ScrollMetrics position, double value) {
    if (value < position.pixels && position.pixels <= position.minScrollExtent) // underscroll
      return value - position.pixels;
    if (position.maxScrollExtent <= position.pixels && position.pixels < value) // overscroll
      return value - position.pixels;
    if (value < position.minScrollExtent && position.minScrollExtent < position.pixels) // hit top edge
      return value - position.minScrollExtent;
    if (position.pixels < position.maxScrollExtent && position.maxScrollExtent < value) // hit bottom edge
      return value - position.maxScrollExtent;
    return 0.0;
  }
複製代碼

ps: 前面說過藍色的半圓是默認的 ScrollBehaviorbuildViewportChrome 方法實現的。

BouncingScrollPhysicsapplyBoundaryConditions 直接返回 0 ,也就是達到 0 是就邊界,過了 0 的就是邊界外的拖拽效果了。

@override
  double applyBoundaryConditions(ScrollMetrics position, double value) => 0.0;
複製代碼

3.三、createBallisticSimulation

由於 createBallisticSimulation 是在 _handleDragCancel_handleDragEnd 時觸發的,其實就是中止觸摸的時候,createBallisticSimulation 返回 null 時,Scrllable 將進入 IdleScrollActivity ,也就是中止滾動的狀態。

以下圖所示,徹底沒有 Simulation 的列表滾動,是不會連續滾動的。

ClampingScrollPhysicscreateBallisticSimulation 方法中,使用了 ClampingScrollSimulation(固定) 和 ScrollSpringSimulation(彈性) 兩種 Simulation ,以下代碼所示,理論上只有 position.outOfRange 纔會觸發彈性的回彈效果,但 ScrollPhysics 採用了相似 雙親代理模型 ,其 parent 可能會觸發 position.outOfRange ,因此推測這裏纔會有 ScrollSpringSimulation 補充的判斷。

以下代碼能夠看出,只有在 velocity 速度大於默認加速度,而且是可滑動範圍內,才返回 ClampingScrollPhysics 模擬滑動,不然返回 null 進入前面所說的 Idle 中止滑動,這也是爲何普通慢速拖動,不會觸發自動滾動的緣由。

@override
  Simulation createBallisticSimulation(
      ScrollMetrics position, double velocity) {
    final Tolerance tolerance = this.tolerance;
    if (position.outOfRange) {
      double end;
      if (position.pixels > position.maxScrollExtent)
        end = position.maxScrollExtent;
      if (position.pixels < position.minScrollExtent)
        end = position.minScrollExtent;
      assert(end != null);
      return ScrollSpringSimulation(
        spring,
        position.pixels,
        end,
        math.min(0.0, velocity),
        tolerance: tolerance,
      );
    }
    if (velocity.abs() < tolerance.velocity) return null;
    if (velocity > 0.0 && position.pixels >= position.maxScrollExtent)
      return null;
    if (velocity < 0.0 && position.pixels <= position.minScrollExtent)
      return null;
    return ClampingScrollSimulation(
      position: position.pixels,
      velocity: velocity,
      tolerance: tolerance,
    );
  }
複製代碼

BouncingScrollPhysicscreateBallisticSimulation 則簡單一些,只有在結束觸摸時,初始速度大於默認加速度或者超出區域,纔會返回 BouncingScrollSimulation 進行模擬滑動計算,不然經進入前面所說的 Idle 中止滑動。

@override
  Simulation createBallisticSimulation(ScrollMetrics position, double velocity) {
    final Tolerance tolerance = this.tolerance;
    if (velocity.abs() >= tolerance.velocity || position.outOfRange) {
      return BouncingScrollSimulation(
        spring: spring,
        position: position.pixels,
        velocity: velocity * 0.91, // TODO(abarth): We should move this constant closer to the drag end.
        leadingExtent: position.minScrollExtent,
        trailingExtent: position.maxScrollExtent,
        tolerance: tolerance,
      );
    }
    return null;
  }
複製代碼

能夠看出,在中止觸摸時,列表是否會繼續模擬滑動是和 velocitytolerance.velocity 有關,也就是速度大於指定的加速度時纔會繼續滑動 ,而且在可滑動區域內 ClampingScrollSimulationBouncingScrollSimulation 呈現的效果也不同。

以下圖所示,第一頁面的 ScrollSpringSimulation 在中止滾動前是有必定的減速效果的;而第二個頁面 ClampingScrollSimulation 是直接快速滑動到邊界。

事實上,經過選擇或者調整 Simulation ,就能夠對列表滑動的速度、阻尼、回彈效果等實現靈活的自定義。

4、Simulation

前面最後說到了,利用 Simulation 實現對列表的滑動、阻尼、回彈效果的實現處理,那麼 Simulation 是如何工做的呢?

如上圖所示,Simulation 的建立是在 ScrollPositionWithSingleContextgoBallistic 方法中被調用的 ,而後經過 BallisticScrollActivity 去觸發執行。

@override
  void goBallistic(double velocity) {
    assert(pixels != null);
    final Simulation simulation = physics.createBallisticSimulation(this, velocity);
    if (simulation != null) {
      beginActivity(BallisticScrollActivity(this, simulation, context.vsync));
    } else {
      goIdle();
    }
  }
複製代碼

BallisticScrollActivity 狀態中,Simulation 被用於驅動 AnimationControllervalue ,而後在動畫的回調中獲取 Simulation 計算後獲得的 value 進行 setPixels(value) 實現滾動。

這裏又涉及到了動畫的繪製機制,動畫的機制等新篇再詳細說明,簡單來講就是 當系統 drawFramevsync 信號到來時,會執行到 AnimationController 內部的 _tick 方法,從而觸發 _value = _simulation.x(elapsedInSeconds).clamp(lowerBound, upperBound); 改變和 notifyListeners(); 通知更新。

對於 Simulation 的內部計算邏輯這裏就不展開了,大體上可知 ClampingScrollSimulation 的摩擦因子是固定的,而 BouncingScrollSimulation 內部的摩擦因子和計算,是和傳遞的位置有關係。

這裏須要着重說起的就是,爲何 BouncingScrollPhysics 會自動回彈呢?

其實也是 BouncingScrollSimulation 的功勞,由於 BouncingScrollSimulation 構建時,會傳遞有 leadingExtent:position.minScrollExtenttrailingExtent: position.maxScrollExtent 兩個參數,在 underscroll 和 overscroll 的狀況下,會利用 ScrollSpringSimulation 實現彈性的回滾到 leadingExtenttrailingExtent 的動畫,從而達到以下圖的效果:

最後

到這裏 Flutter 的 ScrollPhysicsSimulation 就基本分析完了,嚴格意義上, Simulation 應該是屬於動畫的部分,可是這裏由於ScrollPhysics 也放到了一塊兒。

總結起來就是 ScrollPhysics 中控制了用戶觸摸轉化和邊界條件,而且在用戶中止觸摸時,利用 Simulation 實現了自動滾動與溢出回彈的動畫效果。

自此,第十八篇終於結束了!(///▽///)

資源推薦

其餘文章

《Flutter完整開發實戰詳解系列》

《移動端跨平臺開發的深度解析》

《全網最全Flutter與React Native深刻對比分析》

相關文章
相關標籤/搜索