一、显示索引条
1.1 悬浮索引条层级分析
- 要实现微信通讯录的索引条效果,那么它与通讯录当前的Container属于重叠效果.
- 那么考虑使用Stack层叠结构,当前悬浮检索控件应该在通讯录上层.
- 那么来到friends_page中,将Scaffold的body改为Stack结构
Stack(
children: [
//通讯录列表
Container(...),
//悬浮检索控件
Positioned(
right: 0.0,
bottom: 0.0,
top: 0.0,
width: 30,
child: Container(color: Color.fromRGBO(1, 1, 1, 0.3),)),
],
)
1.2 悬浮检索控件布局
- 接下来布局Positioned中的Container的child: 其中都是英文大写字母,包括一个放大镜搜索图标.那么我们构建这样一个包含所有字母及放大镜的数组.
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'];
- 在friends_page中构建方法里,循环创建我们的字母Widgets,放入一个空数组中
@override
Widget build(BuildContext context) {
//构建字母widgets,采用Expanded方便后续扩展.
List<Widget> words = [];
for(int i = 0; i < INDEX_WORDS.length; i++){
words.add(Expanded(child: Text(INDEX_WORDS[i])));
}
return Scaffold(...);
}
- 悬浮检索控件的内部实现为: 采用Column将所有字母控件摆放完毕
//悬浮检索控件
Positioned(
right: 0.0,
bottom: 0.0,
top: 0.0,
width: 30,
child: Container(
color: Color.fromRGBO(1, 1, 1, 0.3),
child: Column(
//所有字母widgets
children: words,
),
),
)
- 当前显示效果为:
1.3 控件调整
- 把每个检索控中每个控件的字体稍微改小点.
words.add(Expanded(child: Text(INDEX_WORDS[i], style: TextStyle(fontSize: 10),)));
- 把检索框整体修改为合适尺寸
Positioned(
right: 0.0,
top: ScreenHeight(context)/8.0,
width: 30,
height: ScreenHeight(context)/2.0,
child: Container(...),
),
二、抽取悬浮检索控件.
2.1 抽取悬浮控件
- 因为考虑到我们在长按这个检索控件涉及到的一些状态变化及内部赋值处理,我们需要将这个控件抽离出来,形成单一的控价.
- 很明显需要抽取形成一个StatefulWidget.我们考虑直接将Positioned抽走.创建一个index_bar.dart文件,修改报错.
- 那么抽离出去后,该部分实现为.
import 'package:flutter/material.dart';
import 'const.dart';
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'];
class IndexBar extends StatefulWidget {
const IndexBar({Key? key}) : super(key: key);
@override
State<IndexBar> createState() => _IndexBarState();
}
class _IndexBarState extends State<IndexBar> {
@override
Widget build(BuildContext context) {
//构建字母widgets,采用Expanded方便后续扩展.
List<Widget> words = [];
for(int i = 0; i < INDEX_WORDS.length; i++){
words.add(Expanded(child: Text(INDEX_WORDS[i], style: TextStyle(fontSize: 10),)));
}
return Positioned(
right: 0.0,
top: ScreenHeight(context)/8.0,
width: 30,
height: ScreenHeight(context)/2.0,
child: Container(
color: Color.fromRGBO(1, 1, 1, 0.3),
child: Column(
//所有字母widgets
children: words,
),),);}
}
2.2 索引条选中与非选中状态控件颜色设置
- 当长按检索控件时,背景颜色所有改变. 检索控件中所有字母颜色为白色,
- 当松手时,恢复默认状态,字母颜色为黑色
- 首先设置两个私有成员变量: 背景颜色 _backColor,字体颜色: _textColor
class _IndexBarState extends State<IndexBar> {
//1. 背景颜色.默认透明
Color _backColor = Color.fromRGBO(1, 1, 1, 0.0);
//2. 字母文字颜色
Color _textColor = Colors.black;
....
//字母颜色赋值,在TextStyle中实现
words.add(Expanded(child: Text(INDEX_WORDS[i], style: TextStyle(fontSize: 10,color: _textColor),)));
.....
}
- 然后背景颜色通过设置Positioned中的Container的color实现
Container(
color: _backColor,
child: Column(...)
)
- 再者因为选中检索控件后有状态响应,所以采用GestureDetector封装手势,包括拖拽时的手势和拖拽结束后的手势.
GestureDetector(
//4. 拖拽时的手势
onVerticalDragDown: (DragDownDetails details){
_backColor = Color.fromRGBO(1, 1, 1, 0.5);
_textColor = Colors.white;
setState(() { });
},
// 5. 拖拽结束的手势
onVerticalDragEnd: (DragEndDetails details){
_backColor = Color.fromRGBO(1, 1, 1, 0.0);
_textColor = Colors.black;
setState(() { });
},
child: Container(...),
),
三、选中索引条获取选中值
3.1 手势偏移量globalPosition
- 接下来我们需要知道,手势点击的位置对应的坐标.以方便后面我们的气泡处理.
- 引入一个点击手势响应时的偏移量globalPosition.
onVerticalDragDown: (DragDownDetails details){
print(details.globalPosition);
}
- 当点击时,发现点击的位置点坐标输出(相对于全局来说的位置坐标).但是当拖拽时,并没有实时更新.这个时候,引入另一个手势响应事件.
onVerticalDragUpdate: (DragUpdateDetails details){
print(details.globalPosition);
},
- 此时拖拽时坐标位置是持续输出的.
3.2 取出当前的选中值
- 1. 通过拖拽手势,我们拿到了全局的偏移量坐标,但是使用起来不太方便,因此我们想要拿到相对于检索控件的坐标.
- 2. 此时引入context.findRenderObject().拿到渲染树当前的渲染对象RenderBox
- 3. 再通过RenderBox的全局坐标转换为当前坐标方法,拿到相对于当前的偏移量坐标.
//6. 拖拽时响应手势
onVerticalDragUpdate: (DragUpdateDetails details){
//6.1 将全局坐标转变为当前坐标
RenderBox box = context.findRenderObject() as RenderBox;
print(box.globalToLocal(details.globalPosition));
},
- 4. 此时我们需要拿到坐标转换的y值坐标.也就是Offset.dy
- 5.计算每一个item的高度itemHeight,然后算出当前的index = y ~/ itemHeight; 这样取出当前的选中值.
onVerticalDragUpdate: (DragUpdateDetails details){
// print(details.globalPosition);
//6.1 将全局坐标转变为当前坐标
RenderBox box = context.findRenderObject() as RenderBox;
//6.2 坐标转换,取出y值偏移量
double y = box.globalToLocal(details.globalPosition).dy;
//6.3 每一个item的高度 = 控件总高度/字母控件数组个数
var itemHeight = ScreenHeight(context)/2.0/INDEX_WORDS.length;
//6.4 算出当前的index
int index = y ~/itemHeight;
//6.5 拿出当前选中的值.
print('当前选中的是: ${INDEX_WORDS[index]}');
}
3.3 封装获取Item方法
- 根据当前选中的item对应的偏移量,可以算出对应的item.我们把这个取值过程,抽出来形成一个方法.
- 其中index要考虑越界问题,所以我们需要对其进行包装.采用clamp对其限定在范围内.
//7.封装获取index对应item的方法
String getItemByIndex(BuildContext context, Offset globalPosition) {
//6.1 将全局坐标转变为当前坐标
RenderBox box = context.findRenderObject() as RenderBox;
//6.2 坐标转换,取出y值偏移量
double y = box.globalToLocal(globalPosition).dy;
//6.3 每一个item的高度 = 控件总高度/字母控件数组个数
var itemHeight = ScreenHeight(context)/2.0/INDEX_WORDS.length;
//6.4 算出当前的index,要考虑越界问题,所以我们需要对其进行包装.采用clamp对其限定在范围内
// int index = y ~/itemHeight;
int index = (y ~/ itemHeight).clamp(0, INDEX_WORDS.length - 1);
//6.5 拿出当前选中的值.
print('当前选中的是: ${INDEX_WORDS[index]}');
return INDEX_WORDS[index];
}
- 那么现在的问题是,我们在IndexBar中获取到的item.告诉外部组件,去做相应的联动.
四、滚动ListView
4.1 回调选中的item给外部
- 带着上面的问题,我们来到IndexBar,创建一个回调函数暴露给外部即可
- 在IndexBar有状态组件内部创建一个可空的回调函数 indexBarCallBack,顺带的将构造方法也重新定义了.
class IndexBar extends StatefulWidget {
//8.对外暴露索引控件当前选中item的回调方法
final void Function(String str)? indexBarCallBack;
const IndexBar({Key? key, this.indexBarCallBack}) : super(key: key);
@override
State<IndexBar> createState() => _IndexBarState();
}
2. 在拖拽更新响应手势中添加调用
//6. 拖拽时响应手势
onVerticalDragUpdate: (DragUpdateDetails details){
//8.传递回调函数,判空
if (widget.indexBarCallBack != null) {
widget.indexBarCallBack!(getItemByIndex(context,details.globalPosition));
}
},
3. 在拖拽开始时也触发回调方法
//4. 拖拽时的手势
onVerticalDragDown: (DragDownDetails details){
//8.传递回调函数,判空
if (widget.indexBarCallBack != null) {
widget.indexBarCallBack!(getItemByIndex(context,details.globalPosition));
}
_backColor = Color.fromRGBO(1, 1, 1, 0.5);
_textColor = Colors.white;
setState(() { });
},
4. 接着在IndexBar构造方法调用的地方设置,来到friends_page.dart中.将悬浮检索控件的调用改为
//悬浮检索控件
IndexBar(indexBarCallBack: (String str){
//8.通过回调拿到当前选中的item
print(str);
}),
- 如此,点击item后回调的对外暴露就实现了.
4.2 滚动ListView
- 拿到相应的item后,我们就需要设置滚动的位置了.此时引入一个滚动控制器ScrollController
- 在_FriendsPageState中设置一个私有变量_scrollController,要用late修饰.
- 接着在initState中初始化滚动控制器
_scrollController = ScrollController();
3.然后在ListView.builder中有个controller属性,将我们创建的_scrollController设置给它,此时的_scrollController就相当于代理
4.来到IndexBar调用处.在回调响应方法中,设置滚动控制器的响应方法
IndexBar(indexBarCallBack: (String str){
//9.4 滚动控制器的响应 offset: 滚动到的位置,假定为250, 持续时间为100ms,动画为.easeIn
_scrollController.animateTo(250.0, duration: Duration(milliseconds: 100), curve: Curves.easeIn);
}),
- 此时选中item后对ListView的滚动交互就实现了.
4.3 计算具体的滚动偏移量
- 回顾ListView的布局,cell的高度为54,组头为30,那么每个组头位于ListView中的位置是可以计算出来的.那么接下里我们通过设计一个_groupOffsetMap来存放每个组头对应的偏移量.从而实现滚动联动
- 在_FriendsPageState中创建一个组头滚动位置的Map私有成员.因为前两个索引item为放大器和收藏图标.所以偏移量为0.0
//10.1 设置计算组头位置的Map
final Map _groupOffsetMap = {
INDEX_WORDS[0] : 0.0,
INDEX_WORDS[1] : 0.0,
};
2. 在initState方法中计算所有组头对应的偏移量
- 创建字母对应Offset的Map. 起始位置为固定的cell*4
- 遍历网络数据,存入组头对应的偏移量
- 第一个一定是头部,考虑1的情况,防止-1时越界.
- 如果没有头,那么直接加上Cell高度
- 剩下的就是有头部的
//10.2 创建字母对应Offset的Map. 起始位置为固定的cell*4
var _groupOffset = 54.0 * 4;
//10.3 遍历网络数据,存入组头对应的偏移量
for (int i = 0; i < _listDatas.length; i++){
//第一个一定是头部,考虑1的情况,防止-1时越界.
if (i < 1 ){
_groupOffsetMap.addAll({_listDatas[i].indexLetter: _groupOffset});
_groupOffset += 84;
}
//如果没有头,那么直接加上Cell高度
else if (_listDatas[i].indexLetter == _listDatas[i - 1].indexLetter){
_groupOffset += 54;
} else {//剩下的就是有头部的
_groupOffsetMap.addAll({_listDatas[i].indexLetter: _groupOffset});
_groupOffset += 84;
}
3. 在IndexBar中与拖拽索引控件对应的回调相互联动,设置滚动偏移量
IndexBar(indexBarCallBack: (String str){
//10.4 设置滚动偏移量
if (_groupOffsetMap[str] != null) {
_scrollController.animateTo(_groupOffsetMap[str], duration: Duration(milliseconds: 100), curve: Curves.easeIn);
}
}),
4.4 处理滚动到底部的异常问题
- 当我们选择最后几个索引的时候,视图会滚动异常: ListView会先滚动到指定位置,然后又滚回底部的情况.原因是后面的组头内容不够显示一整个屏幕了.所以我们这里需要做下处理.这里主要是对ListView的滚动做监听.
- 如果在iOS中也就是我们想办法获取滚动视图的contentSize,然后减去UITableView的高度,就是UITableView的最大滚动范围.
- 如果需要获取到ListView的一些滚动信息,可以将它包裹在NotificationListener里面,它有个onNotification属性,是一个回调我们滚动信息的闭包.我们想要的信息就在闭包参数ScrollNotification note里面.准确来说滚动相关的信息包含在ScrollNotification的metrics属性中,它包含当前滚动偏移值,能滚动的最大范围等信息.
- 首先定义一个私有变量 _maxScrollOffsetY,不能给0,否则没有滚动ListView之前,使用IndexBar就无法滚动ListView.
double _maxScrollOffsetY = double.maxFinite;
2.接下来修改通讯录列表的ListView结构,将其改为用NotificationListener包含的child
Container(
color: WeChatThemeColor,
//11.3 监听最大偏移量
child: NotificationListener(
onNotification: (ScrollNotification note) {
// print(note.metrics.pixels.toInt());
// print(note.metrics.maxScrollExtent.toDouble());
_maxScrollOffsetY = note.metrics.maxScrollExtent.toDouble();
return true;
},
child: ListView.builder(
//9.3 滚动控制器的设置,此时的_scrollController就相当于代理
controller: _scrollController,
//2. 长度= 固定长度+网络数据数组长度
itemCount: _headerData.length+_listDatas.length,
itemBuilder: _itemForRow,
),
)
),
3. 接下来在IndexBar的回调中做逻辑判断处理
IndexBar(indexBarCallBack: (String str){
//10.4 设置滚动偏移量
if (_groupOffsetMap[str] != null) {
//11.4 如果滚动偏移量超过了最大值就调用最大值.否则调用计算值
if (_groupOffsetMap[str] < _maxScrollOffsetY){
_scrollController.animateTo(_groupOffsetMap[str], duration: Duration(milliseconds: 100), curve: Curves.easeIn);
} else {
_scrollController.animateTo(_maxScrollOffsetY, duration: Duration(milliseconds: 100), curve: Curves.easeIn);
}
}
}),
4.5 优化回调执行的频率问题
- 从打印的结果来看,我们选中一个下标的时候会被回调很多次.这样我们滚动好友列表的时候会造成不必要的性能消耗. 明明只需要滚动一次,但是滚动了数次都在同一个位置.所以这里我们需要优化一下.
- 我们需要记录一个_currentIndexLetter,每次执行回调的时候,判断回调的首字母是否与当前记录的_currentIndexLetter相同,如果是一样的就不用再进行回调了.只有不同的时候,才执行回调.
- 在_IndexBarState中设置一个私有变量_currentIndexLetter
- 在拖拽开始和拖拽过程这两个回调方法中,进行记录的_currentIndexLetter回调判断.
//4. 拖拽开始时的手势
onVerticalDragDown: (DragDownDetails details){
//12.2 回调判断
if (_currentIndexLetter != getItemByIndex(context, details.globalPosition)){
_currentIndexLetter = getItemByIndex(context, details.globalPosition);
//8.传递回调函数,判空
if (widget.indexBarCallBack != null) {
widget.indexBarCallBack!(getItemByIndex(context,details.globalPosition));
}
}
_backColor = Color.fromRGBO(1, 1, 1, 0.5);
_textColor = Colors.white;
setState(() { });
},
//6. 拖拽过程的响应手势
onVerticalDragUpdate: (DragUpdateDetails details){
//7.方法封装
// getItemByIndex(context,details.globalPosition);
//12.2 回调判断
if (_currentIndexLetter != getItemByIndex(context, details.globalPosition)){
_currentIndexLetter = getItemByIndex(context, details.globalPosition);
//8.传递回调函数,判空
if (widget.indexBarCallBack != null) {
widget.indexBarCallBack!(getItemByIndex(context,details.globalPosition));
}
}
},
- 综上: 回调频率就正常了.
五、显示指示器
5.1 修改层级结构
- 通讯录界面的最后一步,显示我们拖拽时的指示器,首先考虑的就是布局的层级结构修改.
- 因为开始的时候只考虑了右侧的一列显示我们的字母索引.现在只需要在左侧添加一块容器来显示我们的指示器气泡就行.
- 所以IndexBar布局层级结构修改为Row.指示器背景的图片用一张气泡图片展示即可.中间的文字,使用Text.
- 先大致看一下修改的效果
Positioned(
right: 0.0,
top: ScreenHeight(context)/8.0,
width: 120,
height: ScreenHeight(context)/2.0,
//3.因为选中后有状态响应,所以采用GestureDector
child: Row(
children: [
Container(
width: 100,
color: Colors.cyan,
child: Stack(
alignment: Alignment(-0.3,0),
children: [
Image(image: AssetImage('images/气泡.png'), width: 60,),
Text('A',style: TextStyle(fontSize: 35,color: Colors.white),),
],
),
),
GestureDetector(...),
],
)
);
- 显示结果如下:
5.2 指示器的显示与隐藏逻辑
- 接下来就是对指示器的显示与隐藏逻辑进行控制.和背景色的显示隐藏逻辑类似.都是在手势的开始和结束回调中进行控制.
- 使用一个bool变量来控制指示器的现实与隐藏.在手势的开始和结束方法里面操作这个bool变量,然后setState()就可以实现指示器显示与隐藏.
- 关于指示器的显示文本,也就是我们定义的 _currentIndexLetter,直接使用即可.
- 最后关于如何控制整个IndexBar的气泡上下移动.
- 通过对Alignment的使用,发现可以控制IndexBar上气泡的上下位移.
- 不断的修改Alignment的y值会找到一个合适的y值指向第一个放大器,那么-y就指向最后一个字母Z.
- 尝试多次后发现y=-1.12的时候,指示器刚好指向第一个放大器的位置.
- 那么现在的问题就是将1.12 * 2=2.24分成 INDEX_WORDS.length-1份,然后根据选择的下标,取得对应的Alignment的y值.
- 当我们选择第一个的时候下标为0,y值应该为-1.12,当我们选择最后一个的时候下标为INDEX_WORDS.length-1,y值应该为1.12.
5.根据这些信息可以找到计算y值的公式
- 新增两个私有变量 _showIndicator和 _indicatorAlignmentY
//13.1 新增指示器显示与否的bool值,新增指示器的y值
var _showIndicator = false;
var _indicatorAlignmentY = -1.12;
- 具体代码实现
- 气泡布局为
- 拖拽开始和结束的响应处理为
- 拖拽过程的计算处理为
//6. 拖拽过程的响应手势
onVerticalDragUpdate: (DragUpdateDetails details){
//12.2 回调判断
if (_currentIndexLetter != getItemByIndex(context, details.globalPosition)){
_currentIndexLetter = getItemByIndex(context, details.globalPosition);
//8.传递回调函数,判空
if (widget.indexBarCallBack != null) {
widget.indexBarCallBack!(getItemByIndex(context,details.globalPosition));
}
//13.5 指示器位置值计算
_indicatorAlignmentY = (1.13 * 2) / (INDEX_WORDS.length.toDouble()-1) * INDEX_WORDS.indexOf(_currentIndexLetter) - 1.13;
setState(() {});
}
}
综上: 通讯录界面就完成了,显示效果为