需求来源
这是领苗确认记录详情页需要展示给用户的内容,大家可以看到这个页面要承载很多的信息,要向下滚动一段很长的距离才能展示完,想要实现的效果是在页面的顶部有一个TabBar,用户可以通过点击TabBar进行锚点(jumpTo到指定位置),AppBar下的整个页面是可以自由滚动的,在滚动过程中AppBar始终固定在顶部,TabBar在第一次进入详情页的时候不显示,只有在向下滑动的时候会由透明渐变为不透明并固定在顶部,同时当页面滑动到TabBar锚点位置的时候TabBar会切换到对应的下标,也就是要实现TabBar和ScrollView联动的双向控制,Tabbar的切换可以控制页面的跳转,页面的滑动又可以反过来控制TabBar的切换,类似与京东、淘宝的商品详情页效果。
开发思路
- ScrollView+TabBar
大家都知道一般情况下TabBar是和TabBarView配合使用的,它俩受同一个TabController控制,所以能轻松实现联动,而ScrollerView有自己的滑动控制器ScrollController,TabController和ScrollController并不能直接互相控制,必须再实现一个Controller把TabController和ScollController结合起来才能使用,这种实现方式没有深入研究过,感兴趣的同学可以自行尝试。 -
SliverAppBar展开折叠
SliverAppBar控件可以实现页面头部区域展开、折叠的效果,类似于Android中的CollapsingToolbarLayout,效果图如下:
SliverAppBar控件需要和CustomScrollView搭配使用,SliverAppBar要通常放在slivers的第一位,后面接其他sliver控件。
CustomScrollView(
slivers: [
SliverAppBar(),
//其他sliver控件
])
-
NestedScrollView滚动神器
NestedScrollView是一个可以在其内部嵌套其他滚动视图的滚动视图组件,其滚动位置是固有链接的。
在普通的ScrollView中, 如果有一个Sliver组件容纳了一个TabBarView,它沿相反的方向滚动(例如,允许用户在标签所代表的页面之间水平滑动,而列表则垂直滚动),则该TabBarView内部的任何列表都不会相互作用 与外部ScrollView。例如,浏览内部列表以滚动到顶部不会导致外部ScrollView中的SliverAppBar折叠以展开。 效果图如下:
采用方案
SliverAppBar基本已经达到了我们想要的效果,但在界面顶部会有块空白区域试了很多方法怎么都去不掉,最后看了SliverAppBar这个控件的源码发现是它自带的初始高度。
这个没法设置或消除,不可能直接去改源码,所以后来换了一种实现思路,舍弃了SliverAppBar这个控件,以Stack的形式将TabBar置于ScrollView之上也能达到我们想要的效果,那么问题来了,如何实现TabBar的滚动渐变?很容易想到Opacity透明度控件,通过滚动监听来控制TabBar透明度的改变,借助Notificaion可以完美实现我们的需求。
Notification
Notification是Flutter中一个重要的机制,在Widget树中,每一个节点都可以分发通知(Notification)与父(包括祖先)Widget通信,通知会沿着当前节点(context)向上传递,所有父节点都可以通过NotificationListener来监听自己关注的通知,Flutter中称这种通知由子向父的传递为“通知冒泡”(Notification Bubbling)。
Flutter中很多地方使用了通知,如可滚动(Scrollable) Widget中滑动时就会分发ScrollNotification,而Scrollbar正是通过监听ScrollNotification来确定滚动条位置的。除了ScrollNotification,Flutter中还有SizeChangedLayoutNotification、KeepAliveNotification 、LayoutChangedNotification等。
通过NotificationListener监听滚动事件和通过ScrollController有两个主要的不同:
- 通过NotificationListener可以在从可滚动组件到widget树根之间任意位置都能监听。而ScrollController只能和具体的可滚动组件关联后才可以。
- 收到滚动事件后获得的信息不同;NotificationListener在收到滚动事件时,通知中会携带当前滚动位置和ViewPort的一些信息,而ScrollController只能获取当前滚动位置。
demo实现
NestedScrollView(
controller: _ctl,
headerSliverBuilder: _sliverBuilder,//传空
body: NotificationListener(
onNotification: (ScrollNotification notification) {
if (notification is ScrollUpdateNotification) {//ScrollUpdateNotification判断是为了确保是由滚动触发的offset的改变
int pixels = notification.metrics.pixels.toInt();
//根据滚动的偏移量计算出透明度,实现appBar滚动渐变
double alpha =
notification.metrics.pixels / APPBAR_SCROLL_OFFSET;
if (alpha < 0)
alpha = 0;
else if (alpha > 1) alpha = 1;
setState(() {
appBarAlpha = alpha;
if (pixels == 0) {
isClick = false;
_tabC.index = 0;
} else if (480 < pixels && pixels < 520) {
isClick = false;
_tabC.index = 1;
print(_tabC.index);
} else if (980 < pixels && pixels < 1000) {
isClick = false;
_tabC.index = 2;
}
});
print(appBarAlpha);
}
return false;
},
child: Stack(
children: [
new BodyView(widget.widgets, type, isClick),
Opacity(
opacity: appBarAlpha,//透明度随滚动渐变
child: Container(
padding: EdgeInsets.symmetric(horizontal: 80.0),
color: Colors.white,
height: 40,
child: new TabBar(
controller: _tabC,
indicatorSize: TabBarIndicatorSize.label,
labelColor: Color(0xffFF4F73),
indicatorColor: Color(0xffFF4F73),
unselectedLabelColor: Color(0xff000000),
labelStyle: new TextStyle(fontSize: 14.0),
labelPadding: EdgeInsets.only(bottom: 20),
indicatorPadding: EdgeInsets.only(
bottom: 15, top: 10, left: 5, right: 5.0),
tabs: tabs.map((item) => new Text('$item')).toList()),
),
),
],
))),
通过改造后,目前这个组件的原型已经实现并且可以满足我们的需求,最后就是对该demo进行完善使其能够完美接入我们的业务,做到技术赋能业务。
完整源码
/*
* @Author: yz.lilinjun
* @Date: 2020-06-16 18:42:49
* @Last Modified by: yz.lilinjun
* @Last Modified time: 2020-07-11 17:49:02
*/
import 'package:flutter/material.dart';
import 'package:yingzi_flutter_fmc_plugin/yz_fmc_uikit.dart';
const APPBAR_SCROLL_OFFSET = 100;
const TABBAR_HEIGHT = 44.0;
class YZSliverAppBarPage extends StatefulWidget {
final List listWidgets;
final List tabs;
final List sliverBuilder;
YZSliverAppBarPage({this.listWidgets, this.tabs, this.sliverBuilder});
@override
State createState() => new _YZSliverAppBarPageState();
}
class _YZSliverAppBarPageState extends State
with TickerProviderStateMixin {
TabController _tabC; //TabBar的控制器
ScrollController _ctl = new ScrollController(); //NestedScrollView的主控制器
List listWidgets = []; //存储封装好的子模块视图的数组
List positions = List(); //记录每个子模块的位置信息
int index; //TabBar控制锚点的子模块索引
double appBarAlpha = 0; //TabBar透明度的控制参数
bool isScroll = true; //滚动开关,开启时,点击监听失效
bool isClick = false; //点击开关,开启时,滚动监听失效
@override
void initState() {
super.initState();
listWidgets = widget.listWidgets.map((view) {
//对外部传入的每个Widget进行封装,并定义GlobalKey
GlobalKey _key = GlobalKey();
return YZSliverWidgetEntity(key: _key, childView: view);
}).toList();
_tabC = new TabController(length: widget.tabs.length, vsync: this);
_tabC.addListener(() => _onTabChanged());
WidgetsBinding.instance.addPostFrameCallback((_) {
//监听Widget是否绘制完毕
_getPositions();
});
setState(() {});
}
@override
Widget build(BuildContext context) {
print('内嵌页面build');
return Scaffold(
body: NestedScrollView(
controller: _ctl,
headerSliverBuilder: _sliverBuilder,
body: NotificationListener(
onNotification: (ScrollNotification notification) {
if (notification is ScrollUpdateNotification) {
//ScrollUpdateNotification判断是为了确保是由滚动触发的offset的改变
ScrollMetrics metrics = notification.metrics;
print(metrics.pixels); // 当前位置
_onScroll(notification.metrics.pixels);
}
return false;
},
child: Stack(
children: [
YZBodyView(listWidgets, index, isScroll, positions),
_tabBar()
],
))),
);
}
List _sliverBuilder(BuildContext context, bool innerBoxIsScrolled) {
return widget.sliverBuilder ?? [];
}
Widget _tabBar() {
return Opacity(
opacity: appBarAlpha, //透明度随滚动渐变
child: Container(
color: Colors.white,
height: TABBAR_HEIGHT,
child: TabBar(
onTap: (i) {
//由于滚动和点击动作互斥,而TabController只能监听到tabBar下标的切换,
//无法监听到点击动作,所以需要实现TabBar的点击回调,否则会出现页面死锁
isClick = true;
},
controller: _tabC,
indicatorSize: TabBarIndicatorSize.label,
labelColor: Color(YZThemeFMC.colors.fontColor29C542),
indicatorColor: Color(YZThemeFMC.colors.color29C542),
unselectedLabelColor: Color(YZThemeFMC.colors.fontColor595959),
labelStyle: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w500),
unselectedLabelStyle:
TextStyle(fontSize: 16.0, fontWeight: FontWeight.w500),
tabs: widget.tabs),
),
);
}
//监听TabBar的切换事件,通知ScrollView进行页面锚点
void _onTabChanged() {
if (isClick) {
setState(() {
isScroll = false;
index = _tabC.index;
});
}
}
//滚动监听
void _onScroll(double pixels) {
isClick = false;
double offset = pixels + positions[0].dy + TABBAR_HEIGHT; //误差修正
//根据滚动的偏移量计算出透明度,实现appBar滚动渐变
double alpha = pixels / APPBAR_SCROLL_OFFSET;
if (alpha < 0)
alpha = 0;
else if (alpha > 1) alpha = 1;
setState(() {
appBarAlpha = alpha;
for (int i = 0; i < positions.length; i++) {
if (i == positions.length - 1) {
if (offset >= positions[i].dy) {
//当偏离值溢出控件的滚动范围,TabBar下标始终指向last one
isScroll = true;
_tabC.index = i;
}
} else if (positions[i].dy <= offset && offset < positions[i + 1].dy) {
//当滚动的偏离值一进入某个子模块Widget的高度范围内时,则修正TabBar对应的下标
isScroll = true;
_tabC.index = i;
}
}
});
}
//我们需要获取Widget的大小或位置信息。但Widget对象本身没有大小及位置数据,
//那么想要拿到Widget的大小及位置信息,就需要通过与Widget对象相关联的RenderBox对象来获取
void _getPositions() { //获取滚动视图每个子模块的位置信息
for (int i = 0; i < listWidgets.length; i++) {
GlobalKey _key = GlobalKey();
_key = listWidgets[i].key; ////页面渲染完成后遍历拿到每个子模块视图的key
final RenderBox renderBox =
_key.currentContext.findRenderObject(); //获取`RenderBox`对象
final Offset position =
renderBox.localToGlobal(Offset.zero); //获取renderBox的位置
print('widget$i position = $position');
positions.add(position);
//最后一个视图不能占满全屏的时候自动补全高度
// if (i == listWidgets.length - 1) {
// final double lastWidgetHeight = renderBox.size.height;
// final double deviceHeight = MediaQuery.of(context).size.height;
// if (lastWidgetHeight < deviceHeight) {
// GlobalKey _key = GlobalKey();
// listWidgets.removeLast();
// listWidgets.add(YZSliverWidgetEntity(
// key: _key,
// childView: widget.listWidgets.last,
// height: deviceHeight));
// }
// }
}
}
}
//滚动页面的body部分
class YZBodyView extends StatefulWidget {
final List listWidgets;
final int index;
final bool isScroll;
final List positions;
YZBodyView(this.listWidgets, this.index, this.isScroll, this.positions);
@override
_YZBodyViewState createState() => _YZBodyViewState();
}
class _YZBodyViewState extends State {
ScrollController _innerC;
@override
void initState() {
super.initState();
PrimaryScrollController primaryScrollController =
context.findAncestorWidgetOfExactType(); //取得上下文的父类控制body的主控制器
_innerC = primaryScrollController.controller; //让内控制器等于取到的这个控制器
}
@override
Widget build(BuildContext context) {
if (!widget.isScroll) _actions(widget.index);
return new SingleChildScrollView(
child: new Column(children: widget.listWidgets),
);
}
//锚点动作
void _actions(int index) {
setState(() {
WidgetsBinding.instance.addPostFrameCallback((callback) {
for (int i = 0; i < widget.positions.length; i++) {
if (index == 0) {
_innerC.jumpTo(0.0); //点击第一个tab直接跳转至顶部
break;
} else {
//点击tab跳转误差修正,positions记录的只是实际渲染的renderBox位置
//_innerC控制的只是可滚动的部分,不包含NestedScrollView以外的部分
double position =
widget.positions[i].dy - widget.positions[0].dy - TABBAR_HEIGHT;
if (index == i) {
//跳转到页面指定位置
_innerC.jumpTo(position);
break;
}
}
}
});
});
}
}
//对传入的widgets进行封装处理以便取得每个widget的key
class YZSliverWidgetEntity extends StatefulWidget {
final Widget childView;
final double height;
YZSliverWidgetEntity({Key key, this.childView, this.height})
: super(key: key);
@override
YZSliverWidgetEntityState createState() => YZSliverWidgetEntityState();
}
class YZSliverWidgetEntityState extends State {
Widget childView;
@override
void initState() {
super.initState();
childView = widget.childView;
setState(() {});
}
@override
Widget build(BuildContext context) {
return Container(
height: widget.height,
child: childView,
);
}
}
使用示例
_renderTab() {
final List myTabs = [
Tab(text: YZShell.of(context).text('放苗信息')),
Tab(text: YZShell.of(context).text('领苗信息')),
Tab(text: YZShell.of(context).text('审批流程')),
];
return myTabs;
}
YZSliverAppBarPage(
tabs: _renderTab(),
listWidgets:
[
_seedlingInfo(),
_getSeedlingInfo(context),
_approvalProcess(),
],
);
适用性
在一个页面滚动区域不是很长的情况下不建议使用,只有当页面足够长的情况下使用这个组件效果会比较好,因为如果一个页面过短,点击TabBar最后一栏进行锚点时,页面最后一个子模块内容无法置顶,只会将页面最后的内容推到屏幕范围内,并且由于TabBar监听到的是滚动的位置,会导致TabBar无法切换到对应的下标,看上去会像个bug,其实是因为底部已经没有内容了。
总结
这个组件本身并没有太大的技术难度,但是有一些比较细节的小逻辑得处理好,并且从中衍生出来的很多琐碎的小的知识点都可以进行拓展。 在组件开发的过程中遇到问题时不局限于控件本身的设计模式,转变开发思维去找寻一些比较新颖的解决方案可能会有意外的收获。同时技术不能脱离于业务,技术赋能业务才能创造价值。
参考
- Flutter用NestedScrollView的项目必须知道的坑
- Flutter 首页必用组件NestedScrollView
- 滚动监听及控制