Flutter 重識 NestedScrollView

前言

extended_nested_scroll_view 是我的第一個上傳到 pub.dev 的 Flutter 組件.

一晃眼都快3年了返十,經(jīng)歷了43個版本迭代,功能穩(wěn)定,代碼與官方同步筒主。

而我最近一直籌備著對其進(jìn)行重構(gòu)专控。怎么說了罚舱,接觸 Flutter 3年了继薛,認(rèn)知也與當(dāng)初有所不同饲齐。我相信自己如果現(xiàn)在再面對 NestedScrollView 的問題诫隅,我應(yīng)該能處理地更好腐魂。

注意: 后面用到的 SliverPinnedToBoxAdapterextended_sliver里面一個組件,你把它當(dāng)作 SliverPersistentHeader( Pinned 為 true逐纬,minExtent = maxExtent) 就好了蛔屹。

NestedScrollView 是什么

A scrolling view inside of which can be nested other scrolling views, with their scroll positions being intrinsically linked.

將外部滾動(Header部分)和內(nèi)部滾動(Body部分)聯(lián)動起來。里面滾動不了豁生,滾動外面兔毒。外面滾動沒了漫贞,滾動里面。那么 NestedScrollView 是如何做到的呢育叁?

NestedScrollView 其實(shí)是一個 CustomScrollView, 下面為偽代碼迅脐。

    CustomScrollView(
      controller: outerController,
      slivers: [
       ...<Widget>[Header1,Header2],
      SliverFillRemaining()(
        child: PrimaryScrollController(
          controller: innerController,
          child: body,
        ),
      ),
      ],
    );
  • outerController 是 CustomScrollViewcontroller, 從層級上看豪嗽,就是外部
  • 這里使用了 PrimaryScrollController 谴蔑,那么 body 里面的任何滾動組件,在不自定義 controller 的情況下昵骤,都將公用 innerController树碱。

至于為什么會這樣,首先看一下每個滾動組件都有的屬性 primary变秦,如果 controller 為 null 成榜,并且是豎直方法,就默認(rèn)為 true 蹦玫。

primary = primary ?? controller == null && identical(scrollDirection, Axis.vertical),

然后 在 scroll_view.dart 中赎婚,如果 primary 為 true,就去獲取 PrimaryScrollController 的 controller樱溉。

    final ScrollController? scrollController =
        primary ? PrimaryScrollController.of(context) : controller;
    final Scrollable scrollable = Scrollable(
      dragStartBehavior: dragStartBehavior,
      axisDirection: axisDirection,
      controller: scrollController,
      physics: physics,
      scrollBehavior: scrollBehavior,
      semanticChildCount: semanticChildCount,
      restorationId: restorationId,
      viewportBuilder: (BuildContext context, ViewportOffset offset) {
        return buildViewport(context, offset, axisDirection, slivers);
      },
    );

這也解釋了為啥有些同學(xué)給 body 中的滾動組件設(shè)置了 controller挣输,就會發(fā)現(xiàn)內(nèi)外滾動不再聯(lián)動了。

為什么要擴(kuò)展官方的

理解了 NestedScrollView 是什么福贞,那我為啥要擴(kuò)展官方組件呢撩嚼?

Header 中包含多個 Pinned Sliver 時候的問題

分析

先看一個圖,你覺得列表向上滾動最終的結(jié)果是什么挖帘?代碼在下面完丽。

    CustomScrollView(
          slivers: <Widget>[
            SliverToBoxAdapter(
              child: Container(
                alignment: Alignment.center,
                child: Text('Header: 100高度'),
                height: 100,
                color: Colors.yellow.withOpacity(0.4),
              ),
            ),
            SliverPinnedToBoxAdapter(
              child: Container(
                alignment: Alignment.center,
                child: Text('Header: Pinned 100高度'),
                height: 100,
                color: Colors.red.withOpacity(0.4),
              ),
            ),
            SliverToBoxAdapter(
              child: Container(
                alignment: Alignment.center,
                child: Text('Header: 100高度'),
                height: 100,
                color: Colors.yellow.withOpacity(0.4),
              ),
            ),
            SliverFillRemaining(
              child: Column(
                children: List.generate(
                    100,
                    (index) => Container(
                          alignment: Alignment.topCenter,
                          child: Text('body: 里面的內(nèi)容$index,高度100'),
                          height: 100,
                          decoration: BoxDecoration(
                              color: Colors.green.withOpacity(0.4),
                              border: Border.all(
                                color: Colors.black,
                              )),
                        )),
              ),
            )
          ],
        ),

嗯,沒錯拇舀,列表的第一個 Item 會滾動到 Header1 下面逻族。但實(shí)際上,我們通常的需求是需要列表停留在 Header1 底邊骄崩。

Flutter 官方也注意到了這個問題聘鳞,并且提供了 SliverOverlapAbsorber
SliverOverlapInjector 來處理這個問題,

  • SliverOverlapAbsorber 來包裹 PinnedtrueSliver
  • 在 body 中使用 SliverOverlapInjector 來占位
  • NestedScrollView._absorberHandle 來實(shí)現(xiàn) SliverOverlapAbsorberSliverOverlapInjector 的信息傳遞要拂。
   return Scaffold(
     body: NestedScrollView(
       headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
         return <Widget>[
           // 監(jiān)聽計(jì)算高度抠璃,并且通過 NestedScrollView._absorberHandle 將
           // 自身的高度 告訴 SliverOverlapInjector
           SliverOverlapAbsorber(
             handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
             sliver: SliverPinnedToBoxAdapter(
              child: Container(
                alignment: Alignment.center,
                child: Text('Header: Pinned 100高度'),
                height: 100,
                color: Colors.red.withOpacity(0.4),
              ),
            )
           )
         ];
       },
       body: Builder(
         builder: (BuildContext context) {
           return CustomScrollView(
             // The "controller" and "primary" members should be left
             // unset, so that the NestedScrollView can control this
             // inner scroll view.
             // If the "controller" property is set, then this scroll
             // view will not be associated with the NestedScrollView.
             slivers: <Widget>[
               // 占位,接收 SliverOverlapAbsorber 的信息
               SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
               SliverFixedExtentList(
                 itemExtent: 48.0,
                 delegate: SliverChildBuilderDelegate(
                     (BuildContext context, int index) => ListTile(title: Text('Item $index')),
                   childCount: 30,
                 ),
               ),
             ],
           );
         }
       )
     )
   );
 }

