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

相关推荐
y先森4 小时前
CSS3中的伸缩盒模型(弹性盒子、弹性布局)之伸缩容器、伸缩项目、主轴方向、主轴换行方式、复合属性flex-flow
前端·css·css3
前端Hardy4 小时前
纯HTML&CSS实现3D旋转地球
前端·javascript·css·3d·html
susu10830189114 小时前
vue3中父div设置display flex,2个子div重叠
前端·javascript·vue.js
IT女孩儿5 小时前
CSS查缺补漏(补充上一条)
前端·css
吃杠碰小鸡6 小时前
commitlint校验git提交信息
前端
虾球xz7 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇7 小时前
HTML常用表格与标签
前端·html
疯狂的沙粒7 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员7 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐7 小时前
前端图像处理(一)
前端