Flutter筆記-深入分析滑動控件

ps: 文中flutter源碼版本 1.0.0


通過分析各個滑動控件,如:ListViewPageViewSingleChildScrollView等,內部都有一個Scrollable控件
也就是說滑動其實就是靠的Scrollable控件,這里就通過源碼對其進行分析

class Scrollable extends StatefulWidget {
  /// Creates a widget that scrolls.
  ///
  /// The [axisDirection] and [viewportBuilder] arguments must not be null.
  const Scrollable({
    Key key,
    this.axisDirection = AxisDirection.down,
    this.controller,
    this.physics,
    @required this.viewportBuilder,
    this.excludeFromSemantics = false,
    this.semanticChildCount,
  }) : assert(axisDirection != null),
       assert(viewportBuilder != null),
       assert(excludeFromSemantics != null),
       super (key: key);
  //滑動方向,上下左右四方向
  final AxisDirection axisDirection;
  //滑動控制
  final ScrollController controller;
  /* 滑動效果
  * AlwaysScrollableScrollPhysics() 總是可以滑動,默認值
  * NeverScrollableScrollPhysics禁止滾動
  * BouncingScrollPhysics 內容超過一屏 上拉有回彈效果
  * ClampingScrollPhysics 包裹內容 不會有回彈
  */
  final ScrollPhysics physics;
  //關鍵,
  final ViewportBuilder viewportBuilder;
  //語義控件,輔助工具相關
  final bool excludeFromSemantics;
  final int semanticChildCount;

  Axis get axis => axisDirectionToAxis(axisDirection);

  @override
  ScrollableState createState() => ScrollableState();
  ...
}

StatefulWidget控件直奔createState()方法,同時先查看Statebuild(BuildContext context)方法

class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
    implements ScrollContext {
  ...
  @override
  Widget build(BuildContext context) {
    assert(position != null);
    //RawGestureDetector,手勢監聽控件
    Widget result = RawGestureDetector(
      key: _gestureDetectorKey,
      gestures: _gestureRecognizers,
      behavior: HitTestBehavior.opaque,
      excludeFromSemantics: widget.excludeFromSemantics,
      //Semantics 語義控件,輔助控件相關,不考慮
      child: Semantics(
        explicitChildNodes: !widget.excludeFromSemantics,
        //IgnorePointer,語義控件相關,不考慮
        child: IgnorePointer(
          key: _ignorePointerKey,
          ignoring: _shouldIgnorePointer,
          ignoringSemantics: false,
          //InheritedWidget控件,主要是為了共享position數據,即包含了physics的ScrollPosition對象
          child: _ScrollableScope(
            scrollable: this,
            position: position,
            child: widget.viewportBuilder(context, position),
          ),
        ),
      ),
    );
    //含Semantics直接跳過,不相關
    if (!widget.excludeFromSemantics) {
      result = _ScrollSemantics(
        key: _scrollSemanticsKey,
        child: result,
        position: position,
        allowImplicitScrolling: widget?.physics?.allowImplicitScrolling ?? _physics.allowImplicitScrolling,
        semanticChildCount: widget.semanticChildCount,
      );
    }
    return _configuration.buildViewportChrome(context, result, widget.axisDirection);
  }
...  
}

為什么android滑動超過界限的效果和ios不同處理

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: _kDefaultGlowColor,
        );
    }
    return null;
  }

_ScrollableScope私有控件中,child: widget.viewportBuilder(context, position)是什么

typedef ViewportBuilder = Widget Function(BuildContext context, ViewportOffset position);

viewportBuilder是一個方法,返回一個widget,而值是通過Scrollable的構造函數傳遞過來的

因此,我們來看看SingleChildScrollViewviewportBuilder是什么

class SingleChildScrollView extends StatelessWidget {
  ...
  @override
  Widget build(BuildContext context) {
    final AxisDirection axisDirection = _getDirection(context);
    Widget contents = child;
    if (padding != null)
      contents = Padding(padding: padding, child: contents);
    final ScrollController scrollController = primary
        ? PrimaryScrollController.of(context)
        : controller;
    final Scrollable scrollable = Scrollable(
      axisDirection: axisDirection,
      controller: scrollController,
      physics: physics,
      //就是這里,這個傳遞的offset即上面的position,是一個ScrollPosition對象
      viewportBuilder: (BuildContext context, ViewportOffset offset) {
        return _SingleChildViewport(
          axisDirection: axisDirection,
          offset: offset,
          child: contents,
        );
      },
    );
    return primary && scrollController != null
      ? PrimaryScrollController.none(child: scrollable)
      : scrollable;
  }
}

_SingleChildViewport是一個SingleChildRenderObjectWidget,突然有些熟悉了,就是自繪控件那,直接找createRenderObject方法

class _SingleChildViewport extends SingleChildRenderObjectWidget {
  ...
  @override
  _RenderSingleChildViewport createRenderObject(BuildContext context) {
    return _RenderSingleChildViewport(
      axisDirection: axisDirection,
      offset: offset,
    );
  }
  ...
}
class _RenderSingleChildViewport extends RenderBox 
with RenderObjectWithChildMixin<RenderBox> 
implements RenderAbstractViewport {...}