如果你覺得這種方法不清楚脱惰,那我簡化一下搏嗡,用另外的方式表達(dá)。我們也增加一個 100 的占位枪芒。不過實(shí)際操作中是不可能這樣做的彻况,這樣會導(dǎo)致初始化的時候列表上方會留下 100 的空位。

   CustomScrollView(
          slivers: <Widget>[
            SliverToBoxAdapter(
              child: Container(
                alignment: Alignment.center,
                child: Text('Header0: 100高度'),
                height: 100,
                color: Colors.yellow.withOpacity(0.4),
              ),
            ),
            SliverPinnedToBoxAdapter(
              child: Container(
                alignment: Alignment.center,
                child: Text('Header1: Pinned 100高度'),
                height: 100,
                color: Colors.red.withOpacity(0.4),
              ),
            ),
            SliverToBoxAdapter(
              child: Container(
                alignment: Alignment.center,
                child: Text('Header2: 100高度'),
                height: 100,
                color: Colors.yellow.withOpacity(0.4),
              ),
            ),
            SliverFillRemaining(
              child: Column(
                children: <Widget>[
                  // 我相當(dāng)于 SliverOverlapAbsorber
                  Container(
                    height: 100,
                  ),
                  Column(
                    children: List.generate(
                        100,
                        (index) => Container(
                              alignment: Alignment.topCenter,
                              child: Text('body: 里面的內(nèi)容$index,高度100'),
                              height: 100,
                              decoration: BoxDecoration(
                                  color: Colors.green.withOpacity(0.4),
                                  border: Border.all(
                                    color: Colors.black,
                                  )),
                            )),
                  ),
                ],
              ),
            )
          ],
        ),

那問題來了舅踪,如果 NestedScrollViewHeader 中包含多個 PinnedtrueSliver纽甘, 那么 SliverOverlapAbsorber 便無能為力了,Issue 傳送門抽碌。

解決

我們再來回顧 NestedScrollView 長什么樣子的悍赢,可以看出來,這個問題應(yīng)該跟 outerController 有關(guān)系货徙。參照前面簡單 demo 來看左权,只要我們讓外部少滾動 100,就可以讓列表停留在 Pinned Header1 底部了痴颊。

    CustomScrollView(
      controller: outerController,
      slivers: [
       ...<Widget>[Header1,Header2],
      SliverFillRemaining()(
        child: PrimaryScrollController(
          controller: innerController,
          child: body,
        ),
      ),
      ],
    );
maxScrollExtent

我們再思考一下赏迟,是什么會影響一個滾動組件的滾動最終距離?

答案是 ScrollPosition.maxScrollExtent

知道了是什么東西影響蠢棱,我們要做的就是在合適的時候修改這個值锌杀,那么如何獲取時機(jī)呢?

將下面代碼

  @override
  double get maxScrollExtent => _maxScrollExtent!;
  double? _maxScrollExtent;

改為以下代碼

  @override
  double get maxScrollExtent => _maxScrollExtent!;
  //double? _maxScrollExtent;
  double? __maxScrollExtent;
  double? get _maxScrollExtent => __maxScrollExtent;
  set _maxScrollExtent(double? value) {
    if (__maxScrollExtent != value) {
      __maxScrollExtent = value;
   }
  } 

這樣我們就可以在 set 方法里面打上 debug 斷點(diǎn)泻仙,看看是什么時候 _maxScrollExtent 被賦值的糕再。

運(yùn)行例子 ,得到以下 Call Stack玉转。

看到這里突想,我們應(yīng)該知道,可以通過 override applyContentDimensions 方法究抓,去重新設(shè)置 maxScrollExtent

ScrollPosition

想要 override applyContentDimensions 就要知道 ScrollPosition 在什么時候創(chuàng)建的猾担,繼續(xù)調(diào)試, 把斷點(diǎn)打到 ScrollPosition 的構(gòu)造上面。

graph TD
ScrollController.createScrollPosition --> ScrollPositionWithSingleContext --> ScrollPosition

可以看到如果不是特定的 ScrollPosition漩蟆,我們平時使用的是默認(rèn)的
ScrollPositionWithSingleContext垒探,并且在 ScrollControllercreateScrollPosition 方法中創(chuàng)建。

增加下面的代碼怠李,并且給 demo 中的 CustomScrollView 添加 controllerMyScrollController圾叼,我們再次運(yùn)行 demo,是不是得到了我們想要的效果呢捺癞?

class MyScrollController extends ScrollController {
  @override
  ScrollPosition createScrollPosition(ScrollPhysics physics,
      ScrollContext context, ScrollPosition oldPosition) {
    return MyScrollPosition(
      physics: physics,
      context: context,
      initialPixels: initialScrollOffset,
      keepScrollOffset: keepScrollOffset,
      oldPosition: oldPosition,
      debugLabel: debugLabel,
    );
  }
}

class MyScrollPosition extends ScrollPositionWithSingleContext {
  MyScrollPosition({
    @required ScrollPhysics physics,
    @required ScrollContext context,
    double initialPixels = 0.0,
    bool keepScrollOffset = true,
    ScrollPosition oldPosition,
    String debugLabel,
  }) : super(
          physics: physics,
          context: context,
          keepScrollOffset: keepScrollOffset,
          oldPosition: oldPosition,
          debugLabel: debugLabel,
          initialPixels: initialPixels,
        );

  @override
  bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
    return super.applyContentDimensions(minScrollExtent, maxScrollExtent - 100);
  }
}
_NestedScrollPosition

對應(yīng)到 NestedScrollView 中夷蚊,可以為_NestedScrollPosition 添加以下的方法。

pinnedHeaderSliverHeightBuilder 回調(diào)是獲取 Header 當(dāng)中一共有哪些 PinnedSliver髓介。

  • 對于 SliverAppbar 來說惕鼓,最終固定的高度應(yīng)該包括 狀態(tài)欄的高度(MediaQuery.of(context).padding.top) 和 導(dǎo)航欄的高度(kToolbarHeight)
  • 對于 SliverPersistentHeader ( Pinned 為 true ), 最終固定高度應(yīng)該為 minExtent
  • 如果有多個這種 Sliver唐础, 應(yīng)該為他們最終固定的高度之和箱歧。
  @override
  bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
    if (debugLabel == 'outer' &&
        coordinator.pinnedHeaderSliverHeightBuilder != null) {
      maxScrollExtent =
          maxScrollExtent - coordinator.pinnedHeaderSliverHeightBuilder!();
      maxScrollExtent = math.max(0.0, maxScrollExtent);
    }
    return super.applyContentDimensions(minScrollExtent, maxScrollExtent);
  }

Body 中多列表滾動互相影響的問題

大家一定有這種需求矾飞,在 TabbarView 或者 PageView 中的列表,切換的時候列表的滾動位置要保留呀邢。這個使用 AutomaticKeepAliveClientMixin洒沦,非常簡單。

但是如果把 TabbarView 或者 PageView 放到NestedScrollViewbody 里面的話价淌,你滾動其中一個列表申眼,也會發(fā)現(xiàn)其他的列表也會跟著改變位置。Issue 傳送門

分析

