Flutter - 项目实战 - 仿微信(五)通讯录
接上篇:10、Flutter - 项目实战 - 仿微信(四)数据准备
详细代码参见Demo
其他相关联文章
7、Flutter - 项目实战 - 仿微信(一)BottomNavigationBar 4个主页面显示
10、Flutter - 项目实战 - 仿微信(四)数据准备
12、Flutter - 项目实战 - 仿微信(六)聊天页面
效果:
创建一个单独的文件夹friends 存放相应的文件
通讯录数据单独抽成一个文件 friends_data.dart
左边的bar 单独抽成一个文件实现 index_bar.dart
1、friends_data.dart
class Friends {
final String imageUrl;
final String name;
final String indexLetter; //首字母大写
Friends({this.imageUrl, this.name, this.indexLetter});
}
List<Friends> datas = [
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/27.jpg',
name: 'Lina',
indexLetter: 'L'),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/17.jpg',
name: '菲儿',
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: '阿贵',
indexLetter: 'A'),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/22.jpg',
name: '贝拉',
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'),
];
通讯录所用到的数据
用户头像
用户名
用户名手写字母(用于排序和左侧bar点击的时候滚动到对应位置)
2、index_bar.dart
import 'package:flutter/material.dart';
import 'package:wechat/const.dart';
import 'friends_data.dart';
class IndexBar extends StatefulWidget {
//创建索引条回调
final void Function(String str) indexBarCallBack;
IndexBar({this.indexBarCallBack});
@override
_IndexBarState createState() => _IndexBarState();
}
int getIdex(BuildContext context, Offset globalPosition, List index_word) {
// 拿到box
RenderBox box = context.findRenderObject();
// 拿到y值
double y = box.globalToLocal(globalPosition).dy;
// 算出字符高度 box 的总高度 / 2 / 字符开头数组个数
var itemHeight = ScreenHeight(context) / 2 / index_word.length;
//算出第几个item,并且给一个取值范围 ~/ y除以item的高度取整 clamp 取值返回 0 -
int index = (y ~/ itemHeight).clamp(0, index_word.length - 1);
print('现在选中的是${index_word[index]}');
return index;
}
class _IndexBarState extends State<IndexBar> {
Color _bkColor = Color.fromRGBO(1, 1, 1, 0.0);
Color _textColor = Colors.black;
double _indicatorY = 0.0; //悬浮窗位置
String _indicatorText = 'A'; //显示的字母
bool _indocatorHidden = true; //是否隐藏悬浮窗
final List<String> _index_word = [];
// 排序后的数组
final List<Friends> _listDatas = [];
@override
void initState() {
// TODO: implement initState
super.initState();
//----------------------- 1 -------------------------------
// 1、根据实际数据显示右侧bar
// _index_word.add('🔍');
// _index_word.add('☆');
// _listDatas.addAll(datas);
// //排序!
// _listDatas.sort((Friends a, Friends b) {
// return a.indexLetter.compareTo(b.indexLetter);
// });
经过循环,将每一个头的首字母放入index_word数组
// for (int i = 0; i < _listDatas.length; i++) {
// if (i < 1 || _listDatas[i].indexLetter != _listDatas[i - 1].indexLetter) {
// _index_word.add(_listDatas[i].indexLetter);
// }
// }
//----------------------- 2 -------------------------------
// 2、右侧bar显示全部字母
_index_word.addAll(INDEX_WORDS);
}
@override
Widget build(BuildContext context) {
List<Widget> words = [];
for (int i = 0; i < _index_word.length; i++) {
words.add(Expanded(
child: Text(
_index_word[i],
style: TextStyle(fontSize: 10, color: _textColor),
)));
}
return Positioned(
right: 0.0,
height: ScreenHeight(context) / 2,
top: ScreenHeight(context) / 4,
width: 120,
child: Row(
children: <Widget>[
Container(
alignment: Alignment(0, _indicatorY),
width: 100,
child: _indocatorHidden
? null
: Stack(
alignment: Alignment(-0.2, 0), //0, 0 是中心顶部是0,-1 左边中心是-1,0
children: <Widget>[
Image(
image: AssetImage('images/气泡.png'),
width: 60,
),
Text(
_indicatorText,
style: TextStyle(fontSize: 25, color: Colors.white),
),
],
), //气泡
),
GestureDetector(
child: Container(
width: 20,
color: _bkColor,
child: Column(
children: words,
),
),
onVerticalDragUpdate: (DragUpdateDetails details) {
int index = getIdex(context, details.globalPosition, _index_word);
setState(() {
_indicatorText = _index_word[index];
//根据我们索引条的Alignment的Y值进行运算的。从 -1.1 到 1.1
//整个的Y包含的值是2.2
_indicatorY = 2.2 / _index_word.length * index - 1.1;
_indocatorHidden = false;
});
widget.indexBarCallBack(_index_word[index]);
}, //按住屏幕移动手指实时更新触摸的位置坐标
onVerticalDragDown: (DragDownDetails details) {
//globalPosition 自身坐标系
int index = getIdex(context, details.globalPosition, _index_word);
_indicatorText = _index_word[index];
_indicatorY = 2.2 / _index_word.length * index - 1.1;
_indocatorHidden = false;
widget.indexBarCallBack(_index_word[index]);
print('现在点击的位置是${details.globalPosition}');
setState(() {
_bkColor = Color.fromRGBO(1, 1, 1, 0.5);
_textColor = Colors.white;
});
}, //触摸开始
onVerticalDragEnd: (DragEndDetails details) {
setState(() {
_indocatorHidden = true;
_bkColor = Color.fromRGBO(1, 1, 1, 0.0);
_textColor = Colors.black;
}); //触摸结束
},
) //这个是索引条
],
),
);
}
}
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'
];
代码中实现了两种bar的数据形式,一种是根据实际的通讯录数据去显示对应的数据,一种是显示全部字母
index_bar 的思路:
创建一个纵向布局然后,放入Text,当点击或者是移动的时候,根据移动到的位置,算出是对应那个item 然后取出对应的内容返回
2.1、Expanded
用 Expanded 组件,实现纵向布局的Text均分
Expanded 组件可以使Row、Column、Flex等子组件在其主轴方向上展开并填充可用空间(例如,Row在水平方向,Column在垂直方向)。如果多个子组件展开,可用空间会被其flex factor(表示扩展的速度、比例)分割。
Expanded 组件必须用在Row、Column、Flex内,并且从Expanded到封装它的Row、Column、Flex的路径必须只包括StatelessWidgets或StatefulWidgets组件(不能是其他类型的组件,像RenderObjectWidget,它是渲染对象,不再改变尺寸了,因此Expanded不能放进RenderObjectWidget)。
扩展
Flexible组件可以使Row、Column、Flex等子组件在主轴方向有填充可用空间的能力(例如,Row在水平方向,Column在垂直方向),但是它与Expanded组件不同,它不强制子组件填充可用空间。
2.2、Positioned
* Positioned()用在Stack()组件中
* Positioned组件的属性
bottom: 距离层叠组件下边的距离
left:距离层叠组件左边的距离
top:距离层叠组件上边的距离
right:距离层叠组件右边的距离
width: 层叠定位组件的宽度
height: 层叠定位组件的高度
这里设置bar 的总高度为屏幕高的1/2 ,距离顶部的距离为屏幕高的1/4
2.3、选中字母的气泡显示
用一个很像布局,气泡显示在字母列表的左侧,气泡中的文字在气泡的中间偏左一些显示(
Alignment(-0.2, 0), //0, 0 是中心 顶部是 0,-1 左边中心是-1,0
)根据 _indocatorHidden 来决定是否显示。
2.4、GestureDetector
通过手势识别器,来找到对应的的文字,修改 _indocatorHidden 值,并且修改 _indicatorY 的值来让气泡上下移动位置。
2.4.1、
onVerticalDragDown
触摸开始的时候,显示气泡,气泡的位置在最顶部不(这里说明一下为什么要用2.2、1.1。我们知道(0,0) 是中心,y -1是最上边,y 1是最下边。那为什么要用-1.1 和 1.1呢?因为-1的时候气泡的上边和我们字母列表的上边是对齐的,这样的话气泡的右边小箭头指向的就不是我们实际想要的位置,经过测试得出来的-1.1 和 1.1.这个可以自行测试算出更优值)
将背景色修改为黑色半透明
文字颜色设置为白色
2.4.2、
onVerticalDragEnd
触摸结束的时候,隐藏气泡
将背景色设置为透明
文字颜色修改为黑色
2.4.3、
onVerticalDragUpdate
拖拽手势
onVerticalDragUpdate: (DragUpdateDetails details) {
int index = getIdex(context, details.globalPosition, _index_word);
int getIdex(BuildContext context, Offset globalPosition, List index_word) {
// 拿到box
RenderBox box = context.findRenderObject();
// 拿到y值
double y = box.globalToLocal(globalPosition).dy;
// 算出字符高度 box 的总高度 / 2 / 字符开头数组个数
var itemHeight = ScreenHeight(context) / 2 / index_word.length;
//算出第几个item,并且给一个取值范围 ~/ y除以item的高度取整 clamp 取值返回 0 -
int index = (y ~/ itemHeight).clamp(0, index_word.length - 1);
print('现在选中的是${index_word[index]}');
return index;
}
如果直接用 details.globalPosition 拿到的数据是整个手机屏幕的坐标系,(0,0),在屏幕的左上角。
这时候我们想要的仅仅是这个字母 bar 的坐标系,那么就需要进行坐标系的转换
// 拿到box
RenderBox box = context.findRenderObject();
// 拿到y值
double y = box.globalToLocal(globalPosition).dy;
通过转换得到的就是我们我们的 context 对应的坐标系。然后.dy 就得到了y值。
拿到y值之后,根据bar 的实际高度和 字母数组中的元素个数,计算出对应字母在数组中的下标,然后返回下标
拖拽手势,拿到下标之后,取出对应的字符串赋值给Text,并修改y 的位置显示气泡
然后回调传入对应的 text 对应的字符串(字母)
widget.indexBarCallBack(_index_word[index]);
在我们 friends 中需要知道我们选中的字符串,然后对应去滚动ListView,所以需要回调
//创建索引条回调
final void Function(String str) indexBarCallBack;
IndexBar({this.indexBarCallBack});
3、friends.dart
import 'package:flutter/material.dart';
import 'package:wechat/const.dart';
import 'package:wechat/pages/discover/discover_child_page.dart';
import 'package:wechat/pages/friends/index_bar.dart';
import 'friends_data.dart';
class FriendsPage extends StatefulWidget {
@override
_FriendsPageState createState() => _FriendsPageState();
}
class _FriendsPageState extends State<FriendsPage> {
// 字典里面放item和高度的对应数据
final Map _groupOffsetMap = {
// 这里因为根据实际数据变化和固定全部字母前两个值都是一样的,所以没有做动态修改,如果不一样记得要修改
INDEX_WORDS[0]: 0.0,
INDEX_WORDS[1]: 0.0,
};
ScrollController _scrollController;
final List<Friends> _listDatas = [];
@override
void initState() {
//初始化,只调用一次
// TODO: implement initState
super.initState();
// _listDatas.addAll(datas);
// _listDatas.addAll(datas);
//链式编程,等同于上面的两个
_listDatas..addAll(datas)..addAll(datas);
//排序!
_listDatas.sort((Friends a, Friends b) {
return a.indexLetter.compareTo(b.indexLetter);
});
var _groupOffset = 54.5 * 4;
//经过循环计算,将每一个头的位置算出来,放入字典
for (int i = 0; i < _listDatas.length; i++) {
if (i < 1 || _listDatas[i].indexLetter != _listDatas[i - 1].indexLetter) {
//第一个cell
_groupOffsetMap.addAll({_listDatas[i].indexLetter: _groupOffset});
//保存完了再加——groupOffset偏移
_groupOffset += 84.5;
} else {
// if (_listDatas[i].indexLetter == _listDatas[i - 1].indexLetter) {
//此时没有头部,只需要加偏移量就好了
_groupOffset += 54.5;
}
}
_scrollController = ScrollController();
}
final List<Friends> _headerData = [
Friends(imageUrl: 'images/新的朋友.png', name: '新的朋友'),
Friends(imageUrl: 'images/群聊.png', name: '群聊'),
Friends(imageUrl: 'images/标签.png', name: '标签'),
Friends(imageUrl: 'images/公众号.png', name: '公众号'),
];
Widget _itemForRow(BuildContext context, int index) {
// 系统cell
if (index < _headerData.length) {
return _FriendsCell(
imageAssets: _headerData[index].imageUrl,
name: _headerData[index].name,
);
}
//显示剩下的cell
//如果当前和上一个cell的indexLetter一样,就不显示
bool _hideIndexLetter = (index - 4 > 0 &&
_listDatas[index - 4].indexLetter == _listDatas[index - 5].indexLetter);
return _FriendsCell(
imageUrl: _listDatas[index - 4].imageUrl,
name: _listDatas[index - 4].name,
groupTitle: _hideIndexLetter ? null : _listDatas[index - 4].indexLetter,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: WeChatThemColor,
title: Text('通讯录'),
actions: <Widget>[
GestureDetector(
child: Container(
margin: EdgeInsets.only(right: 10),
child: Image(
image: AssetImage('images/icon_friends_add.png'),
width: 25,
),
),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (BuildContext context) => DiscoverChildPage(
title: '添加朋友',
)));
},
)
],
),
body: Stack(
children: <Widget>[
Container(
color: WeChatThemColor,
child: ListView.builder(
controller: _scrollController,
itemCount: _listDatas.length + _headerData.length,
itemBuilder: _itemForRow,
)), //列表
IndexBar(
indexBarCallBack: (String str) {
if (_groupOffsetMap[str] != null) {
_scrollController.animateTo(_groupOffsetMap[str],
duration: Duration(milliseconds: 1), curve: Curves.easeIn);
}
},
), //悬浮检索控件
],
),
);
}
}
class _FriendsCell extends StatelessWidget {
final String imageUrl;
final String name;
final String groupTitle;
final String imageAssets;
const _FriendsCell(
{this.imageUrl, this.name, this.imageAssets, this.groupTitle}); //首字母大写
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Container(
alignment: Alignment.centerLeft,
padding: EdgeInsets.only(left: 10),
height: groupTitle != null ? 30 : 0,
color: Color.fromRGBO(1, 1, 1, 0.0),
child: groupTitle != null
? Text(
groupTitle,
style: TextStyle(fontSize: 18, color: Colors.grey),
)
: null,
), //组头
Container(
color: Colors.white,
child: Row(
children: <Widget>[
Container(
margin: EdgeInsets.all(10),
width: 34,
height: 34,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.0),
image: DecorationImage(
image: imageUrl != null
? NetworkImage(imageUrl)
: AssetImage(imageAssets),
)),
), //图片
Container(
child: Text(
name,
style: TextStyle(fontSize: 17),
),
), //昵称
],
),
), //通讯录组内容
Container(
height: 0.5,
color: WeChatThemColor,
child: Row(
children: <Widget>[
Container(
width: 50,
color: Colors.white,
)
],
),
) //分割线
],
);
}
}
3.1、数据
void initState() {
//初始化,只调用一次
// TODO: implement initState
super.initState();
// _listDatas.addAll(datas);
// _listDatas.addAll(datas);
//链式编程,等同于上面的两个
_listDatas..addAll(datas)..addAll(datas);
//排序!
_listDatas.sort((Friends a, Friends b) {
return a.indexLetter.compareTo(b.indexLetter);
});
3.1.1、链式编程
初始化方法里面将数据加载进来,这里使用链式编程,添加两次datas 数组中的数据进来,是因为数据太少不方便进行 右侧bar的滚动调试,所以为了多一点数据加两次,
Flutter中这样.. 后面接着 .. 的写法叫做链式编程
//链式编程,等同于上面的两个 _listDatas..addAll(datas)..addAll(datas);
通讯录的ListView以及添加的组头,这里就不多介绍了,前面几篇也有提到,看代码应该很好理解
3.2、右侧悬浮检索控件
IndexBar(
indexBarCallBack: (String str) {
if (_groupOffsetMap[str] != null) {
_scrollController.animateTo(_groupOffsetMap[str],
duration: Duration(milliseconds: 1), curve: Curves.easeIn);
}
},
), //悬浮检索控件
拿到bar 返回的 string ,然后到_groupOffsetMap(类似iOS中的字典) 里面去检索,找到对应的key,然后取出对应的偏移量,然后_scrollController 去滚动到指定位置
final Map _groupOffsetMap = {
// 这里因为根据实际数据变化和固定全部字母前两个值都是一样的,所以没有做动态修改,如果不一样记得要修改
INDEX_WORDS[0]: 0.0,
INDEX_WORDS[1]: 0.0,
};
以上代码如有错误还望指正,共同进步