源碼考慮的情況比較多,這里我們做個簡化,只考慮垂直方向,并且是向下的(基于源碼重寫了一個類,刪減了源碼的部分內容)

class _RenderChildViewport extends RenderBox with RenderObjectWithChildMixin<RenderBox>{
  _RenderChildViewport({
    @required ViewportOffset offset,
  }):_offset = offset;

  ViewportOffset _offset;
  ViewportOffset get offset => _offset;
  set offset(ViewportOffset value) {
    assert(value != null);
    if (value == _offset)
      return;
    if (attached)
      _offset.removeListener(_hasScrolled);
    _offset = value;
    if (attached)
     //offset變化則重繪并更新
      _offset.addListener(_hasScrolled);
    markNeedsLayout();
    markNeedsCompositingBitsUpdate();
  }
 
  @override
  void attach(PipelineOwner owner) {
    super.attach(owner);
    _offset.addListener(_hasScrolled);
  }

  @override
  void detach() {
    _offset.removeListener(_hasScrolled);
    super.detach();
  }

  void _hasScrolled() {
    //重繪和更新
    markNeedsPaint();
    markNeedsSemanticsUpdate();
  }

  @override
  void performLayout() {
    ...
  }

  @override
  void paint(PaintingContext context, Offset offset) {
     ...
   }
  }
}

_offset增加了監聽,一旦發生了變化,就會調用_hasScrolled(),從而重新繪制,調用paint(PaintingContext context, Offset offset)
從2個方面來看:
1.擺放

class _RenderChildViewport extends RenderBox with RenderObjectWithChildMixin<RenderBox>{
  ...
  @override
  void performLayout() {
    //如果有子控件,子控件內部的擺放就交給子控件自己
    if (child == null) {
      size = constraints.smallest;
    } else {
      child.layout(constraints.widthConstraints(), parentUsesSize: true);
      //約束布局,傳遞的size約束在屏幕內
      size = constraints.constrain(child.size);
    }
    //size.height 父控件的高度
    offset.applyViewportDimension(size.height);
    //child.size.height 子控件的高度
    offset.applyContentDimensions(0.0, child.size.height - size.height);
  }
}

layout過程對size進行了計算,同時設置了offset約束范圍

abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
  ...
  //賦值,賦予父控件的高度
  @override
  bool applyViewportDimension(double viewportDimension) {
    if (_viewportDimension != viewportDimension) {
      _viewportDimension = viewportDimension;
      _didChangeViewportDimensionOrReceiveCorrection = true;
    }
    return true;
  }
  //最大及最小滑動距離
  @override
  bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
    if (!nearEqual(_minScrollExtent, minScrollExtent, Tolerance.defaultTolerance.distance) ||
        !nearEqual(_maxScrollExtent, maxScrollExtent, Tolerance.defaultTolerance.distance) ||
        _didChangeViewportDimensionOrReceiveCorrection) {
      _minScrollExtent = minScrollExtent;
      _maxScrollExtent = maxScrollExtent;
      _haveDimensions = true;
      applyNewDimensions();
      _didChangeViewportDimensionOrReceiveCorrection = false;
    }
    return true;
  }
}

2.繪制

class _RenderChildViewport extends RenderBox with RenderObjectWithChildMixin<RenderBox>{
  ...

  Offset get _paintOffset => _paintOffsetForPosition(offset.pixels);

Offset _paintOffsetForPosition(double position) {
    return Offset(0.0, -position);
  }

  bool _shouldClipAtPaintOffset(Offset paintOffset) {
    assert(child != null);
    return paintOffset < Offset.zero || !(Offset.zero & size).contains((paintOffset & child.size).bottomRight);
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null) {
      final Offset paintOffset = _paintOffset;
      void paintContents(PaintingContext context, Offset offset) {
        //從偏移點處開始繪制子控件,因為是上向滑動,所以這里的paintOffset是負值
        context.paintChild(child, offset + paintOffset);
      }
      //是否需要裁剪
      if (_shouldClipAtPaintOffset(paintOffset)) {
        //矩形裁剪
        context.pushClipRect(needsCompositing, offset, Offset.zero & size, paintContents);
      } else {
        paintContents(context, offset);
      }
    }
  }
}

重點分析一下_shouldClipAtPaintOffset(paintOffset)

abstract class OffsetBase {
  ...
  bool operator <(OffsetBase other) => _dx < other._dx && _dy < other._dy;

  Rect operator &(Size other) => new Rect.fromLTWH(dx, dy, other.width, other.height);
}
paintOffset < Offset.zero || !(Offset.zero & size).contains((paintOffset & child.size).bottomRight)

paintOffset < Offset.zero,不滿足,因為dx不變,就看第二個,圖解一下(Offset.zero & size).contains((paintOffset & child.size).bottomRight):

image.png

很明顯,當子類過大的時候只有當到底部才滿足該條件,因此效果上是除非滑動底部或子類足夠小,否則裁剪畫布,去除超出部分

所以,滑動的過程也就是不斷改變繪制位置的過程


源碼:https://github.com/leaf-fade/flutterDemo/blob/master/lib/scroll/widget/scrollable.dart

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,333評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,491評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,263評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,946評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,708評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,409評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,939評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,774評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,641評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,872評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,650評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容