先看 NestedScrollView 的偽代碼蝉衣。NestedScrollView 之所以能上內(nèi)外聯(lián)動括尸,就是在于 outerControllerinnerController 的聯(lián)動。

    CustomScrollView(
      controller: outerController,
      slivers: [
       ...<Widget>[Header1,Header2],
      SliverFillRemaining()(
        child: PrimaryScrollController(
          controller: innerController,
          child: body,
        ),
      ),
      ],
    );

innerController 負(fù)責(zé) Body病毡,將 Body 中沒有設(shè)置過 controller 的列表的 ScrollPosition 通過 attach 方法濒翻,加載進(jìn)來。

當(dāng)使用列表緩存的時候剪验,切換 tab 的時候肴焊,原列表將不會 dispose,就不會從 controller 中 detach 功戚。 innerController.positions 將不止一個娶眷。而 outerControllerinnerController 的聯(lián)動計(jì)算都是基于 positions 來進(jìn)行的。這就是導(dǎo)致這個問題的原因啸臀。

具體代碼體現(xiàn)在
https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/widgets/nested_scroll_view.dart#L1135

        if (innerDelta != 0.0) {
          for (final _NestedScrollPosition position in _innerPositions)
            position.applyFullDragUpdate(innerDelta);
        }

解決

不管是3年前還是現(xiàn)在再看這個問題届宠,第一感覺,不就是只要找到當(dāng)前顯示的那個列表乘粒,只讓它滾動就可以了嘛豌注,不是很簡單嗎?

確實(shí),但是那只是看起來覺得簡單灯萍,畢竟這個 issue 已經(jīng) open 3年了轧铁。

老方案
  1. ScrollPosition attach 的時候去通過 context 找到這個列表所對應(yīng)的標(biāo)志,跟 TabbarView 或者 PageView 的 index 關(guān)聯(lián)進(jìn)行對比旦棉。
    Flutter 擴(kuò)展NestedScrollView (二)列表滾動同步解決 (juejin.cn)

  2. 通過計(jì)算列表的相對位置齿风,來確定當(dāng)前 顯示 的列表。
    Flutter 你想知道的Widget可視區(qū)域,相對位置,大小 (juejin.cn)

總體來說绑洛,

  • 1方案更準(zhǔn)確救斑,但是用法比較繁瑣。
  • 2方案受動畫影響真屯,在一些特殊的情況下會導(dǎo)致計(jì)算不正確脸候。
新方案

