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