欢迎关注微信公众号:FSA全栈行动 👋
系列文章
开源库: flutter_scrollview_observer
- Flutter - 获取ListView当前正在显示的Widget信息
- Flutter - 列表滚动定位超强辅助库,墙裂推荐!🔥
- Flutter - 快速实现聊天会话列表的效果,完美💯
- Flutter - 船新升级😱支持观察第三方构建的滚动视图💪
- Flutter - 瀑布流交替播放视频 🎞
- Flutter - IM保持消息位置大升级(支持ChatGPT生成式消息) 🤖
- Flutter - 滚动视图中的表单防遮挡 🗒
- Flutter - 秒杀1/2曝光统计 📊
一、概述
相信大家都很熟悉微信的通讯录列表功能,而实现该效果可能会直接选用现有的已封装好的开源库,比如:azlistview,这些库都很优秀,能快速帮助我们完成功能研发,但是会有一些小问题,比如:
- 依赖了一些其它第三方开源库(如:
scrollable_positioned_list
),但限定的版本可能与自己项目中的冲突 - 可能不支持一些刷新组件(如:
easy_refresh
) - 功能和布局的自定义受限(如:无法自定义
Header
和Footer
,列表item
无复选功能)
更要命的是,如果哪天产品实然要求能在 ListView
模式和 GridView
模式之间切换显示,就真的想寄了。 😭
所以说保持使用 Flutter
官方提供的滚动视图才是最可靠的,灵活度高,那本篇文章就跟着我的步伐,一起来实战快速手搓一个微信通讯录列表吧。
先来看看效果:
二、难点分析
上面的效果一眼看下来呢,涉及到的难点就是如下两个:
- 数据定位,防跳动
- 字母列表交互
1、数据定位
滑动右侧字母列表,跳转到对应字母的数据模块位置时,需要考虑数据长度问题,如果数据量够,那数据内容自然是从顶部开始展示,但如果数据量不够,则以列表的最大偏移去显示,说白了就是显示列表的底部数据,且保持页面不动,这里就需要考虑如何避免跳动问题了。
不过我们今天的主角 flutter_scrollview_observer 已经帮我们处理了这个跳动问题,所以这一点就不再是难点,我们在代码中尽管去调 jumpTo
就可以了。
2、字母列表
在手指触摸字母列表视图时,需要实时返回所对应的下标,以及字母视图所对应的偏移,这样才能使游标视图悬浮到对应位置。
手指触摸可以使用 GestureDetector
这个官方组件,该组件提供了一些触摸回调,比如 onVerticalDragUpdate
,我们可以通过回调里的 details
参数拿到此时手指在列表中的偏移量,直接通过 details.localPosition.dy
就可以取到了。
重点来了,怎么根据上述取到的手指偏移量,知道此时对应的下标呢?🧐
转换一下思路,我们可以通过 flutter_scrollview_observer 来获取当前显示的第一个 item
,如果不干预计算,那第一个 item
肯定永远是 A
,所以我们还需要实时指定一个开始计算的偏移量,就可以正常获取到我们理想的第一个正在显示 item
的数据了。
接下来让我们一起来开始实战吧~
三、实战
1、联系人数据
由于我们的重点在于如何处理视图显示上,所以数据我们进行简单生成,真实数据如何处理相信难不倒大家。
dart
/// 联系人模型
class AzListContactModel {
// 章节(这里存放的是对应的字母)
final String section;
// 该字母下的所有联系人姓名
final List<String> names;
AzListContactModel({
required this.section,
required this.names,
});
}
/// 存放联系人模型的数组
List<AzListContactModel> contactList = [];
/// 生成联系人数据
generateContactData() {
// 以 ASCII码 的方式去遍历生成 A-Z 的数据
final a = const Utf8Codec().encode("A").first;
final z = const Utf8Codec().encode("Z").first;
int pointer = a;
while (pointer >= a && pointer <= z) {
final character = const Utf8Codec().decode(Uint8List.fromList([pointer]));
contactList.add(
AzListContactModel(
section: character,
// 为测试数据量不够的情况,所以这里设置了最大生成 8 个元素
names: List.generate(Random().nextInt(8), (index) {
return '$character-$index';
}),
),
);
pointer++;
}
}
2、页面布局
dart
// 滚动视图对应的 controller
ScrollController scrollController = ScrollController();
// SliverViewObserver 对应的 controller
// 这里需要传入 scrollController,这样 observerController 的 jumpTo 方法才能生效,内部的跳转功能就是用 scrollController 去实现的
SliverObserverController observerController = SliverObserverController(controller: scrollController);
// 存放字母下标和所对应的 sliver 的 BuildContext
Map<int, BuildContext> sliverContextMap = {};
联系人滚动视图使用 CustomScrollView
去实现,slivers
里存放了所有字母对应的联系人列表 SliverList
dart
Stack(
children: [
// 观察滚动视图
SliverViewObserver(
controller: observerController,
sliverContexts: () {
// 返回 字母模块 所对应的所有 sliver 的 BuildContext
// 因为我们要观察所有列表,所以这里返回的是全部
return sliverContextMap.values.toList();
},
// 联系人滚动视图
child: CustomScrollView(
...
controller: scrollController,
slivers: contactList.mapIndexed((i, e) {
return _buildSliver(index: i, model: e);
}).toList(),
),
),
// 悬浮游标
_buildCursor(),
// 字母列表视图
Positioned(
top: 0,
bottom: 0,
right: 0,
child: _buildIndexBar(),
),
],
),
构建每一个字母下的联系人列表 SliverList
dart
Widget _buildSliver({
required int index,
required AzListContactModel model,
}) {
// 没有数据,则返回 SliverToBoxAdapter
final names = model.names;
if (names.isEmpty) return const SliverToBoxAdapter();
// 创建 SliverList(当然你可以创建 SliverGrid)
Widget resultWidget = SliverList(
delegate: SliverChildBuilderDelegate(
(context, itemIndex) {
// 存放 SliverList 对应的 BuildContext
if (sliverContextMap[index] == null) {
sliverContextMap[index] = context;
}
// 返回 item 视图,这个没什么特别的
return AzListItemView(name: names[itemIndex]);
},
childCount: names.length,
),
);
// 通过 flutter_sticky_header 这个库来实现字母悬浮视图
resultWidget = SliverStickyHeader(
header: Container(
height: 44.0,
color: const Color.fromARGB(255, 243, 244, 246),
padding: const EdgeInsets.symmetric(horizontal: 16.0),
alignment: Alignment.centerLeft,
child: Text(
model.section,
style: const TextStyle(color: Colors.black54),
),
),
sliver: resultWidget,
);
return resultWidget;
}
3、字母列表
构建字母列表视图的主要代码如下:
dart
/// 字母列表视图的父视图的key
/// 服务于后面获取字母视图偏移量
final indexBarContainerKey = GlobalKey();
/// 从联系人数据里取出所有的字母
List<String> get symbols => contactList.map((e) => e.section).toList();
Widget _buildIndexBar() {
return Container(
key: indexBarContainerKey,
...
child: AzListIndexBar(
// 父视图的key,有印象即可,下面会用到
parentKey: indexBarContainerKey,
// 字母数据
symbols: symbols,
onSelectionUpdate: (index, cursorOffset) {
// 开始触摸以及触摸滑动 的处理回调
...
},
onSelectionEnd: () {
// 结束/取消 触摸 的处理回调
...
},
),
);
}
上面的两个处理回调是用来更新游标视图的,所以这里先不讲它。
接着来讲讲 AzListIndexBar
的重点代码
dart
/// 观察滚动视图所需的控制器
ListObserverController observerController = ListObserverController();
/// 记录当前手指所在的偏移量
double observeOffset = 0;
// 字母列表视图的整体布局
@override
Widget build(BuildContext context) {
// ListViewObserver 用来观察 ListView 滚动视图
Widget resultWidget = ListViewObserver(
// 被观察的滚动视图
child: _buildListView(),
controller: observerController,
// 动态返回当前手指所在的偏移量,用于指定开始计算的偏移,使观察可以得到理想的第一个 item
dynamicLeadingOffset: () => observeOffset,
);
// 触摸监听
resultWidget = GestureDetector(
// 按下
onVerticalDragDown: _onGestureHandler,
// 移动
onVerticalDragUpdate: _onGestureHandler,
// 取消
onVerticalDragCancel: _onGestureEnd,
// 放开
onVerticalDragEnd: _onGestureEnd,
child: resultWidget,
);
return resultWidget;
}
从简单的开始讲,手指放开后,需要隐藏游标,但这个相对于字母列表视图来说是份外之事,所以回调告知即可。
dart
// 结束/取消 触摸
_onGestureEnd([_]) {
// 直接告诉外部,结束滑动了
widget.onSelectionEnd?.call();
}
手指按下和滑动的时候,需要显示并更新游标上的标题,所以在字母列表视图侧,可以提供一些数据给到外部
dart
// 处理开始触摸以及触摸滑动
// 该方法是核心
_onGestureHandler(dynamic details) async {
// details 的类型有可能是 DragDownDetails,也有可能是 DragUpdateDetails
// 这两个类型没有父类,不过都有一个相同类型的 localPosition,存放了当前手指在列表上的偏移量,所以这里为了方便,类型声明为 dynamic
if (details is! DragUpdateDetails && details is! DragDownDetails) return;
observeOffset = details.localPosition.dy;
// 触发一次观察
// 通过 await 就可以在该处拿到观察结果
final result = await observerController.dispatchOnceObserve(
// 在上面的 ListViewObserver 中,我们并没有实现 onObserve 和 onObserveAll 回调
// 在默认情况下,如果回调没有实现,则不会去观察,所以在这里设置了不依赖回调的实现,直接观察
isDependObserveCallback: false,
);
final observeResult = result.observeResult;
// 观察结果为null,说明此次的 第一个item 没有发生变化,比如:上一次观察时为字母A,此次观察还是字母A
// 默认内部对观察结果有做对比处理,如果你希望每次观察不用对比,直接将数据返回,则可以在上述的 dispatchOnceObserve 方法中给 isForce 设置为 true
if (observeResult == null) return;
// 取出 第一个item 的数据
final firstChildModel = observeResult.firstChild;
if (firstChildModel == null) return;
// 第一个item的下标
final firstChildIndex = firstChildModel.index;
// 取出字母视图对应的 RenderObject
final firstChildRenderObj = firstChildModel.renderObject;
// 计算当前字母的中心点相对于父视图左上角的偏移量,我们主要拿 y 值
// ancestor: 传入祖先视图的 RenderObject 做为参考坐标系
final firstChildRenderObjOffset = firstChildRenderObj.localToGlobal(
Offset.zero,
ancestor: widget.parentKey.currentContext?.findRenderObject(),
);
// 计算好后,通过 onSelectionUpdate 回调将数据返出去
final cursorOffset = Offset(
firstChildRenderObjOffset.dx,
firstChildRenderObjOffset.dy + firstChildModel.size.width * 0.5,
);
widget.onSelectionUpdate?.call(
firstChildIndex,
cursorOffset,
);
}
4、更新游标
现在我们回头来看看两个触摸处理结果回调
dart
/// 游标的数据模型
class AzListCursorInfoModel {
/// 字母
final String title;
/// 字母中心点的偏移量
final Offset offset;
AzListCursorInfoModel({
required this.title,
required this.offset,
});
}
dart
// 存放游标的数据模型
ValueNotifier<AzListCursorInfoModel?> cursorInfo = ValueNotifier(null);
Widget _buildIndexBar() {
return Container(
...
child: AzListIndexBar(
parentKey: indexBarContainerKey,
symbols: symbols,
onSelectionUpdate: (index, cursorOffset) {
// 更新游标数据,来显示游标
cursorInfo.value = AzListCursorInfoModel(
title: symbols[index],
offset: cursorOffset,
);
// 取出字母对应的联系人列表视图 SliverList 的 BuildContext
final sliverContext = sliverContextMap[index];
if (sliverContext == null) return;
// 跳到对应的字母章节的第一个 item 的位置
// jumpTo 方法内部处理了跳动问题,尽管调用即可!
observerController.jumpTo(
index: 0,
sliverContext: sliverContext,
);
},
onSelectionEnd: () {
// 清除游标数据,即隐藏游标
cursorInfo.value = null;
},
),
);
}
构建游标视图代码
dart
Widget _buildCursor() {
// 根据 cursorInfo 数据的变化来局部实现
return ValueListenableBuilder<AzListCursorInfoModel?>(
valueListenable: cursorInfo,
builder: (
BuildContext context,
AzListCursorInfoModel? value,
Widget? child,
) {
Widget resultWidget = Container();
double top = 0;
double right = indexBarWidth + 8;
if (value == null) {
// 没有游标数据,隐藏
resultWidget = const SizedBox.shrink();
} else {
// 有游标数据,显示 AzListCursor 视图
double titleSize = 80;
// 根据当前字母视图的中心点偏移量 y 值,减去游标视图的高度,来得出游标的顶部偏移量
top = value.offset.dy - titleSize * 0.5;
resultWidget = AzListCursor(size: titleSize, title: value.title);
}
resultWidget = Positioned(
top: top,
right: right,
child: resultWidget,
);
return resultWidget;
},
);
}
游标视图 AzListCursor
里没什么重要的内容,所以不讲解了,大家可以随意发挥。
Demo链接:azlist_demo
四、最后
通过上述示例的讲解,相信你对 scrollview_observer
的使用又更加清楚,如果你也觉得这个库好用,请不吝给个 Star
👍
GitHub: github.com/fluttercand...
本篇到此结束,感谢大家的支持,我们下次再见! 👋
如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有
iOS
技术,还有Android
,Flutter
,Python
等文章, 可能有你想要了解的技能知识点哦~