Flutter - 如何快速搓一个微信通讯录列表(azlist) 📓

欢迎关注微信公众号:FSA全栈行动 👋

系列文章

开源库: flutter_scrollview_observer

  1. Flutter - 获取ListView当前正在显示的Widget信息
  2. Flutter - 列表滚动定位超强辅助库,墙裂推荐!🔥
  3. Flutter - 快速实现聊天会话列表的效果,完美💯
  4. Flutter - 船新升级😱支持观察第三方构建的滚动视图💪
  5. Flutter - 瀑布流交替播放视频 🎞
  6. Flutter - IM保持消息位置大升级(支持ChatGPT生成式消息) 🤖
  7. Flutter - 滚动视图中的表单防遮挡 🗒
  8. Flutter - 秒杀1/2曝光统计 📊

一、概述

相信大家都很熟悉微信的通讯录列表功能,而实现该效果可能会直接选用现有的已封装好的开源库,比如:azlistview,这些库都很优秀,能快速帮助我们完成功能研发,但是会有一些小问题,比如:

  • 依赖了一些其它第三方开源库(如:scrollable_positioned_list),但限定的版本可能与自己项目中的冲突
  • 可能不支持一些刷新组件(如:easy_refresh
  • 功能和布局的自定义受限(如:无法自定义 HeaderFooter,列表 item 无复选功能)

更要命的是,如果哪天产品实然要求能在 ListView 模式和 GridView 模式之间切换显示,就真的想寄了。 😭

所以说保持使用 Flutter 官方提供的滚动视图才是最可靠的,灵活度高,那本篇文章就跟着我的步伐,一起来实战快速手搓一个微信通讯录列表吧。

先来看看效果:

二、难点分析

上面的效果一眼看下来呢,涉及到的难点就是如下两个:

  1. 数据定位,防跳动
  2. 字母列表交互

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 技术,还有 AndroidFlutterPython 等文章, 可能有你想要了解的技能知识点哦~

相关推荐
小白小白从不日白几秒前
react hooks--useReducer
前端·javascript·react.js
下雪天的夏风12 分钟前
TS - tsconfig.json 和 tsconfig.node.json 的关系,如何在TS 中使用 JS 不报错
前端·javascript·typescript
diygwcom24 分钟前
electron-updater实现electron全量版本更新
前端·javascript·electron
Hello-Mr.Wang41 分钟前
vue3中开发引导页的方法
开发语言·前端·javascript
程序员凡尘1 小时前
完美解决 Array 方法 (map/filter/reduce) 不按预期工作 的正确解决方法,亲测有效!!!
前端·javascript·vue.js
编程零零七4 小时前
Python数据分析工具(三):pymssql的用法
开发语言·前端·数据库·python·oracle·数据分析·pymssql
(⊙o⊙)~哦6 小时前
JavaScript substring() 方法
前端
无心使然云中漫步7 小时前
GIS OGC之WMTS地图服务,通过Capabilities XML描述文档,获取matrixIds,origin,计算resolutions
前端·javascript
Bug缔造者7 小时前
Element-ui el-table 全局表格排序
前端·javascript·vue.js
xnian_7 小时前
解决ruoyi-vue-pro-master框架引入报错,启动报错问题
前端·javascript·vue.js