首先我們先準(zhǔn)備一個的 demo 重現(xiàn)問題。

      NestedScrollView(
        headerSliverBuilder: (
          BuildContext buildContext,
          bool innerBoxIsScrolled,
        ) =>
            <Widget>[
          SliverToBoxAdapter(
            child: Container(
              color: Colors.red,
              height: 200,
            ),
          )
        ],
        body: Column(
          children: [
            Container(
              color: Colors.yellow,
              height: 200,
            ),
            Expanded(
              child: PageView(
                children: <Widget>[
                  ListItem(
                    tag: 'Tab0',
                  ),
                  ListItem(
                    tag: 'Tab1',
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
      
 class ListItem extends StatefulWidget {
  const ListItem({
    Key key,
    this.tag,
  }) : super(key: key);
  final String tag;

  @override
  _ListItemState createState() => _ListItemState();
}

class _ListItemState extends State<ListItem>
    with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    super.build(context);
    return ListView.builder(
      itemBuilder: (BuildContext buildContext, int index) =>
          Center(child: Text('${widget.tag}---$index')),
      itemCount: 1000,
    );
  }

  @override
  bool get wantKeepAlive => true;
}         
Drag

現(xiàn)在再看這個問題,我在思考运沦,我自己滾動了哪個列表泵额,我自己不知道?携添?

看過上一篇 Flutter 鎖定行列的FlexGrid - 掘金 (juejin.cn) 的小伙伴梯刚,應(yīng)該知道在拖拽列表的時候是會生成一個 Drag 的。那么有這個 DragScrollPosition 不就對應(yīng)正在顯示的列表嗎薪寓??

具體到代碼澜共,我們試試打日志看看向叉,

https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/widgets/nested_scroll_view.dart#L1625

  @override
  Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
    print(debugLabel);
    return coordinator.drag(details, dragCancelCallback);
  }

理想很好,但是現(xiàn)實(shí)是骨感的嗦董,不管我是滾動 Header 還是 Body 母谎,都只打印了 outer 。 那意思是 Body 里面的手勢全部被吃了京革?奇唤?

不著急,我們打開 DevTools 匹摇,看看 ListView 里面的 ScrollableState 的狀態(tài)咬扇。(具體為啥要看這里面,可以去讀讀 Flutter 鎖定行列的 FlexGrid (juejin.cn))

哈哈廊勃,gestures 居然為 none懈贺,就是說 Body 里面沒有注冊手勢。

https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/widgets/scrollable.dart#L543 setCanDrag 方法中坡垫,我們可以看到只有 canDrag 等于 false 的時候梭灿,我們是沒有注冊手勢的。當(dāng)然也有一種可能冰悠,setCanDrag 也許就沒有被調(diào)用過堡妒,默認(rèn)的 _gestureRecognizers 就是空。

  @override
  @protected
  void setCanDrag(bool canDrag) {
    if (canDrag == _lastCanDrag && (!canDrag || widget.axis == _lastAxisDirection))
      return;
    if (!canDrag) {
      _gestureRecognizers = const <Type, GestureRecognizerFactory>{};
      // Cancel the active hold/drag (if any) because the gesture recognizers
      // will soon be disposed by our RawGestureDetector, and we won't be
      // receiving pointer up events to cancel the hold/drag.
      _handleDragCancel();
    } else {
      switch (widget.axis) {
        case Axis.vertical:
          _gestureRecognizers = <Type, GestureRecognizerFactory>{
            VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
              () => VerticalDragGestureRecognizer(),
              (VerticalDragGestureRecognizer instance) {
                instance
                  ..onDown = _handleDragDown
                  ..onStart = _handleDragStart
                  ..onUpdate = _handleDragUpdate
                  ..onEnd = _handleDragEnd
                  ..onCancel = _handleDragCancel
                  ..minFlingDistance = _physics?.minFlingDistance
                  ..minFlingVelocity = _physics?.minFlingVelocity
                  ..maxFlingVelocity = _physics?.maxFlingVelocity
                  ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
                  ..dragStartBehavior = widget.dragStartBehavior;
              },
            ),
          };
          break;
        case Axis.horizontal:
          _gestureRecognizers = <Type, GestureRecognizerFactory>{
            HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
              () => HorizontalDragGestureRecognizer(),
              (HorizontalDragGestureRecognizer instance) {
                instance
                  ..onDown = _handleDragDown
                  ..onStart = _handleDragStart
                  ..onUpdate = _handleDragUpdate
                  ..onEnd = _handleDragEnd
                  ..onCancel = _handleDragCancel
                  ..minFlingDistance = _physics?.minFlingDistance
                  ..minFlingVelocity = _physics?.minFlingVelocity
                  ..maxFlingVelocity = _physics?.maxFlingVelocity
                  ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
                  ..dragStartBehavior = widget.dragStartBehavior;
              },
            ),
          };
          break;
      }
    }
    _lastCanDrag = canDrag;
    _lastAxisDirection = widget.axis;
    if (_gestureDetectorKey.currentState != null)
      _gestureDetectorKey.currentState!.replaceGestureRecognizers(_gestureRecognizers);
  }

我們在 setCanDrag 方法中打一個斷點(diǎn)溉卓,看看調(diào)用的時機(jī)皮迟。

  1. RenderViewport.performLayout

performLayout 方法中計(jì)算出當(dāng)前 ScrollPosition 的最小最大值

     if (offset.applyContentDimensions(
              math.min(0.0, _minScrollExtent + mainAxisExtent * anchor),
              math.max(0.0, _maxScrollExtent - mainAxisExtent * (1.0 - anchor)),
           ))
  1. ScrollPosition.applyContentDimensions

調(diào)用 applyNewDimensions 方法

  @override
  bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
    assert(minScrollExtent != null);
    assert(maxScrollExtent != null);
    assert(haveDimensions == (_lastMetrics != null));
    if (!nearEqual(_minScrollExtent, minScrollExtent, Tolerance.defaultTolerance.distance) ||
        !nearEqual(_maxScrollExtent, maxScrollExtent, Tolerance.defaultTolerance.distance) ||
        _didChangeViewportDimensionOrReceiveCorrection) {
      assert(minScrollExtent != null);
      assert(maxScrollExtent != null);
      assert(minScrollExtent <= maxScrollExtent);
      _minScrollExtent = minScrollExtent;
      _maxScrollExtent = maxScrollExtent;
      final ScrollMetrics? currentMetrics = haveDimensions ? copyWith() : null;
      _didChangeViewportDimensionOrReceiveCorrection = false;
      _pendingDimensions = true;
      if (haveDimensions && !correctForNewDimensions(_lastMetrics!, currentMetrics!)) {
        return false;
      }
      _haveDimensions = true;
    }
    assert(haveDimensions);
    if (_pendingDimensions) {
      applyNewDimensions();
      _pendingDimensions = false;
    }
    assert(!_didChangeViewportDimensionOrReceiveCorrection, 'Use correctForNewDimensions() (and return true) to change the scroll offset during applyContentDimensions().');
    _lastMetrics = copyWith();
    return true;
  }
  1. ScrollPositionWithSingleContext.applyNewDimensions

不特殊定義的話,默認(rèn) ScrollPosition 都是 ScrollPositionWithSingleContext的诵。context 是誰呢万栅?
當(dāng)然是 ScrollableState

  @override
  void applyNewDimensions() {
    super.applyNewDimensions();  
    context.setCanDrag(physics.shouldAcceptUserOffset(this));
  }

這里提了一下,平時有同學(xué)問西疤。不滿一屏幕的列表 controller 注冊不觸發(fā) 或者 NotificationListener<ScrollUpdateNotification> 監(jiān)聽不觸發(fā)烦粒。原因就在這里,physics.shouldAcceptUserOffset(this) 返回的是 false。而我們的處理辦法就是 設(shè)置 physics 為 AlwaysScrollableScrollPhysics扰她, shouldAcceptUserOffset 放

AlwaysScrollableScrollPhysicsshouldAcceptUserOffset 方法永遠(yuǎn)返回 true 兽掰。

class AlwaysScrollableScrollPhysics extends ScrollPhysics {
  /// Creates scroll physics that always lets the user scroll.
  const AlwaysScrollableScrollPhysics({ ScrollPhysics? parent }) : super(parent: parent);

  @override
  AlwaysScrollableScrollPhysics applyTo(ScrollPhysics? ancestor) {
    return AlwaysScrollableScrollPhysics(parent: buildParent(ancestor));
  }

  @override
  bool shouldAcceptUserOffset(ScrollMetrics position) => true;
}
  1. ScrollableState.setCanDrag

最終達(dá)到這里,去根據(jù) canDragaxis(水平/垂直)

_NestedScrollCoordinator

那接下來徒役,我們就去 NestedScrollView 代碼里面找找看孽尽。

https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/widgets/nested_scroll_view.dart#L1612

  @override
  void applyNewDimensions() {
    super.applyNewDimensions();
    coordinator.updateCanDrag();
  }

這里我們看到調(diào)用了 coordinator.updateCanDrag()

首先我們看看 coordinator 是什么忧勿?不難看出來杉女,用來協(xié)調(diào) outerControllerinnerController 的。


class _NestedScrollCoordinator
    implements ScrollActivityDelegate, ScrollHoldController {
  _NestedScrollCoordinator(
    this._state,
    this._parent,
    this._onHasScrolledBodyChanged,
    this._floatHeaderSlivers,
  ) {
    final double initialScrollOffset = _parent?.initialScrollOffset ?? 0.0;
    _outerController = _NestedScrollController(
      this,
      initialScrollOffset: initialScrollOffset,
      debugLabel: 'outer',
    );
    _innerController = _NestedScrollController(
      this,
      initialScrollOffset: 0.0,
      debugLabel: 'inner',
    );
  }

那么我們看看 updateCanDrag 方法里面做了什么鸳吸。

  void updateCanDrag() {
    if (!_outerPosition!.haveDimensions) return;
    double maxInnerExtent = 0.0;
    for (final _NestedScrollPosition position in _innerPositions) {
      if (!position.haveDimensions) return;
      maxInnerExtent = math.max(
        maxInnerExtent,
        position.maxScrollExtent - position.minScrollExtent,
      );
    }
    // _NestedScrollPosition.updateCanDrag
    _outerPosition!.updateCanDrag(maxInnerExtent);
  }

_NestedScrollPosition.updateCanDrag

  void updateCanDrag(double totalExtent) {
    // 調(diào)用 ScrollableState 的 setCanDrag 方法
    context.setCanDrag(totalExtent > (viewportDimension - maxScrollExtent) ||
        minScrollExtent != maxScrollExtent);
  }

知道原因之后熏挎,我們試試動手改下。

  • 修改 _NestedScrollCoordinator.updateCanDrag 為如下:
  void updateCanDrag({_NestedScrollPosition? position}) {
    double maxInnerExtent = 0.0;

    if (position != null && position.debugLabel == 'inner') {
      if (position.haveDimensions) {
        maxInnerExtent = math.max(
          maxInnerExtent,
          position.maxScrollExtent - position.minScrollExtent,
        );
        position.updateCanDrag(maxInnerExtent);
      }
    }
    if (!_outerPosition!.haveDimensions) {
      return;
    }

    for (final _NestedScrollPosition position in _innerPositions) {
      if (!position.haveDimensions) {
        return;
      }
      maxInnerExtent = math.max(
        maxInnerExtent,
        position.maxScrollExtent - position.minScrollExtent,
      );
    }
    _outerPosition!.updateCanDrag(maxInnerExtent);
  }
  • 修改 _NestedScrollPosition.drag 方法為如下:
  bool _isActived = false;
  @override
  Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
    _isActived = true;
    return coordinator.drag(details, () {
      dragCancelCallback();
      _isActived = false;
    });
  }

  /// Whether is actived now
  bool get isActived {
    return _isActived;
  }
  • 修改 _NestedScrollCoordinator._innerPositions 為如下:
 Iterable<_NestedScrollPosition> get _innerPositions {
    if (_innerController.nestedPositions.length > 1) {
      final Iterable<_NestedScrollPosition> actived = _innerController
          .nestedPositions
          .where((_NestedScrollPosition element) => element.isActived);
      print('${actived.length}');
      if (actived.isNotEmpty) return actived;
    }
    return _innerController.nestedPositions;
  }

現(xiàn)在再運(yùn)行 demo 晌砾, 切換列表之后滾動看看坎拐,是否??了?結(jié)果是失望的养匈。

  1. 雖然我們在 drag 操作的時候哼勇,確實(shí)可以判斷到誰是激活的,但是手指 up 呕乎,開始慣性滑動的時候积担,dragCancelCallback 回調(diào)已經(jīng)觸發(fā),_isActived 已經(jīng)被設(shè)置為 false 猬仁。
  2. 當(dāng)我們在操作 PageView 上方黃色區(qū)域的時候(通常情況下磅轻,這部分可能是 Tabbar ), 由于沒有在列表上面進(jìn)行 drag 操作,所以這個時候 actived 的列表為 0.
      NestedScrollView(
        headerSliverBuilder: (
          BuildContext buildContext,
          bool innerBoxIsScrolled,
        ) =>
            <Widget>[
          SliverToBoxAdapter(
            child: Container(
              color: Colors.red,
              height: 200,
            ),
          )
        ],
        body: Column(
          children: [
            Container(
              color: Colors.yellow,
              height: 200,
            ),
            Expanded(
              child: PageView(
                children: <Widget>[
                  ListItem(
                    tag: 'Tab0',
                  ),
                  ListItem(
                    tag: 'Tab1',
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
是否可見

問題好像又走到了老地方逐虚,怎么判斷一個視圖是可見聋溜。

首先,我們這里能拿到最直接的就是 _NestedScrollPosition叭爱,我們看看這個家伙有什么東西可以利用撮躁。

一眼就看到了 context(ScrollableState),是一個 ScrollContext买雾,而 ScrollableState 實(shí)現(xiàn)了 ScrollContext把曼。

  /// Where the scrolling is taking place.
  ///
  /// Typically implemented by [ScrollableState].
  final ScrollContext context;

看一眼 ScrollContextnotificationContextstorageContext 應(yīng)該是相關(guān)的漓穿。

abstract class ScrollContext {
  /// The [BuildContext] that should be used when dispatching
  /// [ScrollNotification]s.
  ///
  /// This context is typically different that the context of the scrollable
  /// widget itself. For example, [Scrollable] uses a context outside the
  /// [Viewport] but inside the widgets created by
  /// [ScrollBehavior.buildOverscrollIndicator] and [ScrollBehavior.buildScrollbar].
  BuildContext? get notificationContext;

  /// The [BuildContext] that should be used when searching for a [PageStorage].
  ///
  /// This context is typically the context of the scrollable widget itself. In
  /// particular, it should involve any [GlobalKey]s that are dynamically
  /// created as part of creating the scrolling widget, since those would be
  /// different each time the widget is created.
  // TODO(goderbauer): Deprecate this when state restoration supports all features of PageStorage.
  BuildContext get storageContext;

  /// A [TickerProvider] to use when animating the scroll position.
  TickerProvider get vsync;

  /// The direction in which the widget scrolls.
  AxisDirection get axisDirection;

  /// 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);

  /// Whether the user can drag the widget, for example to initiate a scroll.
  void setCanDrag(bool value);

  /// Set the [SemanticsAction]s that should be expose to the semantics tree.
  void setSemanticsActions(Set<SemanticsAction> actions);

  /// Called by the [ScrollPosition] whenever scrolling ends to persist the
  /// provided scroll `offset` for state restoration purposes.
  ///
  /// The [ScrollContext] may pass the value back to a [ScrollPosition] by
  /// calling [ScrollPosition.restoreOffset] at a later point in time or after
  /// the application has restarted to restore the scroll offset.
  void saveOffset(double offset);
}

再看看 ScrollableState 中的實(shí)現(xiàn)嗤军。

class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, RestorationMixin
    implements ScrollContext {
 
  @override
  BuildContext? get notificationContext => _gestureDetectorKey.currentContext;

  @override
  BuildContext get storageContext => context; 
    
}    
  • storageContext 其實(shí)是
    ScrollableStatecontext

  • notificationContext 查找下引用晃危,可以看到叙赚。

果然老客,誰觸發(fā)的事件,當(dāng)然是 ScrollableState 里面的 RawGestureDetector 震叮。

    NotificationListener<ScrollNotification>(
      onNotification: (ScrollNotification scrollNotification) {
  /// The build context of the widget that fired this notification.
  ///
  /// This can be used to find the scrollable's render objects to determine the
  /// size of the viewport, for instance.
  // final BuildContext? context;
        print(scrollNotification.context);
        return false; 
      },
    );

最終我們還是要在 storageContext 上面下功夫了胧砰。之前 # Flutter Sliver一生之?dāng)?/a> # 系列里面我們對 Sliver 相關(guān)知識進(jìn)行過梳理。對于 TabbarView 或者 PageView 當(dāng)前顯示的元素苇瓣,在 RenderSliverFillViewport 當(dāng)中應(yīng)該是唯一的(除非你把 viewportFraction 的值設(shè)置為小于 1 的數(shù)值 )尖昏。我們可以通過 _NestedScrollPosition
Context 向上找到 RenderSliverFillViewport焊刹,看看 RenderSliverFillViewport 中的 child 是否為 _NestedScrollPositionContext 狐史。

  • 修改 _NestedScrollCoordinator._innerPositions 為如下:

  Iterable<_NestedScrollPosition> get _innerPositions {
    if (_innerController.nestedPositions.length > 1) {
      final Iterable<_NestedScrollPosition> actived = _innerController
          .nestedPositions
          .where((_NestedScrollPosition element) => element.isActived);
      if (actived.isEmpty) {
        for (final _NestedScrollPosition scrollPosition
            in _innerController.nestedPositions) {
          final RenderObject? renderObject =
              scrollPosition.context.storageContext.findRenderObject();

          if (renderObject == null || !renderObject.attached) {
            continue;
          }

          if (renderObjectIsVisible(renderObject, Axis.horizontal)) {
            return <_NestedScrollPosition>[scrollPosition];
          }
        }
        return _innerController.nestedPositions;
      }

      return actived;
    } else {
      return _innerController.nestedPositions;
    }
  }
  • renderObjectIsVisible 方法中查看是否存在于 TabbarView 或者 PageView 中罢低,并且其 axisScrollPositionaxis 相垂直。如果有的話媳禁,用 RenderViewport 當(dāng)前的 child 調(diào)用 childIsVisible 方法驗(yàn)證是否包含 ScrollPosition 所對應(yīng)的 RenderObject撤蚊。注意,這里調(diào)用了 renderObjectIsVisible 因?yàn)榭赡苡星短?多級)的 TabbarView 或者 PageView损话。
  bool renderObjectIsVisible(RenderObject renderObject, Axis axis) {
    final RenderViewport? parent = findParentRenderViewport(renderObject);
    if (parent != null && parent.axis == axis) {
      for (final RenderSliver childrenInPaint
          in parent.childrenInHitTestOrder) {
        return childIsVisible(childrenInPaint, renderObject) &&
            renderObjectIsVisible(parent, axis);
      }
    }
    return true;
  }
  • 向上尋找 RenderViewport ,我們只在 NestedScrollViewbody 的中找槽唾,直到 _ExtendedRenderSliverFillRemainingWithScrollable丧枪。
  RenderViewport? findParentRenderViewport(RenderObject? object) {
    if (object == null) {
      return null;
    }
    object = object.parent as RenderObject?;
    while (object != null) {
      // 只在 body 中尋找
      if (object is _ExtendedRenderSliverFillRemainingWithScrollable) {
        return null;
      }
      if (object is RenderViewport) {
        return object;
      }
      object = object.parent as RenderObject?;
    }
    return null;
  }
  • 調(diào)用 visitChildrenForSemantics 遍歷 children,看是否能找到 ScrollPosition 所對應(yīng)的 RenderObject
    /// Return whether renderObject is visible in parent
  bool childIsVisible(
    RenderObject parent,
    RenderObject renderObject,
  ) {
    bool visible = false;

    // The implementation has to return the children in paint order skipping all
    // children that are not semantically relevant (e.g. because they are
    // invisible).
    parent.visitChildrenForSemantics((RenderObject child) {
      if (renderObject == child) {
        visible = true;
      } else {
        visible = childIsVisible(child, renderObject);
      }
    });
    return visible;
  }

還有其他方案嗎

其實(shí)對于 Body 中多列表滾動互相影響的問題
庞萍,如果你只是要求列表保持位置的話拧烦,你完全可以利用 PageStorageKey 來保持滾動列表的位置。這樣的話钝计,TabbarView 或者 PageView 切換的時候恋博,ScrollableStatedispose,并且從將 ScrollPositioninnerControllerdetach 掉私恬。

  @override
  void dispose() {
    if (widget.controller != null) {
      widget.controller!.detach(position);
    } else {
      _fallbackScrollController?.detach(position);
      _fallbackScrollController?.dispose();
    }

    position.dispose();
    _persistedScrollOffset.dispose();
    super.dispose();
  }

而你需要做的是在上一層债沮,利用比如
provider | Flutter Package (flutter-io.cn) 來保持列表數(shù)據(jù)或者其他數(shù)據(jù)狀態(tài)。

   NestedScrollView(
        headerSliverBuilder: (
          BuildContext buildContext,
          bool innerBoxIsScrolled,
        ) =>
            <Widget>[
          SliverToBoxAdapter(
            child: Container(
              color: Colors.red,
              height: 200,
            ),
          )
        ],
        body: Column(
          children: <Widget>[
            Container(
              color: Colors.yellow,
              height: 200,
            ),
            Expanded(
              child: PageView(
                //controller: PageController(viewportFraction: 0.8),
                children: <Widget>[
                  ListView.builder(
                    //store Page state
                    key: const PageStorageKey<String>('Tab0'),
                    physics: const ClampingScrollPhysics(),
                    itemBuilder: (BuildContext c, int i) {
                      return Container(
                        alignment: Alignment.center,
                        height: 60.0,
                        child:
                            Text(const Key('Tab0').toString() + ': ListView$i'),
                      );
                    },
                    itemCount: 50,
                  ),
                  ListView.builder(
                    //store Page state
                    key: const PageStorageKey<String>('Tab1'),
                    physics: const ClampingScrollPhysics(),
                    itemBuilder: (BuildContext c, int i) {
                      return Container(
                        alignment: Alignment.center,
                        height: 60.0,
                        child:
                            Text(const Key('Tab1').toString() + ': ListView$i'),
                      );
                    },
                    itemCount: 50,
                  ),
                ],
              ),
            ),
          ],
        ),
      ),

重構(gòu)代碼

體力活

3年不知不覺就寫了 18 個 Flutter 組件庫和 3 個 Flutter 相關(guān) 工具本鸣。

  1. like_button | Flutter Package (flutter-io.cn)

  2. extended_image_library | Flutter Package (pub.dev)

  3. extended_nested_scroll_view | Flutter Package (flutter-io.cn)

  4. extended_text | Flutter Package (flutter-io.cn)

  5. extended_text_field | Flutter Package (flutter-io.cn)

  6. extended_image | Flutter Package (flutter-io.cn)

  7. extended_sliver | Flutter Package (flutter-io.cn)

  8. pull_to_refresh_notification | Flutter Package (flutter-io.cn)

  9. waterfall_flow | Flutter Package (flutter-io.cn)

  10. loading_more_list | Flutter Package (flutter-io.cn)

  11. extended_tabs | Flutter Package (flutter-io.cn)

  12. http_client_helper | Dart Package (flutter-io.cn)

  13. extended_text_library | Flutter Package (flutter-io.cn)

  14. extended_list | Flutter Package (flutter-io.cn)

  15. extended_list_library | Flutter Package (flutter-io.cn)

  16. ff_annotation_route_library | Flutter Package (flutter-io.cn)

  17. loading_more_list_library | Dart Package (flutter-io.cn)

  18. ff_annotation_route | Dart Package (flutter-io.cn)

  19. ff_annotation_route_core | Dart Package (flutter-io.cn)

  20. flex_grid | Flutter Package (flutter-io.cn)

  21. assets_generator | Dart Package (flutter-io.cn)

  22. fluttercandies/JsonToDart: The tool to convert json to dart code, support Windows疫衩,Mac,Web. (github.com)

可以說每一次官方發(fā)布 Stable 版本荣德,對于我來說都是一次體力活闷煤。特別是 extended_nested_scroll_viewextended_text
, extended_text_field
, extended_image 這 4 個庫涮瞻,merge 代碼是不光是體力活鲤拿,也需要認(rèn)真仔細(xì)去理解新改動。

結(jié)構(gòu)重構(gòu)

這次乘著這個改動的機(jī)會署咽,我將整個結(jié)構(gòu)做了調(diào)整近顷。

  • src/extended_nested_scroll_view.dart 為官方源碼,只做了一些必要改動。比如增加參數(shù)幕庐,替換擴(kuò)展類型久锥。最大程度的保持官方源碼的結(jié)構(gòu)和格式。

  • src/extended_nested_scroll_view_part.dart 為擴(kuò)展官方組件功能的部分代碼异剥。增加下面3個擴(kuò)展類瑟由,實(shí)現(xiàn)我們相應(yīng)的擴(kuò)展方法。

class _ExtendedNestedScrollCoordinator extends _NestedScrollCoordinator
class _ExtendedNestedScrollController extends _NestedScrollController
class _ExtendedNestedScrollPosition extends _NestedScrollPosition

最后在 src/extended_nested_scroll_view.dart 修改初始化代碼即可冤寿。以后我只需要用 src/extended_nested_scroll_view.dart 跟官方的代碼進(jìn)行 merge 即可歹苦。

  _NestedScrollCoordinator? _coordinator;

  @override
  void initState() {
    super.initState();
    _coordinator = _ExtendedNestedScrollCoordinator(
      this,
      widget.controller,
      _handleHasScrolledBodyChanged,
      widget.floatHeaderSlivers,
      widget.pinnedHeaderSliverHeightBuilder,
      widget.onlyOneScrollInBody,
      widget.scrollDirection,
    );
  }

小糖果??

如果你看到這里,已經(jīng)看了6000字督怜,感謝殴瘦。送上一些的技巧,希望能對你有所幫助号杠。

CustomScrollView center

CustomScrollView.center 這個屬性我其實(shí)很早之前就講過了蚪腋,
Flutter Sliver一生之?dāng)?(ScrollView) (juejin.cn)
簡單地來說:

  • center 是開始繪制的地方姨蟋,既繪制在 zero scroll offset 的地方屉凯, 向前為負(fù),向后為正眼溶。
  • center 之前的 Sliver 是倒序繪制悠砚。

比如下面代碼,你覺得最終的效果是什么樣子的堂飞?

    CustomScrollView(
        center: key,
        slivers: <Widget>[
        SliverList(),
        SliverGrid(key:key),
        ]
    )

效果圖如下灌旧,SliverGrid 被繪制在了開始位置。你可以向下滾動绰筛,這個時候枢泰,上面的 SliverList 才會展示。

CustomScrollView.anchor 可以控制 center 的位置铝噩。
0 為 viewport 的 leading宗苍,1 為 viewport 的 trailing,既這個是 viewport 高度垂直(寬度水平)的占比薄榛。比如如果是 0.5讳窟,那么繪制 SliverGrid 的地方就會在 viewport 的中間位置。

通過這2個屬性敞恋,我們可以創(chuàng)造一些有趣的效果丽啡。

聊天列表

flutter_instant_messaging/main.dart at master · fluttercandies/flutter_instant_messaging (github.com) 一年前寫的小 demo,現(xiàn)在移到 flutter_challenges/chat_sample.dart at main · fluttercandies/flutter_challenges (github.com) 統(tǒng)一維護(hù)硬猫。

ios 倒序相冊

flutter_challenges/ios_photo album.dart at main · fluttercandies/flutter_challenges (github.com) 代碼在此补箍。

起源于馬師傅給 wechat_assets_picker | Flutter Package (flutter-io.cn)提的需求(尾款都沒有結(jié))改执,要讓相冊查看效果跟 Ios 原生的一樣。 Ios 的設(shè)計(jì)果然不一樣坑雅,學(xué)習(xí)(chao)就是了辈挂。

斗魚首頁滾動效果

flutter_challenges/float_scroll.dart at main · fluttercandies/flutter_challenges (github.com) 代碼在此。

不得不再提提裹粤,NotificationListener终蒂,它是 Notification 的監(jiān)聽者。通過 Notification.dispatch 遥诉,通知會沿著當(dāng)前節(jié)點(diǎn)(BuildContext)向上傳遞拇泣,就跟冒泡一樣,你可以在父節(jié)點(diǎn)使用 NotificationListener 來接受通知矮锈。 Flutter 中經(jīng)常使用到的是 ScrollNotification霉翔,除此之外還有SizeChangedLayoutNotificationKeepAliveNotification 苞笨、LayoutChangedNotification 等债朵。你也可以自己定義一個通知。

import 'package:flutter/material.dart';
import 'package:oktoast/oktoast.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return OKToast(
      child: MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
          visualDensity: VisualDensity.adaptivePlatformDensity,
        ),
        home: MyHomePage(),
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key key}) : super(key: key);

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return NotificationListener<TextNotification>(
      onNotification: (TextNotification notification) {
        showToast('星星收到了通知: ${notification.text}');
        return true;
      },
      child: Scaffold(
          appBar: AppBar(),
          body: NotificationListener<TextNotification>(
            onNotification: (TextNotification notification) {
              showToast('大寶收到了通知: ${notification.text}');
              // 如果這里改成 true, 星星就收不到信息了瀑凝,
              return false;
            },
            child: Center(
              child: Builder(
                builder: (BuildContext context) {
                  return RaisedButton(
                    onPressed: () {
                      TextNotification('下班了!')..dispatch(context);
                    },
                    child: Text('點(diǎn)我'),
                  );
                },
              ),
            ),
          )),
    );
  }
}

