07_Flutter使用NestedScrollView+TabBarView滚动位置共享问题修复
一.案发现场
可以看到,上图中三个列表的滑动位置共享了,滑动其中一个列表,会影响到另外两个,这显然不符合要求,先来看下布局,再说明产生这个问题的原因:
-
布局整体使用NestedScrollView,顶部banner和TabBar通过headerSliverBuilder创建,body为TabBarView,TabBarView中有三个列表,通过TabController与TabBar实现联动,同时每一个列表通过继承StatefulWidget构建并混入AutomaticKeepAliveClientMixin,重写wantKeepAlive的getter方法返回true,这样可以保证每次切换Tab的时候,ListView不会重新创建,实现懒加载。
NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return [ SliverToBoxAdapter( child: Container( height: 200, color: Colors.red, alignment: Alignment.center, child: const Text( "banner", style: TextStyle( color: Colors.white, fontSize: 16 ), ), ), ), SliverOverlapAbsorber( handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: StickySliverToBoxAdapter( child: Container( color: Colors.white, child: TabBar( tabs: List.generate(_tabs.length, (index) { return Padding( padding: const EdgeInsets.symmetric(vertical: 15), child: Text(_tabs[index]), ); }), unselectedLabelColor: const Color(0xFF7B7B7B), labelColor: const Color(0xFF5E80FF), isScrollable: false, indicatorSize: TabBarIndicatorSize.label, indicator: UnderlineTabIndicator( borderRadius: BorderRadius.circular(3), borderSide: const BorderSide(color: Color(0xFF5E80FF), width: 3), insets: const EdgeInsets.symmetric(horizontal: 3, vertical: 9) ), controller: _tabController, ), ), ), ), ]; }, body: LayoutBuilder( builder: (context, _) { return Container( padding: EdgeInsets.only(top: NestedScrollView.sliverOverlapAbsorberHandleFor(context).layoutExtent ?? 0), child: NestedTabBarView( controller: _tabController, children: List.generate(_tabs.length, (index) { return _TabInnerListView( tabName: _tabs[index], ); }) ) ); } ) ) class _TabInnerListViewState extends State<_TabInnerListView> with AutomaticKeepAliveClientMixin { final int length = 20; Widget build(BuildContext context) { return CustomScrollView( physics: const ClampingScrollPhysics(), slivers: [ ...(List.generate(length, (index) { return SliverToBoxAdapter( child: Container( height: 100, margin: const EdgeInsets.only(top: 16, left: 16, right: 16), color: Colors.orange, alignment: Alignment.center, child: Text( "${widget.tabName} item $index", style: const TextStyle( color: Colors.white ), ), ), ); })), const SliverToBoxAdapter( child: SizedBox( height: 16, ), ) ], ); } bool get wantKeepAlive => true; }
-
上述问题产生的原因,需要追踪NestedScrollView的源码,NestedScrollView整体的布局结构如下:
如果没有_NestedScrollCoordinator的加持,那么外层的CustomScrollView和内层的CustomScrollView就会各划各的。_NestedScrollCoordinator处理嵌套滑动是在applyUserOffset方法中完成的:
class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController { void applyUserOffset(double delta) { updateUserScrollDirection( delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse, ); assert(delta != 0.0); if (_innerPositions.isEmpty) { _outerPosition!.applyFullDragUpdate(delta); } else if (delta < 0.0) { ... } else { double innerDelta = delta; if (_floatHeaderSlivers) { innerDelta = _outerPosition!.applyClampedDragUpdate(delta); } if (innerDelta != 0.0) { double outerDelta = 0.0; final List<double> overscrolls = <double>[]; final List<_NestedScrollPosition> innerPositions = _innerPositions.toList(); for (final _NestedScrollPosition position in innerPositions) { final double overscroll = position.applyClampedDragUpdate(innerDelta); outerDelta = math.max(outerDelta, overscroll); overscrolls.add(overscroll); } if (outerDelta != 0.0) { outerDelta -= _outerPosition!.applyClampedDragUpdate(outerDelta); } for (int i = 0; i < innerPositions.length; ++i) { final double remainingDelta = overscrolls[i] - outerDelta; if (remainingDelta > 0.0) { innerPositions[i].applyFullDragUpdate(remainingDelta); } } } } } }
可以看到在applyUserOffset中,是通过_NestedScrollPosition的applyFullDragUpdate响应滑动事件的,如果调用_outerPosition!.applyFullDragUpdate,则外层的CustomScrollView滑动。同理,内层CustomScrollView滑动,只不过applyUserOffset在处理内层滑动时,是遍历innerPositions把所有内层CustomScrollView的_NestedScrollPosition滚动相同的位移。
_NestedScrollPosition? get _outerPosition { if (!_outerController.hasClients) { return null; } return _outerController.nestedPositions.single; } Iterable<_NestedScrollPosition> get _innerPositions { return _innerController.nestedPositions; }
这也就解释了上图中,为什么滚动其中一个列表,其他列表也会跟着滑动相同的位置?。
二.解决方案
综上所述,_NestedScrollCoordinator的_innerPositions的返回结果是所有内层CustomScrollView的_NestedScrollPosition,要解决这个问题,我们只需要想办法将_NestedScrollCoordinator的_innerPositions的返回结果改变成只包含当前选中的内层CustomScrollView的_NestedScrollPosition即可,而_innerPositions的取值是来源于_innerController的nestedPositions。_innerController是一个_NestedScrollController对象,接着看_NestedScrollController的源码:
class _NestedScrollController extends ScrollController {
...
ScrollPosition createScrollPosition(
ScrollPhysics physics,
ScrollContext context,
ScrollPosition? oldPosition,
) {
return _NestedScrollPosition(
coordinator: coordinator,
physics: physics,
context: context,
initialPixels: initialScrollOffset,
oldPosition: oldPosition,
debugLabel: debugLabel,
);
}
void attach(ScrollPosition position) {
assert(position is _NestedScrollPosition);
super.attach(position);
coordinator.updateParent();
coordinator.updateCanDrag();
position.addListener(_scheduleUpdateShadow);
_scheduleUpdateShadow();
}
void detach(ScrollPosition position) {
assert(position is _NestedScrollPosition);
(position as _NestedScrollPosition).setParent(null);
position.removeListener(_scheduleUpdateShadow);
super.detach(position);
_scheduleUpdateShadow();
}
...
Iterable<_NestedScrollPosition> get nestedPositions {
// TODO(vegorov): use instance method version of castFrom when it is available.
return Iterable.castFrom<ScrollPosition, _NestedScrollPosition>(positions);
}
}
可以看到_NestedScrollController是私有类,并且NestedScrollView从头到尾都没有暴露任何可以修改或替换_innerController的方法给我们,因此,想在外部直接修改是不可能的。怎么办呢?
首先,内层的每一个CustomScrollView都是我们在外部人为编写的,我们可以在外部给内层的每一个CustomScrollView重新指定ScrollController,虽然暂时没什么卵用😄,但是别着急。
class _TabInnerListViewState extends State<_TabInnerListView> with AutomaticKeepAliveClientMixin {
final int length = 20;
late ScrollController _scrollController;
void initState() {
super.initState();
_scrollController = ScrollController();
}
void dispose() {
_scrollController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return CustomScrollView(
controller: _scrollController,
physics: const ClampingScrollPhysics(),
slivers: [
...(List.generate(length, (index) {
return SliverToBoxAdapter(
child: Container(
height: 100,
margin: const EdgeInsets.only(top: 16, left: 16, right: 16),
color: Colors.orange,
alignment: Alignment.center,
child: Text(
"${widget.tabName} item $index",
style: const TextStyle(
color: Colors.white
),
),
),
);
})),
const SliverToBoxAdapter(
child: SizedBox(
height: 16,
),
)
],
);
}
bool get wantKeepAlive => true;
}
可以看到,此时嵌套滑动失效了,这是因为我们为内层的每一个CustomScrollView单独指定ScrollController后,CustomScrollView的滑动全部交给了这个这个ScrollController处理,跟NestedScrollView的_innerController已经没有半毛钱关系了。既然没有关系,那我们就建立关系,怎么建立:
-
创建NestedInnerScrollController类继承ScrollController
-
重写createScrollPosition方法,通过PrimaryScrollController.maybeOf(context)获取NestedScrollView的_innerController,将createScrollPosition转交给_innerController完成
-
重写attach方法,将attach转交给_innerController完成
-
重写detach方法,将detach转交给_innerController完成
-
为每一个内层的CustomScrollView指定controller为NestedInnerScrollController的实例
class NestedInnerScrollController extends ScrollController { ScrollController? _inner; NestedInnerScrollController(); ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) { ScrollPosition scrollPosition; ScrollableState? scrollableState = context as ScrollableState; if(scrollableState != null) { _inner = PrimaryScrollController.maybeOf(scrollableState.context); } if(_inner == null) { scrollPosition = super.createScrollPosition(physics, context, oldPosition); } else { scrollPosition = _inner!.createScrollPosition(physics, context, oldPosition); } return scrollPosition; } void attach(ScrollPosition position) { super.attach(position); _inner?.attach(position); } void detach(ScrollPosition position) { _inner?.detach(position); super.detach(position); } } class _TabInnerListViewState extends State<_TabInnerListView> with AutomaticKeepAliveClientMixin { final int length = 20; late ScrollController _scrollController; void initState() { super.initState(); _scrollController = NestedInnerScrollController(); } void dispose() { _scrollController.dispose(); super.dispose(); } Widget build(BuildContext context) { return CustomScrollView( controller: _scrollController, physics: const ClampingScrollPhysics(), slivers: [ ...(List.generate(length, (index) { return SliverToBoxAdapter( child: Container( height: 100, margin: const EdgeInsets.only(top: 16, left: 16, right: 16), color: Colors.orange, alignment: Alignment.center, child: Text( "${widget.tabName} item $index", style: const TextStyle( color: Colors.white ), ), ), ); })), const SliverToBoxAdapter( child: SizedBox( height: 16, ), ) ], ); } bool get wantKeepAlive => true; }
可以看到,嵌套滑动它又回来了😄。那么接下来…,就只剩下解决共享滑动了:
-
将TabBarView单独定义成StatefulWidget,这样我们就可以很方便的为每一个内层的CustomScrollView维护上面定义好的NestedInnerScrollController,同时通过TabController监听TabBar的选中状态。
class NestedTabBarView extends StatefulWidget { final TabController? controller; final List<Widget> children; final ScrollPhysics? physics; final DragStartBehavior dragStartBehavior; final double viewportFraction; final Clip clipBehavior; const NestedTabBarView({ super.key, required this.children, this.controller, this.physics, this.dragStartBehavior = DragStartBehavior.start, this.viewportFraction = 1.0, this.clipBehavior = Clip.hardEdge, }); State<StatefulWidget> createState() => _NestedTabBarViewState(); } class _NestedTabBarViewState extends State<NestedTabBarView> { List<NestedInnerScrollController> _nestedInnerControllers = []; void initState() { super.initState(); _initNestedInnerControllers(); widget.controller?.addListener(_onTabChange); } void didUpdateWidget(covariant NestedTabBarView oldWidget) { super.didUpdateWidget(oldWidget); if(oldWidget.children.length != widget.children.length) { _initNestedInnerControllers(); } } void dispose() { widget.controller?.removeListener(_onTabChange); _disposeNestedInnerControllers(); super.dispose(); } void _onTabChange() { } void _initNestedInnerControllers() { _disposeNestedInnerControllers(); List<NestedInnerScrollController> controllers = List.generate(widget.children.length, (index) { return NestedInnerScrollController(); }); if(mounted) { setState(() { _nestedInnerControllers = controllers; }); } else { _nestedInnerControllers = controllers; } } void _disposeNestedInnerControllers() { _nestedInnerControllers.forEach((element) { element.dispose(); }); } Widget build(BuildContext context) { return TabBarView( controller: widget.controller, physics: widget.physics, dragStartBehavior: widget.dragStartBehavior, viewportFraction: widget.viewportFraction, clipBehavior: widget.clipBehavior, children: widget.children ); } }
-
使用InheritedWidget,将NestedInnerScrollController暴露给对应的内层CustomScrollView使用
class _InheritedInnerScrollController extends InheritedWidget { final ScrollController controller; const _InheritedInnerScrollController({ required super.child, required this.controller }); bool updateShouldNotify(covariant _InheritedInnerScrollController oldWidget) => controller != oldWidget.controller; } class _NestedTabBarViewState extends State<NestedTabBarView> { ... Widget build(BuildContext context) { return TabBarView( controller: widget.controller, physics: widget.physics, dragStartBehavior: widget.dragStartBehavior, viewportFraction: widget.viewportFraction, clipBehavior: widget.clipBehavior, children: List<Widget>.generate(widget.children.length, (index) { return _InheritedInnerScrollController( controller: _nestedInnerControllers[index], child: widget.children[index], ); }) ); } } class NestedInnerScrollController extends ScrollController { ... static ScrollController of(BuildContext context) { final _InheritedInnerScrollController? target = context.dependOnInheritedWidgetOfExactType<_InheritedInnerScrollController>(); assert( target != null, 'NestedInnerScrollController.of must be called with a context that contains a NestedTabBarView\'s children.', ); return target!.controller; } static ScrollController? maybeOf(BuildContext context) { final _InheritedInnerScrollController? target = context.dependOnInheritedWidgetOfExactType<_InheritedInnerScrollController>(); return target?.controller; } } class _TabInnerListViewState extends State<_TabInnerListView> with AutomaticKeepAliveClientMixin { final int length = 20; Widget build(BuildContext context) { return CustomScrollView( controller: NestedInnerScrollController.maybeOf(context), physics: const ClampingScrollPhysics(), slivers: [ ... ], ); } bool get wantKeepAlive => true; }
使用的时候
NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return [ ... ]; }, body: LayoutBuilder( builder: (context, _) { return Container( padding: EdgeInsets.only(top: NestedScrollView.sliverOverlapAbsorberHandleFor(context).layoutExtent ?? 0), child: NestedTabBarView( controller: _tabController, children: List.generate(_tabs.length, (index) { return _TabInnerListView( tabName: _tabs[index], ); }) ) ); } ) )
-
监听TabBar的选中状态,然后通过NestedInnerScrollController将NestedScrollView的_innerController中所有的ScrollPosition detach,然后再attach与当前选中的NestedInnerScrollController对应的ScrollPosition。
class NestedInnerScrollController extends ScrollController { ... void attachCurrent() { if(_inner != null) { while(_inner!.positions.isNotEmpty) { _inner!.detach(_inner!.positions.first); } _inner!.attach(position); } } } class _NestedTabBarViewState extends State<NestedTabBarView> { ... void _onTabChange() { int index = widget.controller!.index; if (index == widget.controller!.animation?.value) { _nestedInnerControllers[index].attachCurrent(); } } ... }
搞定。
三.完整代码
class _NestedScrollPageState extends State<NestedScrollPage> with TickerProviderStateMixin {
final List<String> _tabs = ["tab1", "tab2", "tab3"];
late TabController _tabController;
void initState() {
super.initState();
_tabController = TabController(length: _tabs.length, vsync: this);
}
void dispose() {
_tabController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("nested scroll"),
),
body: SafeArea(
child: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return [
SliverToBoxAdapter(
child: Container(
height: 200,
color: Colors.red,
alignment: Alignment.center,
child: const Text(
"banner",
style: TextStyle(
color: Colors.white,
fontSize: 16
),
),
),
),
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: StickySliverToBoxAdapter(
child: Container(
color: Colors.white,
child: TabBar(
tabs: List.generate(_tabs.length, (index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 15),
child: Text(_tabs[index]),
);
}),
unselectedLabelColor: const Color(0xFF7B7B7B),
labelColor: const Color(0xFF5E80FF),
isScrollable: false,
indicatorSize: TabBarIndicatorSize.label,
indicator: UnderlineTabIndicator(
borderRadius: BorderRadius.circular(3),
borderSide: const BorderSide(color: Color(0xFF5E80FF), width: 3),
insets: const EdgeInsets.symmetric(horizontal: 3, vertical: 9)
),
controller: _tabController,
),
),
),
),
];
},
body: LayoutBuilder(
builder: (context, _) {
return Container(
padding: EdgeInsets.only(top: NestedScrollView.sliverOverlapAbsorberHandleFor(context).layoutExtent ?? 0),
child: NestedTabBarView(
controller: _tabController,
children: List.generate(_tabs.length, (index) {
return _TabInnerListView(
tabName: _tabs[index],
);
})
)
);
}
)
)
),
);
}
}
class _TabInnerListView extends StatefulWidget {
final String? tabName;
const _TabInnerListView({this.tabName});
State<StatefulWidget> createState() => _TabInnerListViewState();
}
class _TabInnerListViewState extends State<_TabInnerListView> with AutomaticKeepAliveClientMixin {
final int length = 20;
Widget build(BuildContext context) {
return CustomScrollView(
controller: NestedInnerScrollController.maybeOf(context),
physics: const ClampingScrollPhysics(),
slivers: [
...(List.generate(length, (index) {
return SliverToBoxAdapter(
child: Container(
height: 100,
margin: const EdgeInsets.only(top: 16, left: 16, right: 16),
color: Colors.orange,
alignment: Alignment.center,
child: Text(
"${widget.tabName} item $index",
style: const TextStyle(
color: Colors.white
),
),
),
);
})),
const SliverToBoxAdapter(
child: SizedBox(
height: 16,
),
)
],
);
}
bool get wantKeepAlive => true;
}
class NestedTabBarView extends StatefulWidget {
final TabController? controller;
final List<Widget> children;
final ScrollPhysics? physics;
final DragStartBehavior dragStartBehavior;
final double viewportFraction;
final Clip clipBehavior;
const NestedTabBarView({
super.key,
required this.children,
this.controller,
this.physics,
this.dragStartBehavior = DragStartBehavior.start,
this.viewportFraction = 1.0,
this.clipBehavior = Clip.hardEdge,
});
State<StatefulWidget> createState() => _NestedTabBarViewState();
}
class _NestedTabBarViewState extends State<NestedTabBarView> {
List<NestedInnerScrollController> _nestedInnerControllers = [];
void initState() {
super.initState();
_initNestedInnerControllers();
widget.controller?.addListener(_onTabChange);
}
void didUpdateWidget(covariant NestedTabBarView oldWidget) {
super.didUpdateWidget(oldWidget);
if(oldWidget.children.length != widget.children.length) {
_initNestedInnerControllers();
}
}
void dispose() {
widget.controller?.removeListener(_onTabChange);
_disposeNestedInnerControllers();
super.dispose();
}
void _onTabChange() {
int index = widget.controller!.index;
if (index == widget.controller!.animation?.value) {
_nestedInnerControllers[index].attachCurrent();
}
}
void _initNestedInnerControllers() {
_disposeNestedInnerControllers();
List<NestedInnerScrollController> controllers = List.generate(widget.children.length, (index) {
return NestedInnerScrollController();
});
if(mounted) {
setState(() {
_nestedInnerControllers = controllers;
});
} else {
_nestedInnerControllers = controllers;
}
}
void _disposeNestedInnerControllers() {
_nestedInnerControllers.forEach((element) {
element.dispose();
});
}
Widget build(BuildContext context) {
return TabBarView(
controller: widget.controller,
physics: widget.physics,
dragStartBehavior: widget.dragStartBehavior,
viewportFraction: widget.viewportFraction,
clipBehavior: widget.clipBehavior,
children: List<Widget>.generate(widget.children.length, (index) {
return _InheritedInnerScrollController(
controller: _nestedInnerControllers[index],
child: widget.children[index],
);
})
);
}
}
class _InheritedInnerScrollController extends InheritedWidget {
final ScrollController controller;
const _InheritedInnerScrollController({
required super.child,
required this.controller
});
bool updateShouldNotify(covariant _InheritedInnerScrollController oldWidget) => controller != oldWidget.controller;
}
class NestedInnerScrollController extends ScrollController {
ScrollController? _inner;
NestedInnerScrollController();
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
ScrollPosition scrollPosition;
ScrollableState? scrollableState = context as ScrollableState;
if(scrollableState != null) {
_inner = PrimaryScrollController.maybeOf(scrollableState.context);
}
if(_inner == null) {
scrollPosition = super.createScrollPosition(physics, context, oldPosition);
} else {
scrollPosition = _inner!.createScrollPosition(physics, context, oldPosition);
}
return scrollPosition;
}
void attach(ScrollPosition position) {
super.attach(position);
_inner?.attach(position);
}
void detach(ScrollPosition position) {
_inner?.detach(position);
super.detach(position);
}
void attachCurrent() {
if(_inner != null) {
while(_inner!.positions.isNotEmpty) {
_inner!.detach(_inner!.positions.first);
}
_inner!.attach(position);
}
}
static ScrollController of(BuildContext context) {
final _InheritedInnerScrollController? target = context.dependOnInheritedWidgetOfExactType<_InheritedInnerScrollController>();
assert(
target != null,
'NestedInnerScrollController.of must be called with a context that contains a NestedTabBarView\'s children.',
);
return target!.controller;
}
static ScrollController? maybeOf(BuildContext context) {
final _InheritedInnerScrollController? target = context.dependOnInheritedWidgetOfExactType<_InheritedInnerScrollController>();
return target?.controller;
}
}