大家好,
小半年的10-10-6,所以一直没更新,借着五一假期的时间更新一篇热热身,我们开门见山直入主题吧。
说明:代码直接写在了框架项目的demo里,所以用到了一些里面的代码,不过不影响阅读。
另外,老规矩,说明我还是写在注释里方便阅读
Bedrock MVVM+Provider 开发脚手架
根布局
@override
Widget build(BuildContext context) {
return switchStatusBar2Dark(
child: ProviderWidget(
builder: (ctx,model,child){
return _buildPage();
},
model: vm,onModelReady: (model) {},));
}
Widget _buildPage() {
return Container(
width: getWidthPx(750),height: getHeightPx(1334),
child: Column(
children: [
getSizeBox(height: getWidthPx(40) + ScreenUtil.getStatusBarH(context)),
_buildTabs(), //横向的tab listview
getSizeBox(height: getWidthPx(40)),
Expanded(
child: _buildPageBody(), //纵向的 list view
),
],
),
);
}
横向tab的代码
Widget _buildTabs() {
return Container(
width: getWidthPx(750),height: getWidthPx(100),
child: ListView.builder(
cacheExtent: 1500,
padding: EdgeInsets.symmetric(horizontal: getWidthPx(32)),
//用于控制listview,所以添加了scroll controller
controller: vm.tabController,
scrollDirection: Axis.horizontal,
itemCount: vm.tabsTitle.length,
itemBuilder: (ctx,e) {
return GestureDetector(
onTap: () {
vm.tapTab(e);
},
child: TabItemWidget(e).generateWidget(),
);
},
),
);
}
纵向的listview
Widget _buildPageBody() {
return Container(
width: getWidthPx(750),color: Colors.white,
child: ListView.builder(
//用于控制listview,所以添加了scroll controller
controller: vm.bodyController,
itemCount: vm.tabsTitle.length,
padding: EdgeInsets.symmetric(horizontal: getWidthPx(32)),
itemBuilder: (ctx,e) => BodyItemWidget(e).generateWidget(),
),
);
}
以上就是页面的基础布局代码,我们接着看一下 item 的widget代码。
tab item 的布局代码 :
@override
Widget build(BuildContext context) {
//这里保存一下tab的 context 后面会用到
vm.saveTabsChildCtx(tabIndex, context);
//这里用selector 控制一下刷新范围
return Selector(
selector: (ctx, model) {
return model.selectTabIndex;
},
builder: (ctx,value,child) {
return Container(
width: getWidthPx(150),
padding: EdgeInsets.symmetric(horizontal: getWidthPx(20)),
margin: EdgeInsets.only(right: getWidthPx(32)),
decoration: BoxDecoration(
color: value == tabIndex ? Colors.green : Colors.white,
borderRadius: BorderRadius.all(Radius.circular(getHeightPx(16))),
border: Border.all(color: Colors.black,width: getWidthPx(2))
),alignment: Alignment.center,
child: Text('标题 $tabIndex',style: TextStyle(color: Colors.black,fontSize: getSp(20)),),
);
},
);
}
纵向列表的item 布局代码 :
late double height;
@override
void initState() {
super.initState();
vm = Provider.of(context,listen: false);
//生成一个随机高度
height = (Random().nextInt(20)).clamp(5, 19) * 50;
}
@override
Widget build(BuildContext context) {
//保存一下 context 如上
vm.saveBodyItemCtx(index, context);
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(getHeightPx(16))),
border: Border.all(color: Colors.lightBlueAccent,width: getWidthPx(2))
),margin: EdgeInsets.only(bottom: getWidthPx(32)),
child: Column(
children: [
Text('标题 $index',style: TextStyle(color: Colors.black,fontSize: getSp(30))),
Container(
width: getWidthPx(750),
height: getWidthPx(height),
margin: EdgeInsets.symmetric(horizontal: getWidthPx(16),vertical: getWidthPx(42)),
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(getHeightPx(16))),
color: Colors.grey
),child: Text('内容 $index',style: TextStyle(color: Colors.white,fontSize: getSp(50))),
),
],
),
);
}
这里页面的整体布局代码就完成了,非常简单,下面来实现具体效果。
首先我们需要给垂直列表的controller设置一个监听器。
在页面布局中:
@override
void initState() {
super.initState();
vm.initListeners();
}
void initListeners() {
bodyController.addListener(() {
if (isTapAnimateList) return;
//在不改变缓存的情况下,总是有3个存活
updateUnMountedList();
updateTabs();
});
}
然后我们设置一个变量 isTapAnimateList
///表示点击 tab 时,驱动 垂直列表
bool isTapAnimateList = false;
然后我们通过updateUnMountedList 方法收集/更新一下存活的item的index。
也可以理解成没有被回收的item
/// 保存 mounted item 用于垂直list
List? unMountedList;
///更新 存活列表
void updateUnMountedList() {
unMountedList = bodyChildCtx.entries
.where((ctx) {
StatefulElement ele = ctx.value as StatefulElement;
return (ele.state.mounted);
})
.map((e) => e.key)
.toList();
}
这里可以看到有个新变量 bodyChildCtx
///body item context
final SplayTreeMap bodyChildCtx = SplayTreeMap();
它实际上就是我们在item widget的build里那个保存方法中用来存储item的context的:
当然,还有tab的保存容器
///tab item context
final SplayTreeMap tabsChildCtx = SplayTreeMap();
///body item context
final SplayTreeMap bodyChildCtx = SplayTreeMap();
///用来保存垂直item的相对偏移位置
final SplayTreeMap itemOffsetY = SplayTreeMap();
void saveBodyItemCtx(int index , BuildContext ctx) {
bodyChildCtx.update(index, (value) => ctx , ifAbsent: ()=>ctx);
}
void saveTabsChildCtx(int index, BuildContext ctx) {
tabsChildCtx.update(index, (value) => ctx,ifAbsent: () => ctx);
}
我们接着controller 的回调里面,看第三个方法:
///垂直list 当前滚动的位置
int currentItemIndex = 0;
///选择了第几个tab
int selectTabIndex = 0;
///滑动垂直list 驱动tabs滚动
/// * tabs正在滚动时,不响应其它滚动事件
bool isScrollAnimateTabs = false;
///垂直列表滚动时切换上方tabs
void updateTabs() {
if (isScrollAnimateTabs) return;
//当前垂直列表的第几个item在屏幕顶端
currentItemIndex = currentIndexOnScreen();
if (selectTabIndex == currentItemIndex) return;
selectTabIndex = currentItemIndex;
final ctx = tabsChildCtx[currentItemIndex];
if (ctx != null) {
StatefulElement ele = ctx as StatefulElement;
if(!ele.state.mounted) return;
isScrollAnimateTabs = true;
//这里我们对tabs做滚动动画
Scrollable.ensureVisible(ctx, duration: Duration(milliseconds: tabsScrollDuration));
}
isScrollAnimateTabs = false;
//刷新一下tab的选中状态
notifyListeners(refreshSelector: true);
}
我们看一下上面中涉及到的currentIndexOnScreen方法:
///返回当前垂直列表 在屏(顶端)的index
int currentIndexOnScreen() {
if (unMountedList == null || (unMountedList?.isEmpty ?? true)) return 0;
//这里做个首、尾的处理
if (bodyController.position.pixels == bodyController.position.minScrollExtent) {
//head pos
return unMountedList!.first;
} else if (bodyController.position.pixels == bodyController.position.maxScrollExtent) {
//tail pos
return unMountedList!.last;
}
//如果不是首位,我们需要计算一下item的偏移位置
final double scrollPos = bodyController.position.pixels;
//collect 计算一下item相对viewport的位置
collectVerticalItemHeight();
for (int i = 0; i < unMountedList!.length - 1; i++) {
final int ctxIndex = unMountedList![i];
//这个20 是灵活的,具体根据需求变化 ,我这里就写个20
if ((itemOffsetY[ctxIndex]! - scrollPos).abs() < 20) {
return unMountedList![i];
}
}
//容灾
return currentItemIndex;
}
计算item相对viewport的位置方法collectVerticalItemHeight 内部实现为:
///收集垂直列表item 偏移位置
/// * 此处应用item的高度不会变化,所以做了收集避免重复计算
void collectVerticalItemHeight() {
final minScrollExtent = bodyController.position.minScrollExtent;
final maxScrollExtent = bodyController.position.maxScrollExtent;
unMountedList!.forEach((live) {
if (itemOffsetY.containsKey(live)) return;
final BuildContext ctx = bodyChildCtx[live]!;
final RenderObject renderObject = ctx.findRenderObject()!;
//通过 item的ctx 获取到它的viweport
final RenderAbstractViewport viewport = RenderAbstractViewport.of(renderObject)!;
//通过viewport.getOffsetToReveal 获取它的偏移量
final double target = viewport.getOffsetToReveal(renderObject, 0.0).offset.clamp(minScrollExtent, maxScrollExtent);
itemOffsetY[live] = target;
});
}
至此我们就实现了滚动垂直列表切换tab的功能,下面我们需要实现点击tab来滚动到对应垂直list的item效果。
先要增加一个点击事件,这是肯定滴:
//这里重贴一下页面布局的代码
Widget _buildTabs() {
return Container(
width: getWidthPx(750),height: getWidthPx(100),
child: ListView.builder(
cacheExtent: 1500,
padding: EdgeInsets.symmetric(horizontal: getWidthPx(32)),
controller: vm.tabController,
scrollDirection: Axis.horizontal,
itemCount: vm.tabsTitle.length,
itemBuilder: (ctx,e) {
return GestureDetector(
onTap: () {
//这里是点击事件!
vm.tapTab(e);
},
child: TabItemWidget(e).generateWidget(),
);
},
),
);
}
void tapTab(int index) async {
//滚动 垂直列表的item
jumpToItem(index);
selectTabIndex = index;
//刷新一下tab选中效果
notifyListeners(refreshSelector: true);
//这里将点击的tab滚动到屏幕中间
await Scrollable.ensureVisible(tabsChildCtx[index]!,duration: Duration(milliseconds: 500),alignment: 0.5);
}
jumpToItem方法用来滚动到对应的item到屏幕的顶端,代码如下:
final List tabsTitle = List.generate(25, (index) => index);
///用于分片滚动时间,可根据需求调整
final int standardSingleTime = 128;
/// 长距离最小分片时间
final int onCardScrollDuration = 16*8;
///滚动到第[index]卡片
void jumpToItem(int index) async {
//计算一下与目标的距离
final int dis = (index - selectTabIndex).abs();
if (dis == 0) return;
//标记为 垂直列表的滚动为点击tab 驱动
isTapAnimateList = true;
if (dis == 1) {
//如果点击的是毗邻的,直接滚过去
final StatefulElement element = bodyChildCtx[index] as StatefulElement;
if(!element.state.mounted)return;
scrollDuration = 500;
await Scrollable.ensureVisible(element, duration: Duration(milliseconds: scrollDuration));
} else {
//如果不是毗邻的,那么意味着目标item可能还没有创建
//所以这里采用的方法是 一个一个滚过去 XD
// 根据距离占总长度比例 x 时间片, 算出一个单个item的滚动时间
// 不过不宜太小,否则绘制相关工作未完成会报空
scrollDuration = math.max((dis / tabsTitle.length * standardSingleTime).ceil(), onCardScrollDuration);
if (index > selectTabIndex) {
//tab 向右 ,list 向下 滑动
int i = selectTabIndex + 1;
while (i <= index) {
final StatefulElement element = bodyChildCtx[i] as StatefulElement;
if(!element.state.mounted) {
i--;
continue;
}
//通过while 循环,我们一个一个的滚动到目标点
await Scrollable.ensureVisible(element, duration: Duration(milliseconds: scrollDuration));
i++;
}
} else {
//与上面同理
//tab 向左 ,list 向上 滑动
int i = selectTabIndex - 1;
while (i >= index) {
final StatefulElement element = bodyChildCtx[i] as StatefulElement;
if(!element.state.mounted) {
i--;
continue;
}
await Scrollable.ensureVisible(element, duration: Duration(milliseconds: scrollDuration));
i--;
}
}
}
isTapAnimateList = false;
}
至此,交叉列表的垂直联动效果就基本完结了,整体还有很多可以优化的地方,后续再不断完善,有不足的地方欢迎指出,谢谢大家的阅读。
demo代码
Flutter 仿网易云音乐App
Flutter&Android 启动页(闪屏页)的加载流程和优化方案
Flutter版 仿.知乎列表的视差效果
Flutter——实现网易云音乐的渐进式卡片切换
Flutter 仿同花顺自选股列表