flutter-仿微信项目实战三(联系人、索引)
前言
这篇文章功能性稍微多一些,主要介绍了联系人列表
、索引功能
联系人列表采用非SectionList
的方式巧妙实现,毕竟系统没提供,另外介绍右侧索引条,且保证能和左侧联系人形成互动
ps
: 可以根据需要自己封装一个 SectionList
如下图所示,主要分了四个关键单,联系人列表默认item、联系人列表、索引、导航添加好友
联系人功能,主要封装了两个控件,ContactIndexs
为索引功能,ContactCell
为列表的 item
,且了处理Section问题
导航添加好友
AppBar
的左侧定制属性为leading
,设置单个组件,而右侧为 actions
,可设置多个组件,这里简单在重复一下导航跳转,如下所示
Scaffold(
appBar: AppBar(
title: const Text("联系人"),
foregroundColor: Colors.black,
backgroundColor: const Color.fromRGBO(0xe1, 0xe1, 0xe1, 1),
elevation: 0, //去掉阴影
actions: [
TextButton(
onPressed: () {
//跳转到下一个页面
Navigator.of(context).push(MaterialPageRoute(builder:
(BuildContext context) => const AddFriend(title: "添加朋友")));
//如果要主动跳转回来
//Navigator.pop(context);
},
child: Image.asset(
"images/添加朋友.png",
width: 28,
height: 28,
),
),
],
),
//这是是联系人列表实际功能
body: Stack(
children: [
ListView.builder(
//这个controller用于 ListView滚动到指定位置
controller: _controller,
itemBuilder: itemForRow,
itemCount: headerItems.length + friends.length,
),
],
),
);
联系人列表
联系人列表主要包括列表固定头部
、动态内容
,索引
后面介绍
固定头部
:这里先生成数据,使用动态内容的 item 数据结构,只不过只有默认的图片和名字参数
headerItems = [
Friends(imageUrl: 'images/新的朋友.png', name: "新的朋友"),
Friends(imageUrl: 'images/群聊.png', name: "群聊"),
Friends(imageUrl: 'images/标签.png', name: "标签"),
Friends(imageUrl: 'images/公众号.png', name: "公众号"),
];
动态内容
:这是将获取的数据,按照字母letter
进行排序即可,这里不多介绍
friends.sort((a, b) => a.letter.compareTo(b.letter));
联系人列表将头部和尾部合并成一个,数量两个数组集合,在显示内容row
里面分离两个
ListView.builder(
controller: _controller,
itemBuilder: itemForRow,
itemCount: headerItems.length + friends.length,
),
row
实现如下所示
//设置Row,这里通过巧妙的方式设置section效果
//只有一种带标题的cell,如果letter字母和上一个一样,就不显示标题,否则显示
//这样一种cell就可以表示全部内容
Widget itemForRow(BuildContext context, int index) {
if (index < 4) {
//前四个固定标题的处理
final item = headerItems[index];
return ContactCell(imageUrl: item.imageUrl, text: item.name);
}else {
//后面列表的处理,由于使用的不是一个数组,需要重新调整索引,减去固定标题长度
final item = friends[index - 4];
//动态列表第一个,一定有标题
if (index == 4) {
return ContactCell(imageUrl: item.imageUrl, text: item.name, isShowLetter: true, letter: item.letter);
}else {
//后面的都和上面一个比较,一样就不显示标题
final lastItem = friends[index - 5];
return ContactCell(imageUrl: item.imageUrl, text: item.name, isShowLetter: item.letter != lastItem.letter, letter: item.letter);
}
}
}
ContactCell
的逻辑如下所示,主要是在基础cell的基础上添加了一个标题头,根据参数控制是否显示,参数由外部控制,letter和上一个一样就不显示标题,否则显示
Column(
children: [
//头部 letter,采用三目运算符控制顶部标题显示,如果letter和上一个一样就不显示
widget.isShowLetter ?
Container(
height: 28,
color: const Color.fromRGBO(0xe1, 0xe1, 0xe1, 1),
padding: const EdgeInsets.only(left: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(widget.letter),
widget.letter == '☆' ? Row(
children: const [
SizedBox(width: 4),
Text('星标朋友',
style: TextStyle(
fontSize: 12
)
)
]
) : Container()
]
)
) : Container(),
//内部item 头像和文字,和发现页类似,逻辑较为简单,省略
...Container(),
下划线
...line
],
)
索引条
所以条,就是字母letter
集合,将他们纵向布局到右侧即可,然后才是气泡
和跳转指定位置
通信录页面使用 Stack
如下所示,然后包裹了索引条组件 ContactIndexs
Stack(
children: [
ListView.builder(
controller: _controller,
itemBuilder: itemForRow,
itemCount: headerItems.length + friends.length,
),
ContactIndexs(
onUpdateCallback: (String letter, int index) {
print(letter);
jumpToLetter(letter);
},
letterList: letters,
),
],
),
索引的部分实现如下所示,先看看索引条,我们前面使用了 Stack
布局,就是为了此时,直接使用Positioned
布局将内容放到最右侧
//设置控件相对父布局Stack位置,局右侧,高度拉满
//距离右、上、下距离为零,因此左侧位置根据内容拉伸
Positioned(
top: 0,
right: 0,
bottom: 0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//左边气泡,采用alignment的方式调整位置
//直接使用 Stack + Position 索引的方式更简单,使用计算好的点击的letter坐标,减去顶部差即可
//这里挑战一下更复杂的,采用alignment的方式,按照百分比调整
Container(
//通过alignment来调整位置
alignment: Alignment(-0.15, bubbleAligmentY),
//通过索引更新气泡显隐
child: lastIndex > -1
? Stack(
alignment: const Alignment(-0.15, 0),
children: [
Image.asset(
'images/气泡.png',
width: 60,
height: 60,
),
Text(
widget.letterList[lastIndex],
style: const TextStyle(fontSize: 18),
)
],
)
: null,
),
//右边索引
//通过拖拽手势处理,当按下或者移动的时候,需要显示气泡,且更新外部联系人位置
//一次需要通过拖拽点击的位置,进行计算
GestureDetector(
onVerticalDragUpdate: (DragUpdateDetails details) {
//拖拽更新,pan实际走的也是这个,不信看看参数😂
//世界坐标details.globalPosition
//本地坐标details.localPosition
onUpdate(details.localPosition.dy);
},
onVerticalDragStart: (DragStartDetails details) {
//拖拽点击时
onUpdate(details.localPosition.dy);
},
onVerticalDragEnd: (DragEndDetails details) {
//拖拽结束
lastIndex = -1;
setState(() {});
},
//处理点击的
onTapDown: (TapDownDetails details) {
onUpdate(details.localPosition.dy);
},
//取消手势,取消后,更新索引,同时可以用索引更新气泡显隐
onTapCancel: () {
lastIndex = -1;
setState(() {});
},
//点击抬起手势
onTapUp: (TapUpDetails details) {
lastIndex = -1;
setState(() {});
},
//放置字母集合
child: SizedBox(
width: 20,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: words,
),
),
),
],
),
);
words
直接生成一个字母组件集合,以便于展开到快速展开到索引中,只要设置好每个字符高度即可,可以更方便计算,配合父组件的Column
默认次轴居中,很容易就实现效果了
//也可以使用 .map来直接返回
List<Widget> words = [];
for (String item in widget.letterList) {
final wordWidget = SizedBox(
height: 14, child: Text(item, style: const TextStyle(fontSize: 10)));
words.add(wordWidget);
}
这里是索引位置计算的核心逻辑,不仅计算出了点击到的索引是哪一个,还计算出了对应的 alignment百分比
另外气泡使用的是 alignment
,需要先普及一下, Alignment
属性分别是 x、y
,值域均为-1~1
之间,当一个值为0的时候,默认居中,默认布局中(reverse
的正好相反),-1表示上、左,1表示下、右
int lastIndex = -1; //用于保存上一次点击的letter索引,避免连续回调
double bubbleAligmentY = 0; //用于更新气泡的align
//移动点击更新,可以控制气泡,点击了哪个字母
onUpdate(double dy) {
//计算点击了哪一个,可以返回给外面,每一个高度是14
final length = widget.letterList.length;
// final height = MediaQuery.of(context).size.height; 屏幕高度
final height = context.size!.height; //组件高度
//获取内容高度
const letterHeight = 14;
final allLetterHeight = length * letterHeight;
//获取第一个索引的起始y坐标
final startY = (height - allLetterHeight) / 2;
//由于计算嗾使是以父节点的空间来响应手势,起始坐标在最上方,之前计算好了第一个的位置
var index = (dy - startY) ~/ letterHeight; //~/取模,即向下取整
// final index = (length / letterHeight).floor(); //或者这个也行
//由于往上滑或者往下,会出现越界,处理一下越界问题
index = index.clamp(0, length - 1); //可以通过 clamp 方法处理数字越界问题
//如果和上一个不一样,直接回调字母,和更新的索引
if (index != lastIndex) {
widget.onUpdateCallback(widget.letterList[index], index);
}
lastIndex = index;
//计算气泡,内部使用,还没计算
//之前已经计算出了start,用position的方式将会更简单,这里我们挑战使用alignment,计算百分比
//第一个字符中间y坐标
final firstLetterCy = startY + letterHeight / 2;
//气泡与第一个字符中心点对齐需要移动坐标
final halfHeight = height / 2;
//由于中心点是0,上下是-1,所以计算出索引,要用halfHeight才是相当于中心点的坐标
//而物体总移动,还需要抛去自己一半的高度(顶部对齐-1,中心对齐0),所以少移动了半个物体的高度
final radioY =
(halfHeight - firstLetterCy - index * letterHeight) / (halfHeight - 30);
setState(() {
bubbleAligmentY = -radioY;
});
}
到这里索引条的功能基本完毕了,剩下的就是索引定位到指定的位置,这就和索引条功能无关了
索引条和联系人互动
前面索引条已经定位到点击到的letter
,现在通过点击的letter
定位到指定的字母标题
的位置
如下所示,所以条给了一个回调,返回的点击的字母和索引
//作为代理,用于更新ListView跳转位置
final ScrollController _controller = ScrollController();
Stack(
children: [
ListView.builder(
//代理
controller: _controller,
itemBuilder: itemForRow,
itemCount: headerItems.length + friends.length,
),
ContactIndexs(
onUpdateCallback: (String letter, int index) {
print(letter);
jumpToLetter(letter);
},
letterList: letters,
),
],
)
跳到指定位置,一共三个属性,偏移量、动画时间、过渡动画曲线
jumpToLetter(String letter) {
_controller.animateTo(
lettersLocationMap[letter]!,
duration: const Duration(microseconds: 400),
curve: Curves.easeIn);
}
偏移量的计算如下所示
已知:单个item的高度、顶部固定item的数量、标题的item位置,计算指定标题item的偏移
1、指定标题item偏移
= 顶部固定item高度
+ 上一个组总高度
,然后分解为步骤2
2、指定标题item偏移
: 第一组则偏移量 = 顶部固定高度 ; 后续偏移量 = 上一组偏移 + 上一组总高度(上一组数量 * index + 标题高度)
//用于保存letter对应的偏移量
Map<String, double> lettersLocationMap = {};
//更新letter的同时,更新偏移量
double offsetY = 47 * 4; //顶部的四个
var offsetIndex = 0; //默认偏移
var isFirst = true;
for (final item in friends) {
if (!letters.contains(item.letter)) {
letters.add(item.letter);
//第一组的偏移
if (isFirst) {
offsetY += offsetIndex * 47; //更新offsetY
isFirst = false;
}else {
//后续偏移
offsetY += (offsetIndex * 47 + 28);
}
lettersLocationMap[item.letter] = offsetY;
offsetIndex = 0;
}
offsetIndex++;
}
实际数据量小的话,也可以在移动的时候再计算偏移量也不迟
最后
每次的编写与挑战,都会让我们对组件更加理解,使用起来更加得心应手,快来试试吧
转载自:https://juejin.cn/post/7087422670886666277