class TextNotification extends Notification {
  TextNotification(this.text);
  final String text;
}

而我們經(jīng)常使用的下拉刷新和上拉加載更多的組件也可以通過監(jiān)聽 ScrollNotification 來完成序芦。

pull_to_refresh_notification | Flutter Package (flutter-io.cn)

loading_more_list | Flutter Package (flutter-io.cn)

ScrollPosition.ensureVisible

要完成這個操作,應(yīng)該大部分人都是會的猜丹。其實(shí)萬變不離其中,通過當(dāng)前對象的 RenderObject 去找到對應(yīng)的 RenderAbstractViewport硅卢,然后通過 getOffsetToReveal 方法獲取相對位置射窒。

  /// Animates the position such that the given object is as visible as possible
  /// by just scrolling this position.
  ///
  /// See also:
  ///
  ///  * [ScrollPositionAlignmentPolicy] for the way in which `alignment` is
  ///    applied, and the way the given `object` is aligned.
  Future<void> ensureVisible(
    RenderObject object, {
    double alignment = 0.0,
    Duration duration = Duration.zero,
    Curve curve = Curves.ease,
    ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
  }) {
    assert(alignmentPolicy != null);
    assert(object.attached);
    final RenderAbstractViewport viewport = RenderAbstractViewport.of(object);
    assert(viewport != null);

    double target;
    switch (alignmentPolicy) {
      case ScrollPositionAlignmentPolicy.explicit:
        target = viewport.getOffsetToReveal(object, alignment).offset.clamp(minScrollExtent, maxScrollExtent) as double;
        break;
      case ScrollPositionAlignmentPolicy.keepVisibleAtEnd:
        target = viewport.getOffsetToReveal(object, 1.0).offset.clamp(minScrollExtent, maxScrollExtent) as double;
        if (target < pixels) {
          target = pixels;
        }
        break;
      case ScrollPositionAlignmentPolicy.keepVisibleAtStart:
        target = viewport.getOffsetToReveal(object, 0.0).offset.clamp(minScrollExtent, maxScrollExtent) as double;
        if (target > pixels) {
          target = pixels;
        }
        break;
    }

    if (target == pixels)
      return Future<void>.value();

    if (duration == Duration.zero) {
      jumpTo(target);
      return Future<void>.value();
    }

    return animateTo(target, duration: duration, curve: curve);
  }

