一文讓你理清PrimaryScrollController
PrimaryScrollController的作用
對蘋果用戶來說,大家基本都知道,iOS手機應用有一個比較常見的功能:點擊狀態(tài)欄,列表就會滾動到頂部。
在iOS原生代碼中,我們可以通過原生框架的已有特性或者自己添加監(jiān)聽來實現(xiàn)這個功能。
那么在flutter中有沒有呢?答案當然是肯定的。
flutter專門為iOS端做了這一個支持,可以讓我們快速的實現(xiàn)點擊狀態(tài)欄回頂部的效果,它就是一系列圍繞PrimaryScrollController數(shù)據(jù)傳遞方式所展開的設計。
按照我們早期flutter開發(fā)經(jīng)驗,如果沒有仔細的對PrimaryScrollController和相關類的實現(xiàn)有詳細的了解,必然會在構(gòu)建結(jié)構(gòu)復雜的頁面時出現(xiàn)各種奇怪的問題。
PrimaryScrollController的定義
PrimaryScrollController的源碼內(nèi)容并不多,主要包含兩部分。
- 擴展自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;
}
...
}
關于InheritedWidget
InheritedWidget可以說是flutter框架內(nèi)比較常見的數(shù)據(jù)傳遞設計抽象,簡單介紹一下。
?
每個Element實例都持有一個_inheritedWidgets?,每當要為Widget添加特定類型的依賴時,就會從該集合里取出相關類型的InheritedElement實例。
而element的_inheritedWidgets是在每次element掛載和重新啟用時,element都會從它的上層element中打包拿到其所持有的所有_inheritedWidgets。
還有特殊的InheritedElement? 它繼承了Element?,相較于普通的Element,InheritedElement?不僅會拿到其上層element所有的_inheritedWidgets,而且會將自己也作為一個元素添加到集合中
自定義 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都能共享上級樹的數(shù)據(jù)。
ScrollController
ScrollController?間接繼承自Listenable,主要有兩個功能
- 監(jiān)聽滾動事件
- 控制列表滾動
ScrollController部分實現(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?。 每個ScrollPosition?對應一個Scrollable?滾動視圖 ,注意ScrollController?是可以綁定多個ScrollPosition。
所以通過scrollController.position直接取值報錯可能是大多數(shù)朋友會踩的坑。
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ù)controller和primary?的,主要用來確定綁定的scrollController是使用controller?還是最近的父級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?中會創(chuàng)建Scrollable,Scrollable?會在_updatePosition?時與ScrollController?進行綁定,接著ScrollController就能控制視圖滾動,或者監(jiān)聽視圖滾動。
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?的實現(xiàn)以及相關的類與其的關系,接下來,我們看一下Flutter官方是怎么利用PrimaryScrollController?來設計點擊狀態(tài)欄回頂部功能的,看看Flutter還在哪些內(nèi)部組件埋下了關于PrimaryScrollController的處理。
Scaffold
到目前為止,我們只談了PrimaryScrollController的使用,那么思考一下:點擊狀態(tài)欄事件的監(jiān)聽是在哪里實現(xiàn)的?是如何對應到每個具體頁面的?
你猜對了,在Scaffold中。Scaffold是基于Material上的一種視覺支架,可以很方便的作出類似iOS風格的交互和UI。Flutter官方在Scaffold中添加了狀態(tài)欄區(qū)域的gesture并處理了點擊事件??丛创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)欄位置的點擊,并在點擊后通過 PrimaryScrollController.of(context) 獲取scrollController,最后調(diào)整滾動位置。
此時我們已經(jīng)知道了狀態(tài)欄監(jiān)聽使用PrimaryScrollController.of(context)?進行了控制滾動,ScrollView 綁定了PrimaryScrollController.of(context) 。
好了,到目前為止,我們可以看下面的例子:一般情況下我們的項目代碼是下面這樣
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
...
)
);
}
}
當你push到PageA時,接著點擊狀態(tài)欄,PageA中的列表回到了頂部。感覺好像沒什么問題,但是好像缺了點什么,對嗎?
對!?? 你發(fā)現(xiàn)了,我們并沒有創(chuàng)建PrimaryScrollController? 和 Scrollcontroller。那么Scaffold中取的PrimaryScrollController來自哪里?
PrimaryScrollController 的默認創(chuàng)建
在上面PageAState中你會發(fā)現(xiàn):PrimaryScrollController.of(context) 是有值的。所以答案只能是在push到頁面pageA時,就創(chuàng)建了PrimaryScrollController和Scrollcontroller。猜測flutter應該是在router層給大家自動創(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)生一級ModalScopeState,會創(chuàng)建ScrollController(), 并添加PrimaryScrollController Widget。頁面Page作為子Wideget就可以獲取到上級的ScrollController。
使用流程小結(jié)
上面講了這么多,現(xiàn)在我們可以總結(jié)一下,正確優(yōu)雅的使用官方提供的點擊狀態(tài)欄功能的步驟:
- 需要通過路由進了頁面
- 頁面需要使用Scaffold, 這里注意(同一個頁面Scaffold不能嵌套,否則可能無法響應狀態(tài)欄點擊事件)
- Scaffold中有ScrollView
- PrimaryScrollController.of(context) 綁定了ScrollView
這樣就實現(xiàn)了點擊狀態(tài)欄滾動視圖回到頂部功能。
實際問題
我們來看一個比較常見的App結(jié)構(gòu):打開app,app底部有三個tab,每個tab都有對應的A,B兩個列表頁。下面是代碼:
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
...
);
}
...
}
上面的代碼有點特殊問題,不知道你們發(fā)現(xiàn)沒有:如果我點擊狀態(tài)欄,頁面的列表會滾動到頂部嗎?分析一下,有Router層創(chuàng)建了PrimaryScrollController,RootTabPageState層包裝了Scaffold監(jiān)聽點擊狀態(tài)欄事件,然后A,B頁面primary=true , 兩個頁面的ScrollView都綁定了父PrimaryScrollController.of(context)。所以點擊狀態(tài)欄,列表會回到頂部。但是你會發(fā)現(xiàn)PrimaryScrollController.of(context) 綁定了兩個ScrollView。所以點擊狀態(tà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頁面加上了Scaffold。想想結(jié)果是什么?雖然我們添加了兩個Scaffold監(jiān)聽各自的頁面A,B。但是PrimaryScrollController.of(context) ,其實是Router層創(chuàng)建的,所以PrimaryScrollController.of(context) 還是綁定了兩個頁面的ScrollView。所以點擊狀態(tài)欄,兩個列表都會回到頂部。我們繼續(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
...
)
)
);
}
...
}
我們在A,B頁面自己添加了PrimaryScrollController并創(chuàng)建了_scrollController,這樣PageA中的Scaffold取PrimaryScrollController.of(context) 其實取的是我們創(chuàng)建的_scrollController。PageA中的Scrollview綁定的也是PageA中的_scrollController。所以現(xiàn)在,我們在A頁面點擊狀態(tài)欄,那么只有A頁面的列表會回到頂部了。當大家真正了解了上面提到的相關內(nèi)容后,在你遇到不同的頁面結(jié)構(gòu)時,就知道如何去設計,才能避免一些奇怪的問題。
大家可以思考一下?在上述例子結(jié)構(gòu)中,如果其中PageAState頁面不止包含一個列表,而是本身是一個可以左右滾動的多列表時,該如何實現(xiàn)在頁面A點擊狀態(tài)欄,讓頁面A當前顯示的列表回到頂部。
篇幅有限,這里給提供一個思路,每個列表單獨創(chuàng)建ScrollController。PageA層自定義ScrollController類,重寫其滾動方法來接受狀態(tài)欄點擊事件,下發(fā)到對應列表的ScrollController。
隱秘的問題
接下來,說一個比較隱秘的問題,下面是一個例子:
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);
}
}
上面這個例子中,我想在CellAState中獲取_controller,然后用它來做點事情,比如里面有個按鈕,然后點擊后,讓列表滾動到某個位置。雖然這個例子看起來非常簡單,但是很不幸,你取到的_controller為null,為什么?此時你會檢查代碼,檢查PrimaryScrollController的使用方式是否有問題,在檢查了一輪之后,發(fā)現(xiàn)并沒有問題,然后你可能開始有點抓狂。這個例子層級少,比較簡單的,我們可以也許可以通過斷點發(fā)現(xiàn)一些端倪,但是在項目中可能層級非常之多,如果通過斷點去找,那將是地獄。沒有辦法,你只能進入地獄,很幸運我們的例子很簡單,這個地獄不是特別深,通過斷點一步一步的,你會發(fā)現(xiàn)有一個 PrimaryScrollController.none,回顧一下,這個東西好像在PrimaryScrollController的源碼中出現(xiàn)過。
這個東西是在哪創(chuàng)建的呢???
因為我們例子比較簡單,所以我們能肯定問題發(fā)生在List中,但是在項目中這將是一個非常隱秘的問題。我們進入listView, 一層層的進入,最后看到了它的抽象類ScrollView,我們之前提到過。我們再來看下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的情況下,它把我們截斷了。所以回到我們的問題,如果我們要在CellA中想通過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é)語
好了,本篇基本已經(jīng)到了尾聲了,相信大家以后碰到與PrimaryScrollController相關的問題便不再是問題了。
看完了這一系列內(nèi)容,我們可以發(fā)現(xiàn)PrimaryScrollController?只是flutter設計的一種數(shù)據(jù)傳遞的方案,只是解決點擊狀態(tài)欄使列表滾動到頂部這個問題中的一環(huán)。整個問題其實是涉及到了ScroView,ScrollController,Scaffold?以及Router中的_ModalScopeState等,它們或多或少的提供了特殊處理和輔助方式。
不得不說flutter的組件提供了非常強大的功能,但這也可能導致看似無關的組件和類之間,內(nèi)部其實是有一定聯(lián)系的,而且比較隱蔽,所以在部分復雜場景下,可能會出現(xiàn)一些問題,這時候就比較考驗開發(fā)者耐心和對各種組件源碼的熟悉度了。