前言
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)該能處理地更好腐魂。
注意: 后面用到的 SliverPinnedToBoxAdapter
是 extended_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 是
CustomScrollView
的controller
, 從層級上看豪嗽,就是外部 - 這里使用了
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
來包裹Pinned
為true
的Sliver
- 在 body 中使用
SliverOverlapInjector
來占位 - 用
NestedScrollView._absorberHandle
來實(shí)現(xiàn)SliverOverlapAbsorber
和SliverOverlapInjector
的信息傳遞要拂。
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,
)),
)),
),
],
),
)
],
),
那問題來了舅踪,如果 NestedScrollView
的 Header
中包含多個 Pinned
為 true
的 Sliver
纽甘, 那么 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
我們再思考一下赏迟,是什么會影響一個滾動組件的滾動最終距離?
知道了是什么東西影響蠢棱,我們要做的就是在合適的時候修改這個值锌杀,那么如何獲取時機(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
垒探,并且在 ScrollController
的 createScrollPosition
方法中創(chuàng)建。
增加下面的代碼怠李,并且給 demo 中的 CustomScrollView
添加 controller
為 MyScrollController
圾叼,我們再次運(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)中一共有哪些 Pinned
的 Sliver
髓介。
- 對于 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
放到NestedScrollView
的 body
里面的話价淌,你滾動其中一個列表申眼,也會發(fā)現(xiàn)其他的列表也會跟著改變位置。Issue 傳送門
分析
先看 NestedScrollView
的偽代碼蝉衣。NestedScrollView
之所以能上內(nèi)外聯(lián)動括尸,就是在于 outerController
和 innerController
的聯(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 將不止一個娶眷。而outerController
和innerController
的聯(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年了轧铁。
老方案
在
ScrollPosition
attach
的時候去通過context
找到這個列表所對應(yīng)的標(biāo)志,跟TabbarView
或者PageView
的 index 關(guān)聯(lián)進(jìn)行對比旦棉。
Flutter 擴(kuò)展NestedScrollView (二)列表滾動同步解決 (juejin.cn)通過計(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
的。那么有這個 Drag
的 ScrollPosition
不就對應(yīng)正在顯示的列表嗎薪寓??
具體到代碼澜共,我們試試打日志看看向叉,
@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ī)皮迟。
- 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)),
))
- 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;
}
- 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 放
AlwaysScrollableScrollPhysics
的 shouldAcceptUserOffset
方法永遠(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;
}
- ScrollableState.setCanDrag
最終達(dá)到這里,去根據(jù) canDrag
和 axis
(水平/垂直)
_NestedScrollCoordinator
那接下來徒役,我們就去 NestedScrollView
代碼里面找找看孽尽。
@override
void applyNewDimensions() {
super.applyNewDimensions();
coordinator.updateCanDrag();
}
這里我們看到調(diào)用了 coordinator.updateCanDrag()
。
首先我們看看 coordinator
是什么忧勿?不難看出來杉女,用來協(xié)調(diào) outerController
和 innerController
的。
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é)果是失望的养匈。
- 雖然我們在
drag
操作的時候哼勇,確實(shí)可以判斷到誰是激活的,但是手指 up 呕乎,開始慣性滑動的時候积担,dragCancelCallback
回調(diào)已經(jīng)觸發(fā),_isActived
已經(jīng)被設(shè)置為false
猬仁。 - 當(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;
看一眼 ScrollContext
,notificationContext
和 storageContext
應(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í)是
ScrollableState
的context
。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;
},
);
- 修改
_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
中罢低,并且其axis
與ScrollPosition
的axis
相垂直。如果有的話媳禁,用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
,我們只在NestedScrollView
的body
的中找槽唾,直到_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
切換的時候恋博,ScrollableState
會 dispose
,并且從將 ScrollPosition
從 innerController
中 detach
掉私恬。
@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) 工具本鸣。
extended_nested_scroll_view | Flutter Package (flutter-io.cn)
pull_to_refresh_notification | Flutter Package (flutter-io.cn)
ff_annotation_route_library | Flutter Package (flutter-io.cn)
可以說每一次官方發(fā)布 Stable
版本荣德,對于我來說都是一次體力活闷煤。特別是 extended_nested_scroll_view,extended_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
霉翔,除此之外還有SizeChangedLayoutNotification
、KeepAliveNotification
苞笨、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)餐頁面
要求:
- 左右2個列表能聯(lián)動攒驰,整個首頁上下滾動聯(lián)動
- 通用性,可成組件
如果你認(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
要求:
- 不要改變整個結(jié)構(gòu)和尺寸痹籍。
- 不要直接
Stack
把整個Item
重寫。 - 通用性晦鞋。
完成效果如下, 擴(kuò)大的范圍理論上可以隨意設(shè)置蹲缠。
結(jié)語
這篇寫的比較多,想到了什么就寫悠垛。不管是什么技術(shù)线定,只有深入了才能領(lǐng)會其中的道理。維護(hù)開源組件确买,確實(shí)是一件很累的事情斤讥。但是這會不斷強(qiáng)迫你去學(xué)習(xí),在不停更新迭代當(dāng)中湾趾,你都會學(xué)習(xí)到一些平時不容易接觸到的知識芭商。積沙成塔,擼遍 Flutter
源碼不再是夢想搀缠。
愛 Flutter
铛楣,愛糖果
,歡迎加入[Flutter Candies]
最最后放上 Flutter Candies 全家桶艺普,真香簸州。