前言
extended_nested_scroll_view 是我的第一個(gè)上傳到 pub.dev 的 Flutter 組件.
一晃眼都快3年了,經(jīng)歷了43個(gè)版本迭代,功能穩(wěn)定,代碼與官方同步。
而我最近一直籌備著對其進(jìn)行重構(gòu)。怎么說了,接觸 Flutter 3年了,認(rèn)知也與當(dāng)初有所不同。我相信自己如果現(xiàn)在再面對 NestedScrollView 的問題,我應(yīng)該能處理地更好。
注意: 后面用到的 SliverPinnedToBoxAdapter 是 extended_sliver里面一個(gè)組件,你把它當(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.
將外部滾動(dòng)(Header部分)和內(nèi)部滾動(dòng)(Body部分)聯(lián)動(dòng)起來。里面滾動(dòng)不了,滾動(dòng)外面。外面滾動(dòng)沒了,滾動(dòng)里面。那么 NestedScrollView 是如何做到的呢?
NestedScrollView 其實(shí)是一個(gè) CustomScrollView, 下面為偽代碼。
CustomScrollView(
controller: outerController,
slivers: [
...<Widget>[Header1,Header2],
SliverFillRemaining()(
child: PrimaryScrollController(
controller: innerController,
child: body,
),
),
],
);
- outerController 是
CustomScrollView的controller, 從層級上看,就是外部 - 這里使用了
PrimaryScrollController,那么body里面的任何滾動(dòng)組件,在不自定義controller的情況下,都將公用innerController。
至于為什么會這樣,首先看一下每個(gè)滾動(dòng)組件都有的屬性 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 中的滾動(dòng)組件設(shè)置了 controller,就會發(fā)現(xiàn)內(nèi)外滾動(dòng)不再聯(lián)動(dòng)了。
為什么要擴(kuò)展官方的
理解了 NestedScrollView 是什么,那我為啥要擴(kuò)展官方組件呢?
Header 中包含多個(gè) Pinned Sliver 時(shí)候的問題
分析
先看一個(gè)圖,你覺得列表向上滾動(dòng)最終的結(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,
)),
)),
),
)
],
),
嗯,沒錯(cuò),列表的第一個(gè) Item 會滾動(dòng)到 Header1 下面。但實(shí)際上,我們通常的需求是需要列表停留在 Header1 底邊。
Flutter 官方也注意到了這個(gè)問題,并且提供了 SliverOverlapAbsorber
SliverOverlapInjector 來處理這個(gè)問題,
-
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á)。我們也增加一個(gè) 100 的占位。不過實(shí)際操作中是不可能這樣做的,這樣會導(dǎo)致初始化的時(shí)候列表上方會留下 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 中包含多個(gè) Pinned 為 true 的 Sliver, 那么 SliverOverlapAbsorber 便無能為力了,Issue 傳送門。
解決
我們再來回顧 NestedScrollView 長什么樣子的,可以看出來,這個(gè)問題應(yīng)該跟 outerController 有關(guān)系。參照前面簡單 demo 來看,只要我們讓外部少滾動(dòng) 100,就可以讓列表停留在 Pinned Header1 底部了。
CustomScrollView(
controller: outerController,
slivers: [
...<Widget>[Header1,Header2],
SliverFillRemaining()(
child: PrimaryScrollController(
controller: innerController,
child: body,
),
),
],
);
maxScrollExtent
我們再思考一下,是什么會影響一個(gè)滾動(dòng)組件的滾動(dòng)最終距離?
知道了是什么東西影響,我們要做的就是在合適的時(shí)候修改這個(gè)值,那么如何獲取時(shí)機(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),看看是什么時(shí)候 _maxScrollExtent 被賦值的。
運(yùn)行例子 ,得到以下 Call Stack。
看到這里,我們應(yīng)該知道,可以通過 override applyContentDimensions 方法,去重新設(shè)置 maxScrollExtent
ScrollPosition
想要 override applyContentDimensions 就要知道 ScrollPosition 在什么時(shí)候創(chuàng)建的,繼續(xù)調(diào)試, 把斷點(diǎn)打到 ScrollPosition 的構(gòu)造上面。
graph TD
ScrollController.createScrollPosition --> ScrollPositionWithSingleContext --> ScrollPosition
可以看到如果不是特定的 ScrollPosition,我們平時(shí)使用的是默認(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 - 如果有多個(gè)這種 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 中多列表滾動(dòng)互相影響的問題
大家一定有這種需求,在 TabbarView 或者 PageView 中的列表,切換的時(shí)候列表的滾動(dòng)位置要保留。這個(gè)使用 AutomaticKeepAliveClientMixin,非常簡單。
但是如果把 TabbarView 或者 PageView 放到NestedScrollView 的 body 里面的話,你滾動(dòng)其中一個(gè)列表,也會發(fā)現(xiàn)其他的列表也會跟著改變位置。Issue 傳送門
分析
先看 NestedScrollView 的偽代碼。NestedScrollView 之所以能上內(nèi)外聯(lián)動(dòng),就是在于 outerController 和 innerController 的聯(lián)動(dòng)。
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)使用列表緩存的時(shí)候,切換 tab 的時(shí)候,原列表將不會
dispose,就不會從 controller 中detach。 innerController.positions 將不止一個(gè)。而outerController和innerController的聯(lián)動(dòng)計(jì)算都是基于 positions 來進(jìn)行的。這就是導(dǎo)致這個(gè)問題的原因。
具體代碼體現(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)在再看這個(gè)問題,第一感覺,不就是只要找到當(dāng)前
顯示的那個(gè)列表,只讓它滾動(dòng)就可以了嘛,不是很簡單嗎?
確實(shí),但是那只是看起來覺得簡單,畢竟這個(gè) issue 已經(jīng) open 3年了。
老方案
在
ScrollPositionattach的時(shí)候去通過context找到這個(gè)列表所對應(yīng)的標(biāo)志,跟TabbarView或者PageView的 index 關(guān)聯(lián)進(jìn)行對比。
Flutter 擴(kuò)展NestedScrollView (二)列表滾動(dòng)同步解決 (juejin.cn)通過計(jì)算列表的相對位置,來確定當(dāng)前
顯示的列表。
Flutter 你想知道的Widget可視區(qū)域,相對位置,大小 (juejin.cn)
總體來說,
- 1方案更準(zhǔn)確,但是用法比較繁瑣。
- 2方案受動(dòng)畫影響,在一些特殊的情況下會導(dǎo)致計(jì)算不正確。
新方案
首先我們先準(zhǔn)備一個(gè)的 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)在再看這個(gè)問題,我在思考,我自己滾動(dòng)了哪個(gè)列表,我自己不知道??
看過上一篇 Flutter 鎖定行列的FlexGrid - 掘金 (juejin.cn) 的小伙伴,應(yīng)該知道在拖拽列表的時(shí)候是會生成一個(gè) Drag 的。那么有這個(gè) Drag 的 ScrollPosition 不就對應(yīng)正在顯示的列表嗎??
具體到代碼,我們試試打日志看看,
@override
Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
print(debugLabel);
return coordinator.drag(details, dragCancelCallback);
}
理想很好,但是現(xiàn)實(shí)是骨感的,不管我是滾動(dòng) 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 的時(shí)候,我們是沒有注冊手勢的。當(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 方法中打一個(gè)斷點(diǎn),看看調(diào)用的時(shí)機(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));
}
這里提了一下,平時(shí)有同學(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);
}
知道原因之后,我們試試動(dòng)手改下。
- 修改
_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 , 切換列表之后滾動(dòng)看看,是否??了?結(jié)果是失望的。
- 雖然我們在
drag操作的時(shí)候,確實(shí)可以判斷到誰是激活的,但是手指 up ,開始慣性滑動(dòng)的時(shí)候,dragCancelCallback回調(diào)已經(jīng)觸發(fā),_isActived已經(jīng)被設(shè)置為false。 - 當(dāng)我們在操作
PageView上方黃色區(qū)域的時(shí)候(通常情況下,這部分可能是Tabbar), 由于沒有在列表上面進(jìn)行drag操作,所以這個(gè)時(shí)候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',
),
],
),
),
],
),
),
是否可見
問題好像又走到了老地方,怎么判斷一個(gè)視圖是可見。
首先,我們這里能拿到最直接的就是 _NestedScrollPosition,我們看看這個(gè)家伙有什么東西可以利用。
一眼就看到了 context(ScrollableState),是一個(gè) 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 中多列表滾動(dòng)互相影響的問題
,如果你只是要求列表保持位置的話,你完全可以利用 PageStorageKey 來保持滾動(dòng)列表的位置。這樣的話,TabbarView 或者 PageView 切換的時(shí)候,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 個(gè) Flutter 組件庫和 3 個(gè) 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 個(gè)庫,merge 代碼是不光是體力活,也需要認(rèn)真仔細(xì)去理解新改動(dòng)。
結(jié)構(gòu)重構(gòu)
這次乘著這個(gè)改動(dòng)的機(jī)會,我將整個(gè)結(jié)構(gòu)做了調(diào)整。
src/extended_nested_scroll_view.dart為官方源碼,只做了一些必要改動(dòng)。比如增加參數(shù),替換擴(kuò)展類型。最大程度的保持官方源碼的結(jié)構(gòu)和格式。src/extended_nested_scroll_view_part.dart為擴(kuò)展官方組件功能的部分代碼。增加下面3個(gè)擴(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 這個(gè)屬性我其實(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 被繪制在了開始位置。你可以向下滾動(dòng),這個(gè)時(shí)候,上面的 SliverList 才會展示。
CustomScrollView.anchor 可以控制 center 的位置。
0 為 viewport 的 leading,1 為 viewport 的 trailing,既這個(gè)是 viewport 高度垂直(寬度水平)的占比。比如如果是 0.5,那么繪制 SliverGrid 的地方就會在 viewport 的中間位置。
通過這2個(gè)屬性,我們可以創(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)就是了。
斗魚首頁滾動(dòng)效果
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 等。你也可以自己定義一個(gè)通知。
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
要完成這個(gè)操作,應(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)
留個(gè)問題,當(dāng)你點(diǎn)擊 點(diǎn)我跳轉(zhuǎn)頂部,我是固定的 這個(gè)按鈕的時(shí)候,你猜會發(fā)生什么現(xiàn)象。
Flutter 挑戰(zhàn)
之前跟掘金官方提過,是否可以增加 你問我答/ 你出題我挑戰(zhàn) 模塊,增加程序員之間的交流,程序員都是不服輸?shù)?,?yīng)該會 ?? 吧? 想想都刺激。我創(chuàng)建一個(gè)新的 FlutterChallenges qq 群 321954965 來進(jìn)行交流;倉庫,用來討論和存放這些小挑戰(zhàn)代碼。平時(shí)收集一些平時(shí)有一些難度的實(shí)際場景例子,不單單只是秀技術(shù)。進(jìn)群需要通過推薦或者驗(yàn)證,歡迎喜歡折騰自己的童鞋
。
情人節(jié) + 七夕 這是不是個(gè)巧合 ??
美團(tuán)餓了么點(diǎn)餐頁面
要求:
- 左右2個(gè)列表能聯(lián)動(dòng),整個(gè)首頁上下滾動(dòng)聯(lián)動(dòng)
- 通用性,可成組件
如果你認(rèn)真看完了 NestedScrollView,我想應(yīng)該有辦法來做這種功能了。
增大點(diǎn)擊區(qū)域
增加點(diǎn)擊區(qū)域,這應(yīng)該是平時(shí)應(yīng)該會遇到的需求,那么在 Flutter 中應(yīng)該怎么實(shí)現(xiàn)呢?
原始代碼地址: 增大點(diǎn)擊區(qū)域 (github.com)
為了測試方便,請?zhí)砑釉?pubspec.yaml 中 添加財(cái)經(jīng)龍大佬的 oktoast 。
oktoast: any
要求:
- 不要改變整個(gè)結(jié)構(gòu)和尺寸。
- 不要直接
Stack把整個(gè)Item重寫。 - 通用性。
完成效果如下, 擴(kuò)大的范圍理論上可以隨意設(shè)置。
結(jié)語
這篇寫的比較多,想到了什么就寫。不管是什么技術(shù),只有深入了才能領(lǐng)會其中的道理。維護(hù)開源組件,確實(shí)是一件很累的事情。但是這會不斷強(qiáng)迫你去學(xué)習(xí),在不停更新迭代當(dāng)中,你都會學(xué)習(xí)到一些平時(shí)不容易接觸到的知識。積沙成塔,擼遍 Flutter 源碼不再是夢想。
愛 Flutter,愛糖果,歡迎加入[Flutter Candies]
最最后放上 Flutter Candies 全家桶,真香。