Demo 代碼地址: ensureVisible 演示 (github.com)

留個問題,當(dāng)你點(diǎn)擊 點(diǎn)我跳轉(zhuǎn)頂部,我是固定的 這個按鈕的時候将塑,你猜會發(fā)生什么現(xiàn)象脉顿。

Flutter 挑戰(zhàn)

之前跟掘金官方提過,是否可以增加 你問我答/ 你出題我挑戰(zhàn) 模塊点寥,增加程序員之間的交流艾疟,程序員都是不服輸?shù)模瑧?yīng)該會 ?? 吧敢辩? 想想都刺激蔽莱。我創(chuàng)建一個新的 FlutterChallenges qq 群 321954965 來進(jìn)行交流;倉庫戚长,用來討論和存放這些小挑戰(zhàn)代碼盗冷。平時收集一些平時有一些難度的實(shí)際場景例子,不單單只是秀技術(shù)同廉。進(jìn)群需要通過推薦或者驗(yàn)證仪糖,歡迎喜歡折騰自己的童鞋
柑司。

情人節(jié) + 七夕 這是不是個巧合 ?锅劝?

美團(tuán)餓了么點(diǎn)餐頁面

要求:

  1. 左右2個列表能聯(lián)動攒驰,整個首頁上下滾動聯(lián)動
  2. 通用性,可成組件

