简单的通讯录列表页我们已经实现了,今天我们来实现通讯录的索引条;我们都知道通讯录的索引条是悬浮在列表上方的,在屏幕右侧;那么,我们的构建方式就要进行修改;

构建索引条

我们需要使用Stack来构建整个界面,使索引条显示在列表上面:
image.png

显示索引条数据

我们在索引条上显示一下信息:

  1. const INDEX_WORDS = [
  2. '🔍',
  3. '☆',
  4. 'A',
  5. 'B',
  6. 'C',
  7. 'D',
  8. 'E',
  9. 'F',
  10. 'G',
  11. 'H',
  12. 'I',
  13. 'J',
  14. 'K',
  15. 'L',
  16. 'M',
  17. 'N',
  18. 'O',
  19. 'P',
  20. 'Q',
  21. 'R',
  22. 'S',
  23. 'T',
  24. 'U',
  25. 'V',
  26. 'W',
  27. 'X',
  28. 'Y',
  29. 'Z'
  30. ];

索引条是竖着显示的,那么很明显,需要使用Column进行布局,我们先将Columnchildren对应的Widget数组创建好:

  1. final List<Widget> _indexList = []; // 索引条Widget数组
  2. @override
  3. void initState() {
  4. super.initState();
  5. ......
  6. // 索引条
  7. for (int i = 0; i < INDEX_WORDS.length; i++) {
  8. _indexList.add(Text(INDEX_WORDS[i], style: const TextStyle(fontSize: 12),));
  9. }
  10. }

这样,我们就讲所有需要显示的Widget放在_indexList中,只需要给Columnchildren赋值为_indexList即可:
image.png

索引条布局优化

目前,所有的索引字母都是在最上面开始显示的,而且各个字母之间的距离过近,此时我们可使用ExpandedText包起来,让他们自适应布局:

  1. // 索引条
  2. for (int i = 0; i < INDEX_WORDS.length; i++) {
  3. _indexList.add(
  4. Expanded(
  5. child: Text(
  6. INDEX_WORDS[i],
  7. style: const TextStyle(fontSize: 12),
  8. ),
  9. )
  10. );
  11. }

此时,重新渲染界面,效果如下:
image.png
我们改一下索引条的位置如下:
image.png
为了便于操作及扩展,我们将索引条抽取出来,放在IndexBar中,完整代码如下:
image.png
这样我们在Stack中直接使用IndexBar即可,也便于后续扩展功能;
image.png

索引条的状态改变

接下来,修改一下索引条在不同情况下的状态变化:默认透明背景,黑色字体,点击或者在上边滑动时背景变黑,字体变白;

我们定义两个颜色,一个背景色,一个字体颜色:

  1. Color _bgColor = const Color.fromRGBO(1, 1, 1, 0.0); // 背景色 默认透明
  2. Color _textColor = Colors.black; // 字体颜色 默认黑色

接下来,在手势触发时,修改两个颜色:
image.png
这个时候,我们发现在手势触发时,背景色变了,而字体颜色没有发生改变,这是因为我们字体使用的Text控件的初始化放在了initState方法中,而此方法只有在界面创建时才会调用;setState触发的是build方法,所以我们应该将创建Text的循环过程放在build中进行,最终代码如下:
image.png
效果如下:
Flutter(十八)实战-通讯录索引条 - 图9

需要注意的是,_indexList的定义也要放在build中,否则每一次渲染,列表都会被渲染一次,而数组没有重新创建,会一直往里边增加元素;

确定鼠标所在的索引位置

现在索引条我们已经基本完成了,那么我们如果知道点击的时候,当前是点击的哪一个字母呢?我们注意到手势的事件是否参数的:

  1. DragDownDetails({
  2. this.globalPosition = Offset.zero,
  3. Offset? localPosition,
  4. })

那么这两个参数都是什么意思呢?我们来打印一下:
image.png
我们通过打印信息可以确定,这两个都是坐标位置:

  • globalPosition:当前点击点在全局的位置;
  • localPosition:当前点击点在当前部件中的位置;

localPosition也是可以通过globalPosition计算得到的:
image.png
接下来我们就可以确定,当前点击的是第几个字符了:
image.png
我么你讲此处的操作封装为方法:

  1. String getIndexString(BuildContext context, Offset localPosition) {
  2. // 点击的坐标点在当前部件中的y坐标
  3. double y = localPosition.dy;
  4. // 算出字符的高度
  5. var itemHeight = screenHeight(context) / 2 / INDEX_WORDS.length;
  6. // 计算是第几个字符
  7. int index = y ~/ itemHeight; // ~/ 意为 取整
  8. // 当前选中的字符
  9. String str = INDEX_WORDS[index];
  10. return str;
  11. }

但是此时是有问题的,如果我们滑动的区域超出了索引条的区域就会报错:
image.png
所以,我们的index取值范围是有限制的:

  1. String getIndexString(BuildContext context, Offset localPosition) {
  2. // 点击的坐标点在当前部件中的y坐标
  3. double y = localPosition.dy;
  4. // 算出字符的高度
  5. var itemHeight = screenHeight(context) / 2 / INDEX_WORDS.length;
  6. // 计算是第几个字符
  7. int index = (y ~/ itemHeight).clamp(0, INDEX_WORDS.length - 1); // ~/ 意为 取整 区域限制在[0, length-1]
  8. // 当前选中的字符
  9. String str = INDEX_WORDS[index];
  10. return str;
  11. }

索引条与联系人列表联动

通常我们点击索引条的时候,是需要联系人列表滚动到相应位置的,那么我们就需要IndexBar对外保留一个接口:

  1. // 点击索引字符的回调;
  2. final void Function(String str)? indexBarCallBack;
  3. IndexBar({this.indexBarCallBack});

然后在IndexBar触发手势事件时,回调该函数:
image.png
这样,我们就能在使用IndexBar时,在其构造函数中获取点击的字符:
image.png
接下来,如果需要ListView同步滚动的话,需要使用到ListView中的属性controller,我们先定义一个_scrollController的属性,然后在initState中初始化:
image.png
然后将_scrollController赋值给ListViewcontroller属性:
image.png
这样的话,如果我们需要滚动ListView,那么我们只需要拿到_scrollController进行滚动即可;如下:
image.png
点击索引字符之后,让ListView滚动到260像素的位置,总共动画时长为1秒,Curves.easeIn的意思是动画开始和结束时速度快,中间速度慢;效果如下:
Flutter(十八)实战-通讯录索引条 - 图19
现在我们滚动到了一个固定值,那么只要我们能够算出每一个分组的位置,就能实现点击字符,滚动到某一个分组的效果;

计算索引分组的位置

我们如果想要滚动到分组的位置,那么就需要提前把位置信息计算出来,我们创建一个Map用来存放每一个分组与其高度的对应关系:

  1. /*存放分组的字符和其对应的高度*/
  2. final Map _groupOffsetMap = {
  3. INDEX_WORDS[0]: 0.0,
  4. INDEX_WORDS[1]: 0.0,
  5. };

01两个位置不是字母,可以不用滚动,所以其高度定为0;然后在initState中循环计算每一个分组头的高度:

  1. final double _rowHeight = 51;
  2. final double _groupHeaderHeight = 30;
  3. // 循环计算,将每一个分组头的字符位置计算出来,放入map中
  4. var _groupOffset = _rowHeight * _headerList.length; // 头部四个的高度
  5. for (int i = 0; i < _listDatas.length; i++) {
  6. if (i < 1) {
  7. // 第一个一定有头
  8. _groupOffsetMap.addAll(
  9. {_listDatas[i].indexLetter: _groupOffset}); // 第一个头部高度为 51 * 4
  10. // 改变偏移位置
  11. _groupOffset += _rowHeight + _groupHeaderHeight;
  12. } else if (_listDatas[i].indexLetter == _listDatas[i - 1].indexLetter) { // 前后两个indexLetter相同的话不用保存;
  13. // 不存map,只添加偏移
  14. _groupOffset += _rowHeight;
  15. } else {
  16. _groupOffsetMap.addAll({_listDatas[i].indexLetter: _groupOffset});
  17. // 改变偏移位置
  18. _groupOffset += _rowHeight + _groupHeaderHeight;
  19. }
  20. }

在滚动时,从Map中取出高度,然后滚动到具体位置:
image.png
运行效果如下:
Flutter(十八)实战-通讯录索引条 - 图21

索引条指示器

如果需要显示索引条的指示器,那么我们就需要修改索引条的布局:
image.png
我们在添加上气泡,然后默认显示一个A,在气泡中居中显示:
image.png
其中,文字在气泡中居中显示是使用了Stackalignment属性,用来调整位置;接下来就是控制气泡显示的位置了,警告我们尝试,气泡上下两端位置的显示区域如下:
Flutter(十八)实战-通讯录索引条 - 图24
我们除了要控制指示器显示的位置,显示的文字还得判断指示器的隐藏和显示状态,我们定义三个变量:

  1. double _indicatorY = 0.0; // 指示器默认Y值
  2. String _indicatorTitle = 'A'; // 指示器文字 默认A
  3. bool _indicatorHidden = true; // 指示器显示与隐藏 默认隐藏

我们将之前写的获取选中字符的方法修改一下,返回字符的索引值:

  1. /*获取选中的字符索引*/
  2. int getIndex(BuildContext context, Offset localPosition) {
  3. // 点击的坐标点在当前部件中的y坐标
  4. double y = localPosition.dy;
  5. // 算出字符的高度
  6. var itemHeight = screenHeight(context) / 2 / INDEX_WORDS.length;
  7. // 计算是第几个字符
  8. int index = (y ~/ itemHeight).clamp(0, INDEX_WORDS.length - 1); // ~/ 意为 取整 clamp限制取值范围在[0, length - 1]
  9. return index;
  10. }

最终指示器代码如下:
image.png
运行效果如下:
Flutter(十八)实战-通讯录索引条 - 图26

至此,通讯录界面效果已完全实现;