Study harmoniously! Don’t be impatient!! I’m your old friend, Xiao Qinglong

preface

Today we will use Flutter to create a simple address book page on wechat.

For the sake of explanation, let’s get the concepts together

  • Groups are groups, the green circles (ListView can have many groups)

  • HeadView represents the beginning of each group, the blue circled part

  • The cell represents the remaining child of each group except the headView, that is, the red circle

  • The indicator shows the right – most column of letters

This article directory

  • 1, analysis,

  • 2. Technical points

    • 2.1. Sort by letter
    • 2.2. HeadView displays only one letter
    • 2.3. Scroll to the specified letter at the beginning of the ListView
    • Get the height of the ListView on the screen
    • 2.5. Calculate and store offsets corresponding to all letters
    • 2.6 How to avoid rebound animation when data is not enough to turn pages
  • 3. Layout idea

    • 3.1. Overall layout
    • 3.2 LiseView cell layout
  • 4. Code implementation

    • 4.1. Data and data model
    • 4.2,wechat_list_page.dart
    • 4.3, the cell
    • 4.4,Indicator wechat_index_bar. Dart
    • 4.5, ssj_colors. Dart
    • 4.6, ssj_const. Dart

Dart and wechat_index_bar.dart.

1, analysis,

To implement this page, we need to analyze a wave:

  1. On the left is a scrollable component, ListView

  2. Listviews need to be “sorted by first letter”, with each headView having one letter

  3. You need a pointer on the right. Drag and click on the letter, and the ListView will scroll to the column with the specified letter

  4. The button on the right of the navigation bar (this is not our focus today, so ignore it for now)

2. Technical points

2.1,Sort by letter

// _listModels is an array of data models. _listModels.sort((a, b) {return a.indexletter! .compareTo(b.indexLetter!) ; });Copy the code

2.2,HeadView displays only one letter

Technically, headView is part of a cell, but we classify the cell:

  • The cell with headView

  • Cell without headView

You only need to add a groupTitle parameter when defining the cell, and use "non-null judgment" to distinguish whether headView is required.Copy the code

2.3,Scroll to the start of the ListView to the specified letter

The Flutter API provides the following implementation:

// duration animation duration // curve animation type // _scrollController controller, Type is ScrollController _scrollController. AnimateTo (offetValue, duration: const duration (microseconds: 5), the curve: Curves.easeIn);Copy the code

In the page build method, you need to record the offset of each letter (headView) on the listView. When clicking or dragging, you can scroll according to the offset of each letter.

2.4,Gets the height of the ListView on the screen

Define a global key

GlobalKey listViewKey = GlobalKey();
Copy the code

When the listView is created, pass this key in

ListView.builder(
  key: listViewKey,
  ...
)
Copy the code

Get this where needed:

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

// rendbox.size. Width is the width of the rendbox.size.
// rendbox.size. Height is the height of the rendbox.size.
Copy the code

2.5,Calculates and stores offsets for all letters