如果你認(rèn)真看完了 NestedScrollView故爵,我想應(yīng)該有辦法來做這種功能了玻粪。

增大點(diǎn)擊區(qū)域

增加點(diǎn)擊區(qū)域,這應(yīng)該是平時應(yīng)該會遇到的需求稠集,那么在 Flutter 中應(yīng)該怎么實(shí)現(xiàn)呢奶段?

原始代碼地址: 增大點(diǎn)擊區(qū)域 (github.com)

為了測試方便,請?zhí)砑釉?pubspec.yaml 中 添加財經(jīng)龍大佬的 oktoast 剥纷。

  oktoast: any

要求:

  1. 不要改變整個結(jié)構(gòu)和尺寸痹籍。
  2. 不要直接 Stack 把整個 Item 重寫。
  3. 通用性晦鞋。

完成效果如下, 擴(kuò)大的范圍理論上可以隨意設(shè)置蹲缠。

結(jié)語

這篇寫的比較多,想到了什么就寫悠垛。不管是什么技術(shù)线定,只有深入了才能領(lǐng)會其中的道理。維護(hù)開源組件确买,確實(shí)是一件很累的事情斤讥。但是這會不斷強(qiáng)迫你去學(xué)習(xí),在不停更新迭代當(dāng)中湾趾,你都會學(xué)習(xí)到一些平時不容易接觸到的知識芭商。積沙成塔,擼遍 Flutter 源碼不再是夢想搀缠。

