实现PageView嵌套滚动的思路
前言
其实 PageView 嵌套 PageView 并不算很罕见的场景,例如网易云音乐这种大量的应用这些场景,如果单独放在 Android 和 iOS来实现都很简单,例如 Android 的 ViewPager 嵌套 ViewPager 默认就支持嵌套滚动。 当子 ViewPager 滑动到最后一页再滑动就会触发父 ViewPager 的滑动,非常的丝滑。
就算一些复杂的场景我们可以自行实现NestedScrollingParent和NestedScrollingChild来自定义嵌套滚动,而 Flutter 中并没有很好的嵌套滚动自定义实现,只能用它自带的 NestedScrollView 控件来实现。
以下图为示例
如果只是想要做到 PageVie 嵌套 PageView 的场景好像只能自己手撕嵌套滚动的事件处理。
一、自定义控件
如果我们尝试做一个 PageView 嵌套 PageView 的示例其实很简单,我们会发现事件基本上一直都在子 PageView 上在移动到最后一页的时候无法触发父 PageView 的事件。
如果你问 AI 他会告诉你用 GestureDetector 或者 NotificationListener 来实现监听,这都是行不通的。
因为 PageView 内部已经处理了事件不能再监听事件,如果只是监听索引也只能修改布局的属性无法达到嵌套滚动的效果。
嵌套滑动判断优先级应以最先接收到事件的控件先做响应,比如说如果我先收到的是子 PageView 的滑动事件,在无法滑动或者事件处理完之前,外部的 PageView 都应该是无法响应的。
那么在子 PageView 上在移动到最后一页的时候如何把事件传递给父 PageView 呢?
如果要自定义就需要按照 NestedScrollerView 中_NestedScrollCoordinator 的做法,创建一个类,继承 ScrollActivityDelegate 和 ScrollHoldController,并修改 position 的方法,将其中drag,hold等需要协调处理的方法交由这个新类实现,而不是像默认的 NestedScrollerView 中根据滑动方向判断优先级的方式。
在applyUserOffset 方法中,判断子 Page 的 position ,其 pixels 加上滑动距离是否大于 maxScrollExtent ,通过这个简单判断一下是不是 overScroll ,并将事件根据结果分发给子 PageView 或者父 PageView 在 goBallistic 方法中,我是根据滑动方向判断子 PageView 是否会 overScroll。
所以整体的逻辑就是由最底层的子 PageView 负责计算,当遇到需要嵌套滑动的时候,计算出滑动结果并调用父 PageView 的 controller,其实就是判断一下是否是过度滑动,在需要的情况下交给父 ScrollerController 处理,基本的动画、复位算法,已经有现成方法实现,过渡滑动则需要 controller 所绑定的 position 中携带的pixel、maxScrollExtent、minScrollExtent等信息来判断。
子 PageVie 怎么拿到父 PageView 的 controller 进而操作父 PageView 的滑动则需要参考 NestedScrollerView-PrimaryScrollerController 的 controller 传递方式,父 PageView 只需要将自己的controller 放入 PrimaryScrollerController 这个 InHeritedWidget 即可。
具体代码在此【传送门】 感谢大佬的开源版本。
这个版本比较老,支持的Flutter的版本还需要我们自己修改一下代码。不过总的来说效果还是不能满足我的需求,有时候划着划着就丢失了Page,滑动不够丝滑并且滑动的惯性也没有处理。
再然后我到 pub 社区也没有找到比较好的方案,只能看后期能不能官方支持一下了。
二、铺平Page的思路
我突然想到既然 PageView 的嵌套有坑,那我不嵌套了,直接铺平用一层的 PageView 不就好了吗?
监听 PageView 的滚动根据索引的变化切换顶部的 Tab 不就可以了吗?
import 'package:cpt_payment/modules/payment/payment_view_model.dart';
import 'package:cs_resources/generated/assets.dart';
import 'package:cs_resources/generated/l10n.dart';
import 'package:cs_resources/theme/app_colors_theme.dart';
import 'package:cs_resources/theme/theme_config.dart';
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:router/ext/auto_router_extensions.dart';
import 'package:shared/utils/log_utils.dart';
import 'package:widgets/ext/ex_widget.dart';
import 'package:widgets/my_appbar.dart';
import 'package:widgets/my_load_image.dart';
import 'package:widgets/my_text_view.dart';
import '../../../router/page/payment_page_router.dart';
@RoutePage()
class PaymentPage extends HookConsumerWidget {
const PaymentPage({Key? key}) : super(key: key);
//启动当前页面
static void startInstance({BuildContext? context}) {
if (context != null) {
context.router.push(const PaymentPageRoute());
} else {
appRouter.push(const PaymentPageRoute());
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewModel = ref.read(paymentViewModelProvider.notifier);
int selectedInnerIndex = 1; // 1-3索引记录当前的值
return Scaffold(
appBar: MyAppBar.appBar(
context,
S.current.facility,
backgroundColor: context.appColors.whiteBG,
),
backgroundColor: context.appColors.backgroundDark,
body: AutoTabsRouter.pageView(
routes: const [
InfoPageRoute(),
CondoPaymentPageRoute(),
CondoActivePageRoute(),
CondoHistoryPageRoute(),
ManagePageRoute(),
],
builder: (context, child, pageController) {
final tabsRouter = AutoTabsRouter.of(context);
//监听赋值内部的选中索引
pageController.addListener(() {
if (tabsRouter.activeIndex >= 1 && tabsRouter.activeIndex <= 3) {
selectedInnerIndex = tabsRouter.activeIndex;
}
});
return Column(
children: [
Container(
color: context.appColors.whiteBG,
height: 120,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildTopCategory(
context,
Assets.paymentInfoIcon,
34,
41,
"Info",
tabsRouter.activeIndex == 0,
).onTap(
() {
tabsRouter.setActiveIndex(0);
},
),
_buildTopCategory(
context,
Assets.paymentCondoIcon,
48,
43,
"Condo",
tabsRouter.activeIndex == 1 || tabsRouter.activeIndex == 2 || tabsRouter.activeIndex == 3,
).onTap(
() {
tabsRouter.setActiveIndex(selectedInnerIndex);
},
),
_buildTopCategory(
context,
Assets.paymentManageIcon,
52,
46.5,
"Manage",
tabsRouter.activeIndex == 4,
).onTap(
() {
tabsRouter.setActiveIndex(4);
},
),
],
),
),
Expanded(
child: Column(
children: [
if (tabsRouter.activeIndex >= 1 && tabsRouter.activeIndex <= 3)
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildInnerTab(
context,
S.current.payment,
tabsRouter.activeIndex == 1,
).onTap(() {
tabsRouter.setActiveIndex(1);
}),
_buildInnerTab(
context,
S.current.facility_active,
tabsRouter.activeIndex == 2,
).onTap(() {
tabsRouter.setActiveIndex(2);
}),
_buildInnerTab(
context,
S.current.history,
tabsRouter.activeIndex == 3,
).onTap(() {
tabsRouter.setActiveIndex(3);
}),
],
).marginOnly(top: 14, bottom: 17),
Expanded(
child: child,
),
],
),
),
],
);
},
),
);
}
//顶部的Tab布局
Widget _buildTopCategory(BuildContext context, String iconPath, double iconWidth, double iconHeight, String title, bool isSelected) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Container(
width: 70,
height: 70,
decoration: BoxDecoration(
color: context.appColors.lightBlueBg, // 设置圆形背景颜色
shape: BoxShape.circle, // 设置为圆形
boxShadow: isSelected
? [
BoxShadow(
color: context.appColors.tabLightBlueShadow, // 设置阴影颜色
blurRadius: 5, // 设置模糊半径
spreadRadius: 0.05, // 控制阴影扩散
offset: const Offset(0, 4), // 设置阴影偏移量
),
]
: [], // 未选中时无阴影,
),
child: Center(
child: MyAssetImage(iconPath, width: iconWidth, height: iconHeight),
),
),
const SizedBox(height: 7),
MyTextView(
title,
fontSize: 15,
isFontMedium: true,
textColor: isSelected ? context.appColors.tabTextSelectedDefault : context.appColors.tabTextUnSelectedDefault,
),
],
);
}
//内部的Tab布局
Widget _buildInnerTab(BuildContext context, String title, bool isSelected) {
return MyTextView(
title,
fontSize: 16,
isFontMedium: true,
textColor: isSelected ? Colors.white : context.appColors.tabTextUnSelectedDefault,
backgroundColor: isSelected ? context.appColors.btnBgDefault : Colors.transparent,
cornerRadius: 16.5,
paddingLeft: 20,
paddingRight: 20,
paddingTop: 8,
paddingBottom: 8,
);
}
}
由于我是用的 AutoRouter 这里使用的 AutoRouter 的子路由控件,之前有讲过【传送门】
路由表的定义如下:
CustomRoute(
page: PaymentPageRoute.page,
path: RouterPath.payment,
transitionsBuilder: applySlideTransition,
children: [
AutoRoute(page: InfoPageRoute.page, path: 'info'),
AutoRoute(page: CondoPaymentPageRoute.page, path: 'payment'),
AutoRoute(page: CondoActivePageRoute.page, path: 'active'),
AutoRoute(page: CondoHistoryPageRoute.page, path: 'history'),
AutoRoute(page: ManagePageRoute.page, path: 'manage'),
],
),
这里没有嵌套 PageView 了只是一层,当然了这是 AutoRouter 的用法,你完全可以自己实现 PageView 的嵌套自己实现监听也是一样的效果。
问题:由于是有的父PageView没有子Tab,所以导致的是子Tab有显示隐藏的动画效果,显得稍微有点突兀,如果是父布局全部的子页面都有子Tab,那么就显得好一些:
后记
本文记录了 PageView 嵌套的两种方案,都只是权宜之计,并不是很完美的实现。
关于自定义控件的方案还需要完善细节并且实现physics的动画效果,还需要处理惯性的滚动,这一点也难做,关于铺平 PageView 的思路主要是在 子PageView 切换到 父 PageView 的时候动画效果的缺失导致的突兀效果。
后期的优化思路其实可以参考PageView的滚动Transform进度进而对子 PageView 上的 TabView 进行对应的动画操作从而达到平滑的过渡效果。
就目前来说我们也只能用第二种方案了,后期在进行优化一下,当然如果你有其他的更多的更好的实现方式,也希望大家能评论区交流一起学习进步。
OK,那么今天的分享就到这里啦,如果我的文章有错别字,不通顺的,或者代码、注释、有错漏的地方,同学们都可以指出修正。
如果感觉本文对你有一点的启发和帮助,还望你能点赞
支持一下,你的支持对我真的很重要。
这一期就此完结了。