// Calculate the y position of each item letter and store it in the ziMuMap set
// Count the height of the ListView
const double cellHeight = 50;
int zimuCount = 0;// Count how many letters ziMuMap has, which means how many headViews
for(int i = 0; i<_listModels.length; i++){var thisLetter = _listModels[i].indexLetter;// The current letter
  if(i == 0) {// position 0
    yCount = _headerData.length * cellHeight;
    ziMuMap.addAll({thisLetter:yCount});
    listViewAllHeight = yCount;
    zimuCount ++;
  }else{
    // If the current letter does not match the previous puzzle, the offset position is increased by cellHeight + 30, otherwise cellHeight is offset
    final lastLetter =
        _listModels[i - 1].indexLetter;// The last letter
    if(thisLetter ! = lastLetter) { yCount += cellHeight +30;
      ziMuMap.addAll({thisLetter:yCount});
      listViewAllHeight = yCount + cellHeight;
      zimuCount ++;
    }else{
    // Repeat the letter. No changes are required for ziMuMapyCount += cellHeight; }}}Copy the code

2.6,How to avoid the rebound animation when the data is not enough to turn the page

We know that if you click on a letter, the ListView will automatically jump to the group for that letter.

In one case, the “Z” letter is clicked, and there is only one cell corresponding to the group of “Z” letter. Since there is no other cell behind this cell to fill the remaining screen, the system will automatically rebound.

[The solution is as follows]

  • Click the letter to get the offset of the letter. If the total height of the ListView content – offset of the letter < The height of the ListView in the screen, scroll = the total height of the ListView content – the height of the ListView in the screen, then we can ensure that the content to be displayed in the screen.

  • Total ListView content height = All headView height + All Cell height

3. Layout idea

3.1,The overall layout

1. LiseView fills the entire screen (except navigation bar and Tabar), and ListView scrolling does not affect the indicator, which is attached to the far right, above the LiseView. So the outermost layout is as follows

Stack(
  children: [
     ListView.builder(),/ / list
     IndexBar(),/ / indicator],)Copy the code

3.2,LiseView cell layout

  • The cell header is a headView

  • The most left side of the cell is an image (locally loaded, network loaded), and the left side is the name and the remarks (Row+Column layout) from top to bottom.

  • There is an underline at the bottom (left side not reached the top)

I’m using a stack layout here

Stack(children: [/// headView Row(), /// Container(), /// bottom split line Row(),],)Copy the code

4. Code implementation

4.1,Data and data models

class Friends { final String? whetherHead; // Local image final String? imageName; // Local image final String? imageUrl; // Final String? name; final String? remarks; // Note final String? indexLetter; Friends( {this.whetherHead, this.imageName, this.imageUrl, this.name, this.remarks, this.indexLetter}); } final List<Friends> _headerData = [Friends(imageName: 'images/ new friends.png ', name: 'new friend ', indexLetter: PNG (imageName: 'images/ tag.png '), Friends(imageName: 'images/ tag.png ', indexLetter: 'L'), Friends(imageName: 'images/ tag.png ', name: ImageName: 'imageName ', imageName:' imageName ', imageName: 'imageName ', imageName:' imageName ', imageName: 'imageName ', 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: 'and', / / a few: "2007-08-28, born in Yuyao, Zhejiang province, working in a government department, she was a hero among women with a strong personality and an enterprising spirit." 'https://randomuser.me/api/portraits/women/16.jpg' name: 'AnLi' indexLetter: 'A'), Friends (imageUrl: 'https://randomuser.me/api/portraits/men/31.jpg' name: 'pell, few: "han han guy." indexLetter:' A '), Friends (imageUrl: 'https://randomuser.me/api/portraits/women/22.jpg' name: 'Bella, few: "cool girl.", 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: 'QQ' 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'), ];Copy the code

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'),
];
Copy the code

4.3,cell

// Define a Cell class ListRowCell extends StatefulWidget {final Friends cellFriend; // Data model final String? groupTitle; HeadView final int = null cellIndex; 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()),],), /// / content Container(margin: EdgeInsets. Only (top: widget.groupTitle!= null? (30.0) : (0.0)),// Stack layout, headView height 30 padding: Const EdgeInsets. FromLTRB (10, 5, 10, 5), child: Row(children: [/// / head Container(width: 40, height: 40, decoration: 40)) BoxDecoration( borderRadius: BorderRadius.circular(5), image: DecorationImage( image: widget.cellFriend.imageUrl != null ? NetworkImage(widget.cellFriend.imageUrl!) : AssetImage (widget. CellFriend. ImageName!) as ImageProvider)), Container), / / / name + note information (padding: const EdgeInsets.only( left: 10, ), // color: Colors.grey, // alignment: Alignment.centerLeft, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [/// / name Text(widget.cellfriend.name!= null? (widget.cellfriend.name!) : ("), style: const TextStyle(fontSize: 16.0),), / / / note information widget. CellFriend. Few! = null? (Text (widget. CellFriend. Few!, style: Const TextStyle(fontSize: 13.0), maxLines: 1, Overflow: TextoverFlow.ellipsis,) : (Container()),],),),),),), /// (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: color. Grey,),),],),); }}Copy the code

4.4,Indicator wechat_index_bar. Dart

For indicators, I wrapped them separately in the file 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'
];
Copy the code

4.5,ssj_colors.dart

import 'package:flutter/material.dart'; Class SSJColors{// static const Color themBgColor = color.fromrgbo (237, 237, 237, 1); }Copy the code

4.6,ssj_const.dart

import 'dart:ui'; import 'package:flutter/material.dart'; Double winWidth(BuildContext context) {return mediaQuery.of (context).sie.width; } // Double winHeight(BuildContext context) {return mediaQuery.of (context).size. Height; } / / status bar height double winStateHeight () {return MediaQueryData. FromWindow (window). The padding. The top; } / / / at the bottom of the security domain double winBottomHeight () {return MediaQueryData. FromWindow (window). The padding. The bottom; Paddings = mediaQuery.of (widget.parentContext).padding; // double stateHeight = MediaQuery.of(widget.parentContext).padding.top;Copy the code

There are many ways of layout, you can follow your own ideas ~

[Welcome to like, comment, we communicate ~]