Flutter铛楣,愛糖果,歡迎加入[Flutter Candies]

最最后放上 Flutter Candies 全家桶艺普,真香簸州。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市歧譬,隨后出現(xiàn)的幾起案子岸浑,更是在濱河造成了極大的恐慌,老刑警劉巖瑰步,帶你破解...
    沈念sama閱讀 206,723評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件助琐,死亡現(xiàn)場離奇詭異,居然都是意外死亡面氓,警方通過查閱死者的電腦和手機(jī)兵钮,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,485評論 2 382
  • 文/潘曉璐 我一進(jìn)店門蛆橡,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人掘譬,你說我怎么就攤上這事泰演。” “怎么了葱轩?”我有些...
    開封第一講書人閱讀 152,998評論 0 344
  • 文/不壞的土叔 我叫張陵睦焕,是天一觀的道長。 經(jīng)常有香客問我靴拱,道長垃喊,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,323評論 1 279
  • 正文 為了忘掉前任袜炕,我火速辦了婚禮本谜,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘偎窘。我一直安慰自己乌助,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,355評論 5 374
  • 文/花漫 我一把揭開白布陌知。 她就那樣靜靜地躺著他托,像睡著了一般。 火紅的嫁衣襯著肌膚如雪仆葡。 梳的紋絲不亂的頭發(fā)上赏参,一...
    開封第一講書人閱讀 49,079評論 1 285
  • 那天,我揣著相機(jī)與錄音沿盅,去河邊找鬼把篓。 笑死,一個胖子當(dāng)著我的面吹牛嗡呼,可吹牛的內(nèi)容都是我干的纸俭。 我是一名探鬼主播皇耗,決...
    沈念sama閱讀 38,389評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼南窗,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了郎楼?” 一聲冷哼從身側(cè)響起万伤,我...
    開封第一講書人閱讀 37,019評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎呜袁,沒想到半個月后敌买,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,519評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡阶界,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,971評論 2 325
  • 正文 我和宋清朗相戀三年虹钮,在試婚紗的時候發(fā)現(xiàn)自己被綠了聋庵。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,100評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡芙粱,死狀恐怖祭玉,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情春畔,我是刑警寧澤脱货,帶...
    沈念sama閱讀 33,738評論 4 324
  • 正文 年R本政府宣布,位于F島的核電站律姨,受9級特大地震影響振峻,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜择份,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,293評論 3 307
  • 文/蒙蒙 一扣孟、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧缓淹,春花似錦哈打、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,289評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至伏蚊,卻和暖如春立轧,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背躏吊。 一陣腳步聲響...
    開封第一講書人閱讀 31,517評論 1 262
  • 我被黑心中介騙來泰國打工氛改, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人比伏。 一個月前我還...
    沈念sama閱讀 45,547評論 2 354
  • 正文 我出身青樓胜卤,卻偏偏與公主長得像,于是被迫代替她去往敵國和親赁项。 傳聞我的和親對象是個殘疾皇子葛躏,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,834評論 2 345

推薦閱讀更多精彩內(nèi)容