一文讓你理清PrimaryScrollController
PrimaryScrollController的作用
對(duì)蘋果用戶來(lái)說(shuō),大家基本都知道,iOS手機(jī)應(yīng)用有一個(gè)比較常見(jiàn)的功能:點(diǎn)擊狀態(tài)欄,列表就會(huì)滾動(dòng)到頂部。
在iOS原生代碼中,我們可以通過(guò)原生框架的已有特性或者自己添加監(jiān)聽(tīng)來(lái)實(shí)現(xiàn)這個(gè)功能。
那么在flutter中有沒(méi)有呢?答案當(dāng)然是肯定的。
flutter專門為iOS端做了這一個(gè)支持,可以讓我們快速的實(shí)現(xiàn)點(diǎn)擊狀態(tài)欄回頂部的效果,它就是一系列圍繞PrimaryScrollController數(shù)據(jù)傳遞方式所展開(kāi)的設(shè)計(jì)。
按照我們?cè)缙趂lutter開(kāi)發(fā)經(jīng)驗(yàn),如果沒(méi)有仔細(xì)的對(duì)PrimaryScrollController和相關(guān)類的實(shí)現(xiàn)有詳細(xì)的了解,必然會(huì)在構(gòu)建結(jié)構(gòu)復(fù)雜的頁(yè)面時(shí)出現(xiàn)各種奇怪的問(wèn)題。
PrimaryScrollController的定義
PrimaryScrollController的源碼內(nèi)容并不多,主要包含兩部分。
- 擴(kuò)展自InheritedWidget
- 持有ScrollController類型的變量
下面是源碼部分:
class PrimaryScrollController extends InheritedWidget {
const PrimaryScrollController({
Key? key,
required ScrollController this.controller,
required Widget child,
}) : assert(controller != null),
super(key: key, child: child);
const PrimaryScrollController.none({
Key? key,
required Widget child,
}) : controller = null,
super(key: key, child: child);
final ScrollController? controller;
static ScrollController? of(BuildContext context) {
final PrimaryScrollController? result = context.dependOnInheritedWidgetOfExactType<PrimaryScrollController>();
return result?.controller;
}
...
}
關(guān)于InheritedWidget
InheritedWidget可以說(shuō)是flutter框架內(nèi)比較常見(jiàn)的數(shù)據(jù)傳遞設(shè)計(jì)抽象,簡(jiǎn)單介紹一下。
?
每個(gè)Element實(shí)例都持有一個(gè)_inheritedWidgets?,每當(dāng)要為Widget添加特定類型的依賴時(shí),就會(huì)從該集合里取出相關(guān)類型的InheritedElement實(shí)例。
而element的_inheritedWidgets是在每次element掛載和重新啟用時(shí),element都會(huì)從它的上層element中打包拿到其所持有的所有_inheritedWidgets。
還有特殊的InheritedElement? 它繼承了Element?,相較于普通的Element,InheritedElement?不僅會(huì)拿到其上層element所有的_inheritedWidgets,而且會(huì)將自己也作為一個(gè)元素添加到集合中
自定義 InheritedWidgetA:
class InheritedWidgetA extends InheritedWidget {
Value a;
...
static Value? of(BuildContext context) {
final InheritedWidgetA? result =
context.dependOnInheritedWidgetOfExactType<InheritedWidgetA>();
return result?.a;
}
}
使用示例和數(shù)據(jù)傳遞如下:
inheritedWidget數(shù)據(jù)圖
如上圖所示:childA,childB都能共享上級(jí)樹(shù)的數(shù)據(jù)。
ScrollController
ScrollController?間接繼承自Listenable,主要有兩個(gè)功能
- 監(jiān)聽(tīng)滾動(dòng)事件
- 控制列表滾動(dòng)
ScrollController部分實(shí)現(xiàn):
class ScrollController extends ChangeNotifier {
...
void jumpTo(double value) {
assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
for (final ScrollPosition position in List<ScrollPosition>.of(_positions))
position.jumpTo(value);
}
void attach(ScrollPosition position) {
assert(!_positions.contains(position));
_positions.add(position);
position.addListener(notifyListeners);
}
void detach(ScrollPosition position) {
assert(_positions.contains(position));
position.removeListener(notifyListeners);
_positions.remove(position);
}
}
看源碼發(fā)現(xiàn):
ScrollController?提供了綁定和解綁ScrollPosition?。 每個(gè)ScrollPosition?對(duì)應(yīng)一個(gè)Scrollable?滾動(dòng)視圖 ,注意ScrollController?是可以綁定多個(gè)ScrollPosition。
所以通過(guò)scrollController.position直接取值報(bào)錯(cuò)可能是大多數(shù)朋友會(huì)踩的坑。
ScrollPosition get position {
assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
assert(_positions.length == 1, 'ScrollController attached to multiple scroll views.');
return _positions.single;
}
ScrollView與ScrollController的聯(lián)系:
ScrollView?創(chuàng)建時(shí)是需要兩個(gè)參數(shù)controller和primary?的,主要用來(lái)確定綁定的scrollController是使用controller?還是最近的父級(jí)PrimaryScrollController中的scrollController。
abstract class ScrollView extends StatelessWidget {
final ScrollController? controller;
final bool primary;
@override
Widget build(BuildContext context) {
final List<Widget> slivers = buildSlivers(context);
final AxisDirection axisDirection = getDirection(context);
final ScrollController? scrollController =
primary ? PrimaryScrollController.of(context) : controller;
final Scrollable scrollable = Scrollable(
controller: scrollController,
);
...
return scrollable;
}
}
可以看到在ScrollView?中會(huì)創(chuàng)建Scrollable,Scrollable?會(huì)在_updatePosition?時(shí)與ScrollController?進(jìn)行綁定,接著ScrollController就能控制視圖滾動(dòng),或者監(jiān)聽(tīng)視圖滾動(dòng)。
class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, RestorationMixin
implements ScrollContext {
ScrollPosition get position => _position!;
ScrollPosition? _position;
final _RestorableScrollOffset _persistedScrollOffset = _RestorableScrollOffset();
@override
AxisDirection get axisDirection => widget.axisDirection;
late ScrollBehavior _configuration;
ScrollPhysics? _physics;
ScrollController? _fallbackScrollController;
MediaQueryData? _mediaQueryData;
ScrollController get _effectiveScrollController => widget.controller ?? _fallbackScrollController!;
void _updatePosition() {
_configuration = widget.scrollBehavior ?? ScrollConfiguration.of(context);
_physics = _configuration.getScrollPhysics(context);
if (widget.physics != null) {
_physics = widget.physics!.applyTo(_physics);
} else if (widget.scrollBehavior != null) {
_physics = widget.scrollBehavior!.getScrollPhysics(context).applyTo(_physics);
}
final ScrollPosition? oldPosition = _position;
if (oldPosition != null) {
_effectiveScrollController.detach(oldPosition);
scheduleMicrotask(oldPosition.dispose);
}
_position = _effectiveScrollController.createScrollPosition(_physics!, this, oldPosition);
assert(_position != null);
_effectiveScrollController.attach(position);
}
}
到這里已經(jīng)介紹完了PrimaryScrollController?的實(shí)現(xiàn)以及相關(guān)的類與其的關(guān)系,接下來(lái),我們看一下Flutter官方是怎么利用PrimaryScrollController?來(lái)設(shè)計(jì)點(diǎn)擊狀態(tài)欄回頂部功能的,看看Flutter還在哪些內(nèi)部組件埋下了關(guān)于PrimaryScrollController的處理。
Scaffold
到目前為止,我們只談了PrimaryScrollController的使用,那么思考一下:點(diǎn)擊狀態(tài)欄事件的監(jiān)聽(tīng)是在哪里實(shí)現(xiàn)的?是如何對(duì)應(yīng)到每個(gè)具體頁(yè)面的?
你猜對(duì)了,在Scaffold中。Scaffold是基于Material上的一種視覺(jué)支架,可以很方便的作出類似iOS風(fēng)格的交互和UI。Flutter官方在Scaffold中添加了狀態(tài)欄區(qū)域的gesture并處理了點(diǎn)擊事件??丛创a:
class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin, RestorationMixin {
@override
Widget build(BuildContext context) {
...
switch (themeData.platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
_addIfNonNull(
children,
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleStatusBarTap,
excludeFromSemantics: true,
),
_ScaffoldSlot.statusBar,
removeLeftPadding: false,
removeTopPadding: true,
removeRightPadding: false,
removeBottomPadding: true,
);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
break;
}
...
}
void _handleStatusBarTap() {
final ScrollController? _primaryScrollController = PrimaryScrollController.of(context);
if (_primaryScrollController != null && _primaryScrollController.hasClients) {
_primaryScrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 300),
curve: Curves.linear,
);
}
}
}
可以看到,Scaffold中添加了狀態(tài)欄位置的點(diǎn)擊,并在點(diǎn)擊后通過(guò) PrimaryScrollController.of(context) 獲取scrollController,最后調(diào)整滾動(dòng)位置。
此時(shí)我們已經(jīng)知道了狀態(tài)欄監(jiān)聽(tīng)使用PrimaryScrollController.of(context)?進(jìn)行了控制滾動(dòng),ScrollView 綁定了PrimaryScrollController.of(context) 。
好了,到目前為止,我們可以看下面的例子:一般情況下我們的項(xiàng)目代碼是下面這樣
runApp(
MaterialApp(
theme: ThemeData(
platform: TargetPlatform.iOS,
primarySwatch: Colors.blue,
),
routes: kkConfigureRoutes(),
initialRoute: "/",
)
);
class PageAState {
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
child:ListView(
primary:true
controller:null
...
)
);
}
}
當(dāng)你push到PageA時(shí),接著點(diǎn)擊狀態(tài)欄,PageA中的列表回到了頂部。感覺(jué)好像沒(méi)什么問(wèn)題,但是好像缺了點(diǎn)什么,對(duì)嗎?
對(duì)!?? 你發(fā)現(xiàn)了,我們并沒(méi)有創(chuàng)建PrimaryScrollController? 和 Scrollcontroller。那么Scaffold中取的PrimaryScrollController來(lái)自哪里?
PrimaryScrollController 的默認(rèn)創(chuàng)建
在上面PageAState中你會(huì)發(fā)現(xiàn):PrimaryScrollController.of(context) 是有值的。所以答案只能是在push到頁(yè)面pageA時(shí),就創(chuàng)建了PrimaryScrollController和Scrollcontroller。猜測(cè)flutter應(yīng)該是在router層給大家自動(dòng)創(chuàng)建了。我們尋找一下源碼,發(fā)現(xiàn)在routes.dart的_ModalScope中,套了一層 PrimaryScrollController(controller:primaryScrollController)。
class _ModalScopeState<T> extends State<_ModalScope<T>> {
....
final ScrollController primaryScrollController = ScrollController();
@override
Widget build(BuildContext context) {
return ...
child: PrimaryScrollController(
controller: primaryScrollController,
...
)
}
}
路由每產(chǎn)生一級(jí)ModalScopeState,會(huì)創(chuàng)建ScrollController(), 并添加PrimaryScrollController Widget。頁(yè)面Page作為子Wideget就可以獲取到上級(jí)的ScrollController。
使用流程小結(jié)
上面講了這么多,現(xiàn)在我們可以總結(jié)一下,正確優(yōu)雅的使用官方提供的點(diǎn)擊狀態(tài)欄功能的步驟:
- 需要通過(guò)路由進(jìn)了頁(yè)面
- 頁(yè)面需要使用Scaffold, 這里注意(同一個(gè)頁(yè)面Scaffold不能嵌套,否則可能無(wú)法響應(yīng)狀態(tài)欄點(diǎn)擊事件)
- Scaffold中有ScrollView
- PrimaryScrollController.of(context) 綁定了ScrollView
這樣就實(shí)現(xiàn)了點(diǎn)擊狀態(tài)欄滾動(dòng)視圖回到頂部功能。
實(shí)際問(wèn)題
我們來(lái)看一個(gè)比較常見(jiàn)的App結(jié)構(gòu):打開(kāi)app,app底部有三個(gè)tab,每個(gè)tab都有對(duì)應(yīng)的A,B兩個(gè)列表頁(yè)。下面是代碼:
void main {
runApp(
MaterialApp(
routes: kkConfigureRoutes(),
initialRoute: "/",
)
);
}
class RootTabPageState extends BaseThemeState<RootTabPage> {
late PageController _pageController;
late List<Widget> _tabs;
@override
void initState() {
super.initState();
_tabs = [
PageA(key: _),
PageB(key: _),
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
child:Column(
children:[
Expanded(child: PageView(
children: _tabs,
controller: _pageController,
physics: const NeverScrollableScrollPhysics()
)),
KKBottomBar(...),
]
),
);
}
}
class PageAState with AutomaticKeepAliveClientMixin {
Widget build(BuildContext context) {
super.build(context);
return ListView(
primary:true
controller:null
...
);
}
...
}
class PageBState with AutomaticKeepAliveClientMixin {
Widget build(BuildContext context) {
super.build(context);
return ListView(
primary:true
controller:null
...
);
}
...
}
上面的代碼有點(diǎn)特殊問(wèn)題,不知道你們發(fā)現(xiàn)沒(méi)有:如果我點(diǎn)擊狀態(tài)欄,頁(yè)面的列表會(huì)滾動(dòng)到頂部嗎?分析一下,有Router層創(chuàng)建了PrimaryScrollController,RootTabPageState層包裝了Scaffold監(jiān)聽(tīng)點(diǎn)擊狀態(tài)欄事件,然后A,B頁(yè)面primary=true , 兩個(gè)頁(yè)面的ScrollView都綁定了父PrimaryScrollController.of(context)。所以點(diǎn)擊狀態(tài)欄,列表會(huì)回到頂部。但是你會(huì)發(fā)現(xiàn)PrimaryScrollController.of(context) 綁定了兩個(gè)ScrollView。所以點(diǎn)擊狀態(tài)欄,兩個(gè)列表都會(huì)回到頂部,當(dāng)然如果需求是這樣,那么沒(méi)問(wèn)題,但是我想大部分情況下這是一個(gè)問(wèn)題。所以,我們來(lái)試著改一下:
class RootTabPageState extends BaseThemeState<RootTabPage> {
late PageController _pageController;
late List<Widget> _tabs;
@override
void initState() {
super.initState();
_tabs = [
PageA(key: _),
PageB(key: _),
PageC(key: _),
];
}
@override
Widget build(BuildContext context) {
return Material(
child:Column(
children:[
Expanded(child: PageView(
children: _tabs,
controller: _pageController,
physics: const NeverScrollableScrollPhysics()
)),
KKBottomBar(...),
]
),
);
}
}
class PageAState {
Widget build(BuildContext context) {
return Scaffold(
ListView(
primary:true
controller:null
...
)
);
}
...
}
class PageBState {
Widget build(BuildContext context) {
return Scaffold(
ListView(
primary:true
controller:null
...
)
);
}
...
}
我們將RootTabPageState中的Scaffold改成了Material,A,B頁(yè)面加上了Scaffold。想想結(jié)果是什么?雖然我們添加了兩個(gè)Scaffold監(jiān)聽(tīng)各自的頁(yè)面A,B。但是PrimaryScrollController.of(context) ,其實(shí)是Router層創(chuàng)建的,所以PrimaryScrollController.of(context) 還是綁定了兩個(gè)頁(yè)面的ScrollView。所以點(diǎn)擊狀態(tài)欄,兩個(gè)列表都會(huì)回到頂部。我們繼續(xù)調(diào)整:
class RootTabPageState extends BaseThemeState<RootTabPage> {
late PageController _pageController;
late List<Widget> _tabs;
@override
void initState() {
super.initState();
_tabs = [
PageA(key: _),
PageB(key: _),
PageC(key: _),
];
}
@override
Widget build(BuildContext context) {
return Material(
child:Column(
children:[
Expanded(child: PageView(
children: _tabs,
controller: _pageController,
physics: const NeverScrollableScrollPhysics()
)),
KKBottomBar(...),
]
),
);
}
}
class PageAState {
final ScrollController _scrollController = ScrollController();
Widget build(BuildContext context) {
return
PrimaryScrollController(
controller: _scrollController,
child: Scaffold(
ListView(
primary:true
controller:null
...
)
)
);
}
...
}
class PageBState {
final ScrollController _scrollController = ScrollController();
Widget build(BuildContext context) {
return
PrimaryScrollController(
controller: _scrollController,
child: Scaffold(
ListView(
primary:true
controller:null
...
)
)
);
}
...
}
我們?cè)贏,B頁(yè)面自己添加了PrimaryScrollController并創(chuàng)建了_scrollController,這樣PageA中的Scaffold取PrimaryScrollController.of(context) 其實(shí)取的是我們創(chuàng)建的_scrollController。PageA中的Scrollview綁定的也是PageA中的_scrollController。所以現(xiàn)在,我們?cè)贏頁(yè)面點(diǎn)擊狀態(tài)欄,那么只有A頁(yè)面的列表會(huì)回到頂部了。當(dāng)大家真正了解了上面提到的相關(guān)內(nèi)容后,在你遇到不同的頁(yè)面結(jié)構(gòu)時(shí),就知道如何去設(shè)計(jì),才能避免一些奇怪的問(wèn)題。
大家可以思考一下?在上述例子結(jié)構(gòu)中,如果其中PageAState頁(yè)面不止包含一個(gè)列表,而是本身是一個(gè)可以左右滾動(dòng)的多列表時(shí),該如何實(shí)現(xiàn)在頁(yè)面A點(diǎn)擊狀態(tài)欄,讓頁(yè)面A當(dāng)前顯示的列表回到頂部。
篇幅有限,這里給提供一個(gè)思路,每個(gè)列表單獨(dú)創(chuàng)建ScrollController。PageA層自定義ScrollController類,重寫(xiě)其滾動(dòng)方法來(lái)接受狀態(tài)欄點(diǎn)擊事件,下發(fā)到對(duì)應(yīng)列表的ScrollController。
隱秘的問(wèn)題
接下來(lái),說(shuō)一個(gè)比較隱秘的問(wèn)題,下面是一個(gè)例子:
class PageAState {
final ScrollController _scrollController = ScrollController();
Widget build(BuildContext context) {
super.build(context);
return
PrimaryScrollController(
controller: _scrollController,
Scaffold(
child:ListView(
primary:true
controller:null,
children:[
CellA()
])
)
);
}
}
class CellAState {
ScrollController? _controller;
@override
Widget build(BuildContext context) {
_controller = PrimaryScrollController.of(context);
return MyButton(
onPress:_press
);
}
void _press(){
_controller?.jumpTo(0);
}
}
上面這個(gè)例子中,我想在CellAState中獲取_controller,然后用它來(lái)做點(diǎn)事情,比如里面有個(gè)按鈕,然后點(diǎn)擊后,讓列表滾動(dòng)到某個(gè)位置。雖然這個(gè)例子看起來(lái)非常簡(jiǎn)單,但是很不幸,你取到的_controller為null,為什么?此時(shí)你會(huì)檢查代碼,檢查PrimaryScrollController的使用方式是否有問(wèn)題,在檢查了一輪之后,發(fā)現(xiàn)并沒(méi)有問(wèn)題,然后你可能開(kāi)始有點(diǎn)抓狂。這個(gè)例子層級(jí)少,比較簡(jiǎn)單的,我們可以也許可以通過(guò)斷點(diǎn)發(fā)現(xiàn)一些端倪,但是在項(xiàng)目中可能層級(jí)非常之多,如果通過(guò)斷點(diǎn)去找,那將是地獄。沒(méi)有辦法,你只能進(jìn)入地獄,很幸運(yùn)我們的例子很簡(jiǎn)單,這個(gè)地獄不是特別深,通過(guò)斷點(diǎn)一步一步的,你會(huì)發(fā)現(xiàn)有一個(gè) PrimaryScrollController.none,回顧一下,這個(gè)東西好像在PrimaryScrollController的源碼中出現(xiàn)過(guò)。
這個(gè)東西是在哪創(chuàng)建的呢???
因?yàn)槲覀兝颖容^簡(jiǎn)單,所以我們能肯定問(wèn)題發(fā)生在List中,但是在項(xiàng)目中這將是一個(gè)非常隱秘的問(wèn)題。我們進(jìn)入listView, 一層層的進(jìn)入,最后看到了它的抽象類ScrollView,我們之前提到過(guò)。我們?cè)賮?lái)看下ScrollView的源碼:
abstract class ScrollView extends StatelessWidget {
final ScrollController? controller;
final bool primary;
const ScrollView({
Key? key,
this.controller,
bool? primary,
...
}) : assert(scrollDirection != null),
assert(!(controller != null && (primary ?? false)),
'Primary ScrollViews obtain their ScrollController via inheritance from a PrimaryScrollController widget. '
'You cannot both set primary to true and pass an explicit controller.',
),
primary = primary ?? controller == null && identical(scrollDirection, Axis.vertical),
super(key: key);
@override
Widget build(BuildContext context) {
...
final ScrollController? scrollController =
primary ? PrimaryScrollController.of(context) : controller;
final Scrollable scrollable = Scrollable(
...
);
final Widget scrollableResult = primary && scrollController != null
? PrimaryScrollController.none(child: scrollable)
: scrollable;
...
return scrollableResult;
}
}
我們發(fā)現(xiàn)了什么?
final Widget scrollableResult = primary && scrollController != null
? PrimaryScrollController.none(child: scrollable)
: scrollable;
在 primary && scrollController != null的情況下它為我們包裝了一層PrimaryScrollController.none(child: scrollable) 等效于 PrimaryScrollController(controller:null,child:scrollable)。也就是按照我們外部傳prmary = true的情況下,它把我們截?cái)嗔?。所以回到我們的?wèn)題,如果我們要在CellA中想通過(guò)PrimaryScrollController.of(context)取值,該如何修改?
class PageAState {
final ScrollController _scrollController = ScrollController();
Widget build(BuildContext context) {
super.build(context);
return
PrimaryScrollController(
controller: _scrollController,
Scaffold(
child:ListView(
primary:false
controller:_scrollController,
children:[
CellA()
])
)
);
}
}
class CellAState {
ScrollController? _controller;
@override
Widget build(BuildContext context) {
_controller = PrimaryScrollController.of(context);
return MyButton(
onPress:_press
);
}
void _press(){
_controller?.jumpTo(0);
}
}
結(jié)語(yǔ)
好了,本篇基本已經(jīng)到了尾聲了,相信大家以后碰到與PrimaryScrollController相關(guān)的問(wèn)題便不再是問(wèn)題了。
看完了這一系列內(nèi)容,我們可以發(fā)現(xiàn)PrimaryScrollController?只是flutter設(shè)計(jì)的一種數(shù)據(jù)傳遞的方案,只是解決點(diǎn)擊狀態(tài)欄使列表滾動(dòng)到頂部這個(gè)問(wèn)題中的一環(huán)。整個(gè)問(wèn)題其實(shí)是涉及到了ScroView,ScrollController,Scaffold?以及Router中的_ModalScopeState等,它們或多或少的提供了特殊處理和輔助方式。
不得不說(shuō)flutter的組件提供了非常強(qiáng)大的功能,但這也可能導(dǎo)致看似無(wú)關(guān)的組件和類之間,內(nèi)部其實(shí)是有一定聯(lián)系的,而且比較隱蔽,所以在部分復(fù)雜場(chǎng)景下,可能會(huì)出現(xiàn)一些問(wèn)題,這時(shí)候就比較考驗(yàn)開(kāi)發(fā)者耐心和對(duì)各種組件源碼的熟悉度了。