需求描述
开发过程中会遇到一些常见的表单详情页面,比如商品详情,页面布局是由上到下三大部分组成:Header Tab Body,其中Tab的items会对应到页面的Body widgets上,点击后需要把页面滑动到对应的widget进行展示:
写在前面
在Flutter中这个结构的布局可以使用CustomScrollView轻松搭建,但是其中TabBar与Body的交互动作需要我们自己去完成,本文来梳理下该功能的实现方案
提前奉上pub和github仓库,旨在开源和互相学习。如有错误,欢迎大家指正,如果有帮助,希望可以点个like和star~
需求实现
布局结构
使用过CustomScrollView的同学们应该都知道,我们要提供不同的SliverWidget来给到组件的slivers属性中,通常每个组件我们使用SliverBoxToAdapter来包裹,在此处需要吸顶的TabBar我们使用SliverPersistentHeader并设置其pinned属性为true就可以轻松实现;
关于CustomScrollView的slivers原理这里不做原理详述,后续另起篇幅做介绍,我们只需要Flutter中在对于ScrollView的渲染是拿到所有slivers,然后根据当前的偏移量来渲染当前需要展示在窗口的slivers。所以当要吸顶的sliver偏移量已经到达屏幕顶部或者要更顶部的时候,CustomScrollView会把这个sliver的renderObject绘制在屏幕最顶部,而不是跟随其他sliver被滑动隐藏掉。
好了我们现在布局结构已经完成了,上下滑动也可以达到TabBar吸顶的效果了,接下来我们就需要来实现核心的部分了,剩下的工作分为两部分
- 点击TabItem后,计算应该的偏移量,设置给ScrollView的ScrollController
- 当ScrollView滑动后,计算当前偏移量应该对应的TabItem,设置给TabBar的TabController
所以核心部分是我们如何去计算&监听偏移量,接下来我们一起跟着思路来尝试实现需求
点击TabItem定位ScrollView
假设我们TabBar有4个item,那么关联的body widgets应该就也是4个,当我们点击了index=0的item后,我们的整个ScrollView就要随之移动,并且要把index=0的widget移动到当前屏幕最顶端,也就是TabBar的组件之下。
那么我们需要移动多少距离呢?我们简单先推测一下,在垂直方向上,在index为0的body widget之上,存在着headerWidget、TabBar,所以我们需要知道的偏移量就是这些组件的高度之和。那么结果是怎么样的呢?
我们发现Item0显然是被TabBar遮盖住了,说明我们计算的偏移量是有问题的,整体多向上滑动了一部分,也就是说偏移量计算大了,那么是多了多少量呢?
首先我们来理解一下,当bodyWidget0需要置顶展示的时候,它并不是在ScrollView这个容器的最顶部,因为当headerWidget滑动到隐藏过后,TabBar这个Widget是悬浮在页面的顶部了,在这个TabBar刚刚变为悬浮的时刻,bodyWidget0就属于最顶部的位置了,所以这个偏移量是不需要去加上TabBar的高度的:
关于如何得到组件的高度,在Flutter中我们需要给组件设置一个GlobalKey,然后通过key去获取它的RenderObject来获取当前组件高度:
RenderBox? renderBox = widgetKey.currentContext?.findRenderObject() as RenderBox?;
double? widgetHeight = renderBox?.size.height;
所以当我们的TabBar某个item被选中后,我们需要去做的计算是:
void _tabClicked(int index) {
/// header,传入一个Widget的集合,因为可能存在多个HeaderWidget
double topWidgetsHeight = 0.0;
for (GlobalKey key in widget.headerWidgetsKey) {
RenderObject? renderObject = key.currentContext?.findRenderObject();
if (renderObject is RenderSliverToBoxAdapter) {
topWidgetsHeight += renderObject.child?.size.height ?? 0.0;
}
}
/// body,传入一个Widget的集合,因为可能存在多个BodyWidget
double bottomWidgetsHeight = 0.0;
List<GlobalKey>? bottomWidgetKeyList =
widget.bodyWidgetsKey.sublist(0, index);
for (GlobalKey key in bottomWidgetKeyList) {
RenderObject? renderObject = key.currentContext?.findRenderObject();
if (renderObject is RenderSliverToBoxAdapter) {
bottomWidgetsHeight += renderObject.child?.size.height ?? 0.0;
}
}
double toOffset = topWidgetsHeight + bottomWidgetsHeight;
_scrollController?.animateTo(toOffset,
duration: widget.scrollViewLocateDuration, curve: Curves.linear);
}
看到这里你可能想问,为何要每次tab锁定后去计算一次所有组件的高度,难道不能提前计算完毕然后存储下来吗?原因是需要考虑到页面的复杂性,如果整个页面不论是Header还是Body中的widget的高度都是可能发生变化的,那么我们提前计算就完全没有意义,只能在需要去触发移动的时候进行计算
这样我们讲的两部分工作的前一半就完成了,接着我们来看看怎么反向去通过ScrollView偏移量去定位TabBar
ScrollView滑动后定位TabItem
其实到这里就比较简单了,有了前面的思路,自然我们也就知道当偏移量发生变化后如何去计算当前偏移量属于哪个bodyWidget展示区间,从而再去定位到对应的TabItem。这里也一样,我们只能在特定时刻去计算偏移量(因为组件的高度可能发生变化)
当手指滑动了ScrollView,偏移量即可发生变化,TabBar就需要开始定位了,那么这个特定时刻是什么时候呢?
我们这里需要用到一个组件来包裹ScrollView,就是NotificationListener,这个组件包裹下的Widget发生的一些变化,会给NotificationListener发送一个通知(这个通知不仅限滑动的事件,像组件的大小发生变化等也会发送一个通知)
return NotificationListener(
onNotification: (notification) {
if (notification is ScrollStartNotification) {
// 收到滑动开始的通知
} else if (notification is ScrollUpdateNotification) {
// 收到滑动进行中的通知
} else if (notification is ScrollEndNotification) {
// 收到滑动结束的通知
}
return false;
},
child: CustomScrollView());
}
当我们收到滑动开始的通知时,需要计算当前组件的阶梯高度,如TabItem0应该定位的高度应该大于headerWidgets高度
而小于(headerWidgets高度
+bodyWidget0高度
):
void _assembleWidgetsOffset() {
widgetsOffsetList.clear();
// header offset
double topWidgetsHeight = 0.0;
for (GlobalKey key in widget.headerWidgetsKey) {
RenderObject? renderObject = key.currentContext?.findRenderObject();
if (renderObject is RenderSliverToBoxAdapter) {
topWidgetsHeight += renderObject.child?.size.height ?? 0.0;
}
}
widgetsOffsetList.add(topWidgetsHeight);
// body offset
double bottomWidgetsHeight = 0.0;
for (GlobalKey key in widget.bodyWidgetsKey) {
RenderObject? renderObject = key.currentContext?.findRenderObject();
if (renderObject is RenderSliverToBoxAdapter) {
bottomWidgetsHeight += renderObject.child?.size.height ?? 0.0;
widgetsOffsetList.add(topWidgetsHeight + bottomWidgetsHeight);
}
}
}
此处的widgetsOffsetList就是我们记录的高度阶梯集合,接下来在ScrollUpdateNotification通知,也就是滑动中的通知时,我们只需获取当前的ScrollView偏移量进行简单对比,得到一个要定位的TabBar index,然后去调用TabController的animateTo即可:
double scrollViewOffset = _scrollController?.offset ?? 0.0;
int toIndex = -1;
for (int i = widgetsOffsetList.length - 1; i >= 0; i--) {
double itemOffset = widgetsOffsetList[i];
if (scrollViewOffset > itemOffset) {
toIndex = i;
break;
}
}
if (toIndex == -1) {
toIndex = 0;
}
_tabController.animateTo(toIndex, duration: widget.tabLocateDuration);
冲突问题
上述两个部分我们实现后,基本的核心逻辑我们也就完成了。但是还有一些问题需要解决,试想一下,当我们点击某个TabItem的时候,ScrollView开始了滑动,这个时候同时又收到了ScrollUpdateNotification通知,在body的widgets经过一个临界点时,又触发了TabBar的重定位,那么又进入了前面一个环节,就存在这样的一个冲突需要解决
上述两个部分我们实现后,核心的逻辑我们就已完成。但是还有一个重要的问题需要解决,试想:
如果我们说点击TabBar去定位ScrollView这个操作属于正向操作
,滑动ScrollView后重定位TabBar属于逆向操作
,那么当我们正向操作
把TabBar的index从0点到了3,那么ScrollView开始滑动,滑动过程中又触发了逆向操作
,那么这就导致一个冲突,在重复的进行偏移量设置。
这个问题如何解决呢,其实很简单,我们需要有一个标志位去区分正向触发和逆向触发,在TabBar组件中有一个属性onTap,这个回调函数只会在手动点击TabItem的时候触发,而不会在TabController去设置index后触发,所以我们可以在这个onTap触发后记录一个标志位,然后当ScrollView滑动的时候判断该标志位然后来忽略TabBar的重定位,即可解决
tabTapManual(int index) {
print("用户主动点击了TabItem,记录标志位");
tabTapByManual = true;
}
写在最后
以上就是我们实现这个功能的所有思路,核心思路就是计算偏移量,然后根据TabBar和ScrollView各自的controller去设置偏移位置
除了核心部分,还有许多细节需要完善优化,比如上述讲到的冲突问题,还有偏移量最大值问题,这些问题的方案思路都在注释写清楚了,供各位同学阅读,代码仓促,略显粗糙,旨在交流,欢迎各种建议和意见!!!