和谐学习!不急不躁!!我是你们的老朋友小青龙~
前言
今天我们来用Flutter
实现微信上的简易版通讯录页面
,先上效果图:
为了便于讲解,我们先统一概念
-
group
表示组,即绿色圈部分
(ListView可以有很多个组) -
headView
表示每个组的开头,即蓝色圈部分
-
cell
表示每个组除headView之外,剩余的一个个子部件,即红色圈部分
-
指示器
表示最右边那一列字母
本文目录
-
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.dart
和wechat_index_bar.dart
这两个文件】
1、分析
要实现这个页面,我们需要先分析一波:
-
左边是一个可以滚动的组件 - -
ListView
-
ListView需要「
按照首字母进行排序
」,每个headView都有一个字母 -
右边需要一个
指示器
,拖拽和点击字母,ListView开头会滚动到指定字母那一栏 -
导航栏右边的按钮(这个不是我们今天的重点,先忽略它)
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;