深入进阶-如何解决Flutter上的滑动冲突
学习最忌盲目,无计划,零碎的知识点无法串成系统。学到哪,忘到哪,面试想不起来。这里我整理了Flutter面试中最常问以及Flutter framework中最核心的几块知识,大概二十篇左右文章分析,欢迎关注,共同进步。
导语
一次需求中遇到了这样的场景,PageView中有三个页面,其中一个页面是TabBarView结构。结果出现了当滑动到TabBar的时候,外层PageView无法滑动(滑动冲突)。最终在stackoverflow上找到了这个问题的解法,过程中顺便将Flutter的手势与滑动机制总结了一番。这也是Flutter进阶必须掌握的一个知识点,相信我,这一定是全网最详细,易懂的总结!!
这个系列会分为四篇:
4、解决Flutter滑动冲突的两种思路
读完本文你将收获:两个具体的场景学习如何解决Flutter的滑动冲突
引言
通过前三期的介绍我们已经对Flutter的滑动过程有了清晰的认识,但理论最终要落地到实践才有意义,下面从两个很常见的业务需求中遇到的问题和大家一起看看如何解决滑动冲突。
Case 1:CustomScrollView嵌套ListView
如图,页面的第二个部分是一个滑动到顶部之后固定的TabBar,首先大家会想到CustomScrollView吧(其实NestedScrollView已经解决了嵌套冲突,不过有的时候会失效,最XX的是我当时不知道有这玩意),将TabBar配置到SliverPersistentHeader
的delegate
属性中。
这样当滑动到顶部的时候TabBar即可吸顶,底部TabBarView的内容是一个ListView。这时如果滑动ListView以外的CustomScrollView是没问题。但如果滑动ListView,根据从一次点击探寻Flutter的事件分发原理以及实战Flutter滑动原理可知,在手势竞争的时候,位于最里层的ListView先调用handleEvent
处理手势事件,所以滑动事件被ListView消耗,表现出来就是只有ListView在滑动,外部的Head和Tab位置固定。
Case 2: 网易云结构 PageView嵌套TabBarView 网易云结构
case2类似网易云,整个页面是一个PageView,有三个Child,第二个Child是一个TabBarView。当滑动到第二个Child的时候手势被TabBarView获取处理。这时如果滑动到Tab的最后一页时,会发现整个页面无法继续滑动到第三个Page页面。原因也很简单,其实TabBarView就是对PageVIew进行了封装使其能和Tab交互,所以当滑动到最后一个页面后,TabBarView认为已经滑动到最后一页了,而所有的手势事件都已经被TabBarView处理,外面的PageView无法收到手势事件,所以外面的Page无法滑动。
Case 1:自定义ScrollPosition
参考文章:zhuanlan.zhihu.com/p/106197796…
Case 1的解决办法来自上面的链接,实战Flutter滑动原理中我们提到了对于每一个Scrollable
组件真正控制滑动的是ScrollPosition
,文章的核心思路是:对于Case1的滑动冲突,其实当手指滑动外面的ScrollView的时候现象是不存在冲突问题的,所以只要解决ListView的上的滑动事件即可。对于ListView我们考虑两种场景,当上滑ListView的时候,如果Head还能继续滑动则应该由外面的ScrollView处理。下滑的时候先让ListView处理,如果ListView已经滑动到顶部了,则将剩余的事件交给外面的ScrollView处理。
ScrollController上官方为我们提供了两种自定义滑动行为的建议:
To further customize scrolling behavior with a Scrollable:
- You can provide a viewportBuilder to customize the child model. For example, SingleChildScrollView uses a viewport that displays a single box child whereas CustomScrollView uses a Viewport or a ShrinkWrappingViewport, both of which display a list of slivers.
- You can provide a custom ScrollController that creates a custom ScrollPosition subclass. For example, PageView uses a PageController, which creates a page-oriented scroll position subclass that keeps the same page visible when the Scrollable resizes.
这里选择了第二种方式,自定义ScrollPosition
可以提供一个自定义的[ScrollController]来创建一个自定义的 [ScrollPosition]子类。例如,[PageView]使用了 [PageController],它创建了一个面向页面的滚动位置子类,该子类在[Scrollable]调整大小时保持同一页面可见。
具体做法:
整体解法如图,自定义三个类ConflictScrollController
(滑动控制器),ConflictScrollPosition
(处理滑动的实际对象),ConflictScrollCoordinator
(滑动协调器)。关系:ConflictScrollController
生成ConflictScrollPosition
ConflictScrollPosition
->将滑动事件交给ConflictScrollCoordinator
进行协调。
指一些核心的处理逻辑
ConflictScrollPosition#applyUserOffset
/// 当手指滑动时,该方法会获取到滑动距离
/// [delta]滑动距离,正增量表示下滑,负增量向上滑
/// 我们需要把子部件的 滑动数据 交给协调器处理,主部件无干扰
@override
void applyUserOffset(double delta) {
ScrollDirection userScrollDirection =
delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse;
if (debugLabel != coordinator.pageLabel)
//如果是嵌套内部滑动控件,则通过协调器处理
return coordinator.applyUserOffset(delta, userScrollDirection, this);
//否则使用自身的行为处理
updateUserScrollDirection(userScrollDirection);
setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta));
}
在实战Flutter滑动原理中提到,在确定一个滑动控件响应滑动之后,最终会调到ScrollPosition#applyUserOffset
,这里先判断是不是嵌套内部的ListView,如果是交给协调器处理,否则自身响应。
ConflictScrollCoordinator#applyUserOffset
/// 子部件滑动数据协调
/// [userScrollDirection]用户滑动方向
/// [position]被滑动的子部件的位置信息
void applyUserOffset(double delta,
[ScrollDirection userScrollDirection, ConflictScrollPosition position]) {
if (userScrollDirection == ScrollDirection.reverse) {
//如果是上滑先交给外部控制器消费,有剩余再自己消费
updateUserScrollDirection(_pageScrollPosition, userScrollDirection);
final innerDelta = _pageScrollPosition.applyClampedDragUpdate(delta);
if (innerDelta != 0.0) {
updateUserScrollDirection(position, userScrollDirection);
position.applyFullDragUpdate(innerDelta);
}
} else {
updateUserScrollDirection(position, userScrollDirection);
//否则先自己处理,有剩余在交给外部
final outerDelta = position.applyClampedDragUpdate(delta);
if (outerDelta != 0.0) {
updateUserScrollDirection(_pageScrollPosition, userScrollDirection);
_pageScrollPosition.applyFullDragUpdate(outerDelta);
}
}
}
协调器中先判断滑动的方向如果是上滑先交给外部控制器消费,有剩余再自己消费,否则先自己处理,有剩余在交给外部。
当然不光要处理applyUserOffset
,还有goBallistic
,完整代码参考:github.com/canoninmajo…
Case 2:监听ScrollNotification
case2的解决办法来自stackOverFlow:[How to “merge” scrolls on a TabBarView inside a PageView?]
解决的思路是:当滑动到TabBarView的最后一页(无论是左还是右的时候)系统会发出OverscrollNotification
的通知。那么我们在外部监听,当收到这个通知的时候将滑动事件交给外部处理即可。附可运行链接dart pad
NotificationListener(
onNotification: (notification) {
if (notification is ScrollStartNotification) {
//滑动起始的通知先存起来
dragStartDetails = notification.dragDetails;
}
if (notification is OverscrollNotification) {
//当发生OverScroll的时候,生成外部滑动的drag对象
drag = _pageController.position.drag(dragStartDetails, () {});
//使用外部滑动的drag对象进行滑动
drag.update(notification.dragDetails);
}
if (notification is ScrollEndNotification) {
//滑动结束后取消
drag?.cancel();
}
return true;
},
child: TabBarView(
controller: _tabController,
children: <Widget>[
Container(color: Colors.green[800]),
Container(color: Colors.green),
Container(color: Colors.green[200]),
],
),
)
不过这个按照下面的做法可以使滑动过程更加的流畅
if (notification is UserScrollNotification &&
notification.direction == ScrollDirection.forward &&
!_tabController.indexIsChanging &&
dragStartDetails != null &&
_tabController.index == 0) {
_pageController.position.drag(dragStartDetails, () {});
}
// Simialrly Handle the last tab.
if (notification is UserScrollNotification &&
notification.direction == ScrollDirection.reverse &&
!_tabController.indexIsChanging &&
dragStartDetails != null &&
_tabController.index == _tabController.length - 1) {
_pageController.position.drag(dragStartDetails, () {});
}
结语
至此深入进阶系列的Flutter事件分发相关文章总算写完了,感觉阅读源码是一个逐渐明朗的过程。对于一个装置如果不了解他的原理,就会把他想象的很复杂,但一层层解开后会发现其实整个过程没那么复杂,遇到问题也能从里面找到解决的思路。但源码的阅读切忌以小失大,先有一条主线索,看整个流程,之后再去把握细节,就不会陷入源码无法得其道了。
最后
下一期将会和大家一起研究Flutter UI原理,Flutter的是如何渲染?Widget的build是由谁回调?将在下期中一一揭晓。