Flutter 微信项目之通讯录页面

814 阅读9分钟

和谐学习!不急不躁!!我是你们的老朋友小青龙~

前言

今天我们来用Flutter实现微信上的简易版通讯录页面,先上效果图:

通讯录效果图.GIF

为了便于讲解,我们先统一概念

  • group表示组,即绿色圈部分(ListView可以有很多个组)

  • headView表示每个组的开头,即蓝色圈部分

  • cell表示每个组除headView之外,剩余的一个个子部件,即红色圈部分

  • 指示器表示最右边那一列字母

image.png

本文目录

  • 1、分析

  • 2、技术点

    • 2.1、按照字母进行排序
    • 2.2、headView只显示一个字母
    • 2.3、ListView开头滚动到指定字母
    • 2.4、获取ListView在屏幕里的高度
    • 2.5、计算并存储所有字母对应的偏移量
    • 2.6、如何避免数据不够翻页时的回弹动画
  • 3、布局思路

    • 3.1、整体布局
    • 3.2、LiseView之cell布局
  • 4、代码实现

    • 4.1、数据及数据模型
    • 4.2、wechat_list_page.dart
    • 4.3、cell
    • 4.4、指示器 wechat_index_bar.dart
    • 4.5、ssj_colors.dart
    • 4.6、ssj_const.dart

【主要代码在wechat_list_page.dartwechat_index_bar.dart这两个文件】

1、分析

要实现这个页面,我们需要先分析一波:

  1. 左边是一个可以滚动的组件 - - ListView

  2. ListView需要「按照首字母进行排序」,每个headView都有一个字母

  3. 右边需要一个指示器,拖拽和点击字母,ListView开头会滚动到指定字母那一栏

  4. 导航栏右边的按钮(这个不是我们今天的重点,先忽略它)

2、技术点

2.1、按照字母进行排序

// _listModels是一个List数组,数组里的元素均为数据模型,
// 效果:对数组,按照数据模型indexLetter这个字段进行排序

_listModels.sort((a, b) {
  return a.indexLetter!.compareTo(b.indexLetter!);
});

2.2、headView只显示一个字母

其实严格意义上来说,headView也属于cell的一部分,只不过我们这里对cell进行了分类:

  • 带headView的cell

  • 不带headView的cell

只需要在定义cell的时候,加一个groupTitle参数,利用「非空判断」来区分是否需要headView。

2.3、ListView开头滚动到指定字母

Flutter为我们提供了滚动多少偏移量这么一个API,实现如下:

//参数分析
// offetValue 距离原点y轴上的偏移量,类型是double
// duration   动画持续时间
// curve      动画类型
// _scrollController 控制器,类型是ScrollController
_scrollController.animateTo(offetValue,
    duration: const Duration(microseconds: 5),
    curve: Curves.easeIn);

在页面build方法里,需要记录每个字母(headView)在listView上的偏移量,点击或者拖拽的时候,可以根据字母对应偏移量实现滚动。

2.4、获取ListView在屏幕里的高度

定义一个全局的key

GlobalKey listViewKey = GlobalKey();

在listView创建的时候,把这个key传进去

ListView.builder(
  key: listViewKey,
  ...
)

在需要的地方这样获取:

RenderBox rendBox = listViewKey.currentContext!.findRenderObject() as RenderBox;

// rendBox.size.width  就是在屏幕里的【宽度】
// rendBox.size.height 就是在屏幕里的【高度】

2.5、计算并存储所有字母对应的偏移量

// 计算 每个item字母的y坐标位置,并存储到ziMuMap集合
// 统计ListView的高度
const double cellHeight = 50;
int zimuCount = 0;//统计ziMuMap有多少个字母,也就代表多少个headView
for(int i = 0;i<_listModels.length;i++){
   var thisLetter = _listModels[i].indexLetter;// 当前字母
  if(i == 0){//第0个位置
    yCount = _headerData.length * cellHeight;
    ziMuMap.addAll({thisLetter:yCount});
    listViewAllHeight = yCount;
    zimuCount ++;
  }else{
    //如果当前字母和上一个字谜不匹配,偏移位置就增加cellHeight + 30,否则偏移cellHeight
    final lastLetter =
        _listModels[i - 1].indexLetter;// 上一个字母
    if (thisLetter != lastLetter) {
      yCount += cellHeight + 30;
      ziMuMap.addAll({thisLetter:yCount});
      listViewAllHeight = yCount + cellHeight;
      zimuCount ++;
    }else{
    // 重复字母,不需要对ziMuMap进行改动
      yCount += cellHeight;
    }
  }
}

2.6、如何避免数据不够翻页时的回弹动画

我们知道,点击某个字母,ListView开头会自动跳转到字母对应group。

有一种情况是点击了z字母,而z字母对应group只有一个cell,由于这个cell后面没有其它cell来填充剩余的画面,那么系统会自动回弹

【解决思路如下】

  • 点击字母,获取字母对应偏移量,如果ListView内容总高度 - 字母对应偏移量 < ListView在屏幕内的高度滚动量 = ListView内容总高度- ListView在屏幕内的高度,这样我们可以保证要显示的内容在屏幕内。

  • ListView内容总高度 = 所有的headView高度+所有的cell高度

3、布局思路

3.1、整体布局

1、LiseView填充整个画面(导航栏、tabar除外),且ListView滚动不会影响指示器,指示器紧贴最右边,指示器在LiseView上面。所以最外层布局如下

Stack(
  children: [
     ListView.builder(),//列表
     IndexBar(),//指示器
  ],
)

3.2、LiseView之cell布局

  • cell头部是一个headView

  • cell最左边是一个图片(本地加载、网络加载),左边从上往下是名字、备注信息(Row+Column布局)

  • 底部有一条下划线(左边没到顶)

我这边使用的是stack布局

Stack(
  children: [
    /// headView
    Row(),

    /// 内容(头像、姓名、备注信息)
    Container(),

    /// 底部分割线
    Row(),

  ],
)

4、代码实现

4.1、数据及数据模型

class Friends {
  final String? whetherHead; //本地图片
  final String? imageName; //本地图片
  final String? imageUrl; //网络图片
  final String? name;
  final String? remarks; //备注
  final String? indexLetter;
  Friends(
      {this.whetherHead,
      this.imageName,
      this.imageUrl,
      this.name,
      this.remarks,
      this.indexLetter});
}

final List<Friends> _headerData = [

  Friends(imageName: 'images/新的朋友.png', name: '新的朋友', indexLetter: 'L'),
  Friends(imageName: 'images/群聊.png', name: '群聊', indexLetter: 'L'),
  Friends(imageName: 'images/标签.png', name: '标签', indexLetter: 'L'),
  Friends(imageName: 'images/公众号.png', name: '公众号', indexLetter: 'L'),
];

List<Friends> datas = [
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/27.jpg',
      name: 'Lina',
      remarks: "A beautiful gir who's age is 18.",
      indexLetter: 'L'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/17.jpg',
      name: '菲儿',
      // remarks: "年方二八,浙江余姚人式,政府部门人员,性格刚烈,敢作敢为,实乃女中豪杰。",
      remarks: "年方二八,浙江余姚人式,乃女中豪杰。",
      indexLetter: 'F'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/16.jpg',
      name: '安莉',
      indexLetter: 'A'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/men/31.jpg',
      name: '阿贵',
      remarks: "憨憨小伙。",
      indexLetter: 'A'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/22.jpg',
      name: '贝拉',
      remarks: "酷酷女孩。",
      indexLetter: 'B'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/37.jpg',
      name: 'Lina',
      indexLetter: 'L'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/18.jpg',
      name: 'Nancy',
      indexLetter: 'N'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/men/47.jpg',
      name: '扣扣',
      indexLetter: 'K'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/men/3.jpg',
      name: 'Jack',
      indexLetter: 'J'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/5.jpg',
      name: 'Emma',
      indexLetter: 'E'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/24.jpg',
      name: 'Abby',
      indexLetter: 'A'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/men/15.jpg',
      name: 'Betty',
      indexLetter: 'B'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/men/13.jpg',
      name: 'Tony',
      indexLetter: 'T'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/men/26.jpg',
      name: 'Jerry',
      indexLetter: 'J'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/men/36.jpg',
      name: 'Colin',
      indexLetter: 'C'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/12.jpg',
      name: 'Haha',
      indexLetter: 'H'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/11.jpg',
      name: 'Ketty',
      indexLetter: 'K'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/13.jpg',
      name: 'Lina',
      indexLetter: 'L'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/23.jpg',
      name: 'Lina',
      indexLetter: 'L'),
];

4.2、wechat_list_page.dart

import 'package:flutter/material.dart';
import 'package:ssj_wechat_demo/others/ssj_buttons.dart';
import 'package:ssj_wechat_demo/wechat_index_bar.dart';

import 'others/ssj_colors.dart';
import 'others/ssj_const.dart';

// listView的key,作用是通过它获取listViewKey在屏幕里的高度
GlobalKey listViewKey = GlobalKey();

class ListPage extends StatefulWidget {
  const ListPage({Key? key}) : super(key: key);

  @override
  _ListPageState createState() => _ListPageState();
}

class _ListPageState extends State<ListPage> {
  final List<Friends> _listModels = [];
  late ScrollController _scrollController;// ListView控制器
  late Map ziMuMap = {};
  late double listViewAllHeight = 0;
  @override
  void initState() {
    super.initState();

    print('调用一次initState~');
    // 初始化ListView控制器
    _scrollController = ScrollController();

    // 初始化列表数据
    // _listModels
    //   ..addAll
    //   ..addAll(datas);
    _listModels.addAll(datas);

    // 按indexLetter排序
    _listModels.sort((a, b) {
      return a.indexLetter!.compareTo(b.indexLetter!);
    });

  }

  @override
  Widget build(BuildContext context) {

    double yCount = 0; //累计y轴字母的位置,_itemBuilder需要用到
    // 返回每一个Cell
    Widget _itemBuilder(BuildContext context, int index) {
      if (index < _headerData.length) {
        /// 显示本地图片
        return ListRowCell(
          cellFriend: _headerData[index],
          cellIndex: index,
        );
      } else {
        // 显示head View条件:
        // 1、当前的index 等于 _headerData的总长度
        if (index == _headerData.length) {
          return ListRowCell(
            cellFriend: _listModels[index - _headerData.length],
            groupTitle: _listModels[index - _headerData.length].indexLetter,
            cellIndex: index,
          );
        }
        final thisLetter = _listModels[index - _headerData.length].indexLetter;
        final lastLetter =
            _listModels[index - _headerData.length - 1].indexLetter;
        // 显示head View条件
        // 2、如果当前的字母和上一个字母不一样
        if (thisLetter != lastLetter) {
          return ListRowCell(
            cellFriend: _listModels[index - _headerData.length],
            groupTitle: _listModels[index - _headerData.length].indexLetter,
            cellIndex: index,
          );
        } else {
          // 重复字母,不需要显示字母,所以不需要穿groupTitle
          return ListRowCell(
            cellFriend: _listModels[index - _headerData.length],
            cellIndex: index,
          );
        }
      }
    }

    // cell高度(不包含headView)
    const double cellHeight = 50;

    // 计算得到 字母对应偏移量,并存储到ziMuMap集合
    int headViewCount = 0;// 统计ListView的高度
    for (int i = 0; i < _listModels.length; i++) {
      var thisLetter = _listModels[i].indexLetter;
      if (i == 0) {
        //第0个位置
        yCount = _headerData.length * cellHeight;
        ziMuMap.addAll({thisLetter: yCount});
        listViewAllHeight = yCount;
        headViewCount++;
      } else {
        //如果当前字母和上一个字谜不匹配,偏移位置就增加cellHeight + 30,否则偏移cellHeight
        final lastLetter = _listModels[i - 1].indexLetter;
        if (thisLetter != lastLetter) {
          yCount += cellHeight + 30;
          ziMuMap.addAll({thisLetter: yCount});
          listViewAllHeight = yCount + cellHeight;
          headViewCount++;
        } else {
          yCount += cellHeight;
        }
      }
    }
    // listView的内容高度(别忘记了开头几个本地图片高度)
    listViewAllHeight =
        (_headerData.length + _listModels.length) * cellHeight + headViewCount * 30;

    return Scaffold(
      backgroundColor: SSJColors.themBgColor,
      appBar: AppBar(
        title: const Text("通讯录"),
        // actions: [
        //   SSJBorderButton(
        //     iconName: 'images/icon_friends_add.png',
        //     onTap: () {
        //       print('点击了导航栏按钮');
        //     },
        //   ),
        // ],
      ),
      body: Container(
        color: Colors.yellow,
        child: Stack(
          children: [
            ListView.builder(
              //滚动列表
              key: listViewKey,
              controller: _scrollController,
              itemBuilder: _itemBuilder,
              itemCount: _headerData.length + _listModels.length,
            ),
            IndexBar(
              parentContext: context,
              selectedCallBack: (String itemStr) {
                print('选中字母--$itemStr 对应滚动偏移量:${ziMuMap[itemStr]}');

                if (ziMuMap[itemStr] != null) {
                  double shengyu =
                      listViewAllHeight - ziMuMap[itemStr]; // 内容高度-字母偏移量
                  RenderBox rendBox = listViewKey.currentContext!
                      .findRenderObject() as RenderBox;
                  print(
                      'ListView在屏幕的size--${rendBox.size} 内容高度 = $listViewAllHeight');

                  if (shengyu > rendBox.size.height) {
                    // 剩余空间充足,正常滚动
                    _scrollController.animateTo(ziMuMap[itemStr],
                        duration: const Duration(microseconds: 5),
                        curve: Curves.easeIn);
                  } else {
                    // 剩余空间不足,回弹处理
                    // 滚动量 = 内容高度 - 屏幕内ListView高度
                    final offYNeed = listViewAllHeight - rendBox.size.height;
                    _scrollController.animateTo(offYNeed,
                        duration: const Duration(microseconds: 5),
                        curve: Curves.easeIn);
                  }
                }
              },
            ),
          ],
        ),
      ),
    );
  }
}

// 定义一个Cell
class ListRowCell extends StatefulWidget {
  final Friends cellFriend;// 数据模型
  final String? groupTitle;// 字母,等于null就不显示headView
  final int? cellIndex;// cell下标,预留字段
  const ListRowCell({Key? key, required this.cellFriend, this.groupTitle,this.cellIndex})
      : super(key: key);
  @override
  _ListRowCellState createState() => _ListRowCellState();
}

class _ListRowCellState extends State<ListRowCell> {
  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: Stack(
        children: [
          /// headView
          Row(
            children: [
              widget.groupTitle != null
                  ? (Container(
                      padding: const EdgeInsets.only(left: 10),
                      alignment: Alignment.centerLeft,
                      width: winWidth(context),
                      height: 30,
                      color: SSJColors.themBgColor,
                      child: Text(
                          widget.groupTitle != null ? widget.groupTitle! : ''),
                    ))
                  : (Container()),
            ],
          ),

          /// 内容
          Container(
            margin: EdgeInsets.only(
                top: widget.groupTitle != null ? (30.0) : (0.0)),//stack布局,headView高度为30
            padding: const EdgeInsets.fromLTRB(10, 5, 10, 5),
            child: Row(
              children: [
                /// 头像
                Container(
                  width: 40,
                  height: 40,
                  decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(5),
                      image: DecorationImage(
                          image: widget.cellFriend.imageUrl != null
                              ? NetworkImage(widget.cellFriend.imageUrl!)
                              : AssetImage(widget.cellFriend.imageName!)
                                  as ImageProvider)),
                ),
                /// 名字+备注信息
                Container(
                  padding: const EdgeInsets.only(
                    left: 10,
                  ),
                  // color: Colors.grey,
                  // alignment: Alignment.centerLeft,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      /// 名字
                      Text(
                        widget.cellFriend.name != null
                            ? (widget.cellFriend.name!)
                            : (''),
                        style: const TextStyle(fontSize: 16.0),
                      ),
                      /// 备注信息
                      widget.cellFriend.remarks != null
                          ? (Text(
                              widget.cellFriend.remarks!,
                              style: const TextStyle(fontSize: 13.0),
                              maxLines: 1,
                              overflow: TextOverflow.ellipsis,
                            ))
                          : (Container()),
                    ],
                  ),
                )
              ],
            ),
          ),

          /// 底部分割线
          Row(
            children: [
              Container(
                // color: Colors.red,
                padding: const EdgeInsets.only(left: 10 + 40 + 10),
                child: Container(
                  width: winWidth(context) - 10 - 40 - 10,
                  height: 0.2,
                  color: Colors.grey,
                ),
              )
            ],
          ),

        ],
      ),
    );
  }
}

class Friends {
  final String? whetherHead; //本地图片
  final String? imageName; //本地图片
  final String? imageUrl; //网络图片
  final String? name;
  final String? remarks; //备注
  final String? indexLetter;
  Friends(
      {this.whetherHead,
      this.imageName,
      this.imageUrl,
      this.name,
      this.remarks,
      this.indexLetter});
}

final List<Friends> _headerData = [

  Friends(imageName: 'images/新的朋友.png', name: '新的朋友', indexLetter: 'L'),
  Friends(imageName: 'images/群聊.png', name: '群聊', indexLetter: 'L'),
  Friends(imageName: 'images/标签.png', name: '标签', indexLetter: 'L'),
  Friends(imageName: 'images/公众号.png', name: '公众号', indexLetter: 'L'),
];

List<Friends> datas = [
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/27.jpg',
      name: 'Lina',
      remarks: "A beautiful gir who's age is 18.",
      indexLetter: 'L'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/17.jpg',
      name: '菲儿',
      // remarks: "年方二八,浙江余姚人式,政府部门人员,性格刚烈,敢作敢为,实乃女中豪杰。",
      remarks: "年方二八,浙江余姚人式,乃女中豪杰。",
      indexLetter: 'F'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/16.jpg',
      name: '安莉',
      indexLetter: 'A'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/men/31.jpg',
      name: '阿贵',
      remarks: "憨憨小伙。",
      indexLetter: 'A'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/22.jpg',
      name: '贝拉',
      remarks: "酷酷女孩。",
      indexLetter: 'B'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/37.jpg',
      name: 'Lina',
      indexLetter: 'L'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/18.jpg',
      name: 'Nancy',
      indexLetter: 'N'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/men/47.jpg',
      name: '扣扣',
      indexLetter: 'K'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/men/3.jpg',
      name: 'Jack',
      indexLetter: 'J'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/5.jpg',
      name: 'Emma',
      indexLetter: 'E'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/24.jpg',
      name: 'Abby',
      indexLetter: 'A'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/men/15.jpg',
      name: 'Betty',
      indexLetter: 'B'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/men/13.jpg',
      name: 'Tony',
      indexLetter: 'T'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/men/26.jpg',
      name: 'Jerry',
      indexLetter: 'J'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/men/36.jpg',
      name: 'Colin',
      indexLetter: 'C'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/12.jpg',
      name: 'Haha',
      indexLetter: 'H'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/11.jpg',
      name: 'Ketty',
      indexLetter: 'K'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/13.jpg',
      name: 'Lina',
      indexLetter: 'L'),
  Friends(
      imageUrl: 'https://randomuser.me/api/portraits/women/23.jpg',
      name: 'Lina',
      indexLetter: 'L'),
];

4.3、cell

// 定义一个Cell
class ListRowCell extends StatefulWidget {
  final Friends cellFriend;// 数据模型
  final String? groupTitle;// 字母,等于null就不显示headView
  final int? cellIndex;// cell下标,预留字段
  const ListRowCell({Key? key, required this.cellFriend, this.groupTitle,this.cellIndex})
      : super(key: key);
  @override
  _ListRowCellState createState() => _ListRowCellState();
}

class _ListRowCellState extends State<ListRowCell> {
  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: Stack(
        children: [
          /// headView
          Row(
            children: [
              widget.groupTitle != null
                  ? (Container(
                      padding: const EdgeInsets.only(left: 10),
                      alignment: Alignment.centerLeft,
                      width: winWidth(context),
                      height: 30,
                      color: SSJColors.themBgColor,
                      child: Text(
                          widget.groupTitle != null ? widget.groupTitle! : ''),
                    ))
                  : (Container()),
            ],
          ),

          /// 内容
          Container(
            margin: EdgeInsets.only(
                top: widget.groupTitle != null ? (30.0) : (0.0)),//stack布局,headView高度为30
            padding: const EdgeInsets.fromLTRB(10, 5, 10, 5),
            child: Row(
              children: [
                /// 头像
                Container(
                  width: 40,
                  height: 40,
                  decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(5),
                      image: DecorationImage(
                          image: widget.cellFriend.imageUrl != null
                              ? NetworkImage(widget.cellFriend.imageUrl!)
                              : AssetImage(widget.cellFriend.imageName!)
                                  as ImageProvider)),
                ),
                /// 名字+备注信息
                Container(
                  padding: const EdgeInsets.only(
                    left: 10,
                  ),
                  // color: Colors.grey,
                  // alignment: Alignment.centerLeft,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      /// 名字
                      Text(
                        widget.cellFriend.name != null
                            ? (widget.cellFriend.name!)
                            : (''),
                        style: const TextStyle(fontSize: 16.0),
                      ),
                      /// 备注信息
                      widget.cellFriend.remarks != null
                          ? (Text(
                              widget.cellFriend.remarks!,
                              style: const TextStyle(fontSize: 13.0),
                              maxLines: 1,
                              overflow: TextOverflow.ellipsis,
                            ))
                          : (Container()),
                    ],
                  ),
                )
              ],
            ),
          ),

          /// 底部分割线
          Row(
            children: [
              Container(
                // color: Colors.red,
                padding: const EdgeInsets.only(left: 10 + 40 + 10),
                child: Container(
                  width: winWidth(context) - 10 - 40 - 10,
                  height: 0.2,
                  color: Colors.grey,
                ),
              )
            ],
          ),

        ],
      ),
    );
  }
}

4.4、指示器 wechat_index_bar.dart

关于指示器,我单独封装到文件wechat_index_bar.dart

import 'package:flutter/material.dart';
import 'others/ssj_const.dart';
import 'dart:ui';

// 定义一个回调函数
typedef SelectedCallback = void Function(String itemStr);

const double _itemFontSize = 12.0; // 索引字体大小
const double _itemHeight = 17.0; // 每个索引的高度(计算top要用到)
const double _navGatorHeight = 56.0; // 导航栏高度
const double _bottomNavigationBarHeight = 56.0; // tabbar高度

const _bgColorOn = Color.fromRGBO(200, 100, 200, 1); // 指示器背景颜色-选中
const _bgColorOff = Colors.transparent; // 指示器背景颜色-未选中
const _txtColorOn = Colors.white; // 指示器字体颜色-选中
const _txtColorOff = Colors.grey; // 指示器字体颜色-未选中

const _bubbleW = 40.0; // 气泡宽
const _bubbleH = 40.0; // 气泡高
const _bubbleBGColor = Colors.blue; // 气泡背景颜色
const _bubbleTxtColor = Colors.white; // 气泡内字体颜色
const _bubbleFontSize = 17.0; // 气泡内字体大小

const _bubbleOffBar = 40.0; // 气泡距离指示器艰巨

class IndexBar extends StatefulWidget {
  final SelectedCallback? selectedCallBack;
  final BuildContext parentContext;
  const IndexBar({required this.parentContext, this.selectedCallBack, Key? key})
      : super(key: key);

  @override
  _IndexBarState createState() => _IndexBarState();
}

class _IndexBarState extends State<IndexBar> {
  late Color _bgColor = _bgColorOff; // 指示器背景颜色
  late Color _txtColor = _txtColorOff; // 指示器字体颜色
  late String _selectedStr = ''; // 记录本次选中的字母,用来比较前后两次字母
  late String _bubbleTitle = 'A'; // 气泡文字
  late double _bubbleTop = 0.0; // 气泡 距离顶部的偏移值
  late double _bubbleHeight = 0.0; // 气泡高度,用来控制 显示/不显示
  @override
  Widget build(BuildContext context) {
    final List<Widget> _indexWidgets = [];

    /// 将所有指示器文字, 以组件形式装载成数组
    for (int i = 0; i < INDEX_WORDS.length; i++) {
      var index = INDEX_WORDS[i];
      _indexWidgets.add(Container(
        padding: const EdgeInsets.only(left: 5, right: 5),
        height: _itemHeight,
        child: Text(
          index,
          style: TextStyle(fontSize: _itemFontSize, color: _txtColor),
        ),
      ));
    }

    //索引条的top
    //     = 屏幕高度 - 状态栏高度-导航栏高度56 - BottomNavigationBar高度 - 底部安全域
    final double viewTop = (winHeight(widget.parentContext) -
            winStateHeight() -
        _navGatorHeight -
        _bottomNavigationBarHeight -
            winBottomHeight() -
        _itemHeight * INDEX_WORDS.length) /
        2.0;

    /* 注释 - 逻辑思维
      1、点击按下:背景变灰色,字体变白色
      2、点击结束:背景变透明,字体变灰色
      3、拖拽过程中会定位到哪个字母
      4、top = 屏幕高度 - 状态栏高度-导航栏高度56-BottomNavigationBar高度-底部安全域 */

    return Stack(
      children: [
        Positioned(
          right: 0,
          top: viewTop,
          height: _itemHeight * INDEX_WORDS.length,
          child: GestureDetector(
            child: Container(
              color: _bgColor,
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: _indexWidgets,
              ),
            ),
            onHorizontalDragDown: (DragDownDetails details) {
              // 拖拽开始
              setState(() {
                _bgColor = _bgColorOn;
                _txtColor = _txtColorOn;
              });
            },
            onHorizontalDragUpdate: (DragUpdateDetails details) {
              // 拖拽更新
              gainIndexStr(details, widget.parentContext,
                  (String itemStr, double yOffset) {
                bool result =
                    sendDoBlock(itemStr, _selectedStr, widget.selectedCallBack);
                if (result) {
                  setState(() {
                    _bubbleTitle = itemStr;
                    _bubbleTop = yOffset;
                    _bubbleHeight = _bubbleH;
                  });
                }
                _selectedStr = itemStr; // 记录本次字母,方便下次比较
              });
            },
            onHorizontalDragEnd: (DragEndDetails details) {
              // 拖拽结束
              setState(() {
                _bgColor = _bgColorOff;
                _txtColor = _txtColorOff;
                _bubbleHeight = 0.0;
              });
            },
            onTapDown: (TapDownDetails details) {
              // 按下,非拖拽
              setState(() {
                _bgColor = _bgColorOn;
                _txtColor = _txtColorOn;
                _bubbleHeight = _bubbleH;
              });
              gainIndexStr(details, widget.parentContext,
                  (String itemStr, double yOffset) {
                bool result =
                    sendDoBlock(itemStr, _selectedStr, widget.selectedCallBack);
                if (result) {
                  setState(() {
                    _bubbleTitle = itemStr;
                    _bubbleTop = yOffset;
                    _bubbleHeight = _bubbleH;
                  });
                }
                _selectedStr = itemStr; // 记录本次字母,方便下次比较
              });
            },
            onTapUp: (TapUpDetails details) {
              // 按下 - 抬起
              setState(() {
                _bgColor = _bgColorOff; //Colors.transparent;
                _txtColor = _txtColorOff;
                _bubbleHeight = 0.0;
              });
            },
          ),
        ),
        Positioned(
            /// 气泡
            // top:让中心对齐,所以要减去气泡一半的高度;要跟字母对其,需要加字母一半的高度
            top: _bubbleTop + viewTop - _bubbleHeight*0.5 + _bubbleFontSize*0.5,
            right: _bubbleOffBar,
            child: Stack(children: [
              Image(image: const AssetImage('images/气泡.png'),width: _bubbleW,height: _bubbleHeight,),
              Container(
                alignment: const Alignment(0, 0),
                width: _bubbleW,
                height: _bubbleHeight,
                child: Text(
                  _bubbleTitle,
                  style: const TextStyle(
                      fontSize: _bubbleFontSize, color: _bubbleTxtColor),
                ),
              ),
              ]
            ))
      ],
    );
  }
}

/// 如果跟上次选中的字母不一样,就调用CallBack
bool sendDoBlock(
    String currentStr, String lastStr, SelectedCallback? selectedCallBack) {
  if (currentStr == lastStr) {
    return false;
  }
  if (selectedCallBack != null) {
    selectedCallBack(currentStr);
  }
  return true;
}

/// 根据details和context 返回选中的字母
// details类型不确定,可以是TapDownDetails,也可以是DragUpdateDetails,所以用var修饰
void gainIndexStr(var details, BuildContext context,
    void Function(String itemStr, double yOffset) callBack) {
  
  RenderBox box = context.findRenderObject() as RenderBox;
  /**
   * 获得在组件上,y轴相对偏移值
   * 偏移值 除以 每个item的高度 就是偏移到第几个index下标(index从0开始)
   * */
  double yOffset = box.localToGlobal(details.localPosition).dy; //获得y轴实际偏移值
  var cellIndexDouble = yOffset / _itemHeight; //是一个double类型

  callBack(INDEX_WORDS[cellIndexDouble.toInt()], yOffset);
}

///  数据源
const INDEX_WORDS = [
  '🔍',
  '☆',
  'A',
  'B',
  'C',
  'D',
  'E',
  'F',
  'G',
  'H',
  'I',
  'J',
  'K',
  'L',
  'M',
  'N',
  'O',
  'P',
  'Q',
  'R',
  'S',
  'T',
  'U',
  'V',
  'W',
  'X',
  'Y',
  'Z'
];

4.5、ssj_colors.dart

import 'package:flutter/material.dart';

class SSJColors{
  // 主题颜色
  static const Color themBgColor = Color.fromRGBO(237, 237, 237, 1);
}

4.6、ssj_const.dart

import 'dart:ui';

import 'package:flutter/material.dart';

// 整屏宽度
double winWidth(BuildContext context) {
  return MediaQuery.of(context).size.width;
}

// 整屏高度
double winHeight(BuildContext context) {
  return MediaQuery.of(context).size.height;
}

// 状态栏高度
double winStateHeight() {
  return MediaQueryData.fromWindow(window).padding.top;
}

/// 底部安全域
double winBottomHeight() {
  return MediaQueryData.fromWindow(window).padding.bottom;
}


// 状态栏高度
// EdgeInsets paddings = MediaQuery.of(widget.parentContext).padding;
// double stateHeight = MediaQuery.of(widget.parentContext).padding.top;

【布局方式有多种,大家可以按照自己的想法来~】

【欢迎点赞、评论,大家一起交流~】