上一节,我们搭建项目、熟悉本地资源读取、开发【发现】页面
本节,我们开发【我的】+【通讯录】,
公开代码
,分析
部分布局
和细节点
。
- 状态栏颜色
- 【我的】页面
- 【通讯录】页面
1. 状态栏颜色
iOS
中顶部状态栏
有白色
和黑色
,Flutter
中,可以通过设置主题色的深浅,自动修改顶部状态栏
颜色:
-
primaryColor: Colors.blue
主题色设置为深色
时,状态栏
为白色
:
-
primaryColor: Colors.white
主题色设置为浅色
时,状态栏
为黑色
:
导航栏背景
和标题颜色
、分割线
、通过页面
的appBar
属性可以设置。appBar: AppBar( backgroundColor: widget._themeColor, // 导航栏背景色 centerTitle: true, // (安卓)导航栏标题居中 title: Text( "朋友圈", style: TextStyle(color: Colors.black), //标题颜色), elevation: 0.0 )// 去除分割线
-
main.dart
完整代码:
import 'package:flutter/material.dart';
import 'package:wechat_demo/pages/root_page.dart';
void main() => runApp(App());
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Wechat Demo', // 安卓需要,后台切换app时展示的名称(iOS中名称与APP名称一致)
debugShowCheckedModeBanner: false, // 隐藏debug角标
home: RootPage(),
theme: ThemeData(
primaryColor: Colors.white, // 主题色
highlightColor: Color.fromRGBO(0, 0, 0, 0), // 去除高亮色
splashColor: Color.fromRGBO(0, 0, 0, 0), // 去除水波纹
),
);
}
}
2.【我的】页面
-
布局分析:
- 布局方式
不唯一
,以下仅供参考
:
顶部固定
悬浮相机,背后
使用ListView
展示内容,选用Stack
。头部
有左右和上下多种布局,可使用Row
和Stack
结合展示,利用主轴对齐方式
和aligment
对齐方式,实现布局要求。
【相关知识点】
- 隐藏
状态栏间距
:MediaQuery.removePadding( removeTop: true, // 移除顶部内边距 context: context, // 指定上下文 child: XXX),
圆角
头像:// 头像 Container( width: 55, height: 55, // 圆角 (需要宽高) decoration: BoxDecoration( borderRadius: BorderRadius.circular(8.0), // 设置圆角 image: DecorationImage(image: AssetImage("images/HT.png"), fit: >BoxFit.fill))), // 图片填充
屏幕
相关属性:
屏幕宽度
:MediaQuery.of(context).size.width;
状态栏高度
:MediaQuery.of(context).padding.top;
-
文件位置调整:(按
模块
分类文件夹
)
const
全局常量
和函数
的声明
:
import 'package:flutter/material.dart';
// 微信主题色
Color Wechat_themeColor = Color.fromRGBO(220, 220, 220, 1.0);
// 屏幕宽度(必须传入MaterialApp的contrext)
double ScreenWidth(BuildContext context) => MediaQuery.of(context).size.width;
// 状态栏高度
double StatusBarHeight(BuildContext context) => MediaQuery.of(context).padding.top;
-
mine
我的页面代码
(主要是UI布局,响应事件
及滚动
时顶部背景
白色未处理
):
import 'package:flutter/material.dart';
import 'package:wechat_demo/const.dart';
import 'package:wechat_demo/pages/discover/discover_cell.dart';
class MinePage extends StatefulWidget {
@override
_MinePageState createState() => _MinePageState();
}
class _MinePageState extends State<MinePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Wechat_themeColor,
body: Stack(
children: <Widget>[
Container(
child: MediaQuery.removePadding(
removeTop: true,
context: context,
child: ListView(
children: [
headerView(), // 头部
SizedBox(height: 8),
DiscoverCell(imageName: "images/微信支付.png", title: "支付"),
SizedBox(height: 8),
DiscoverCell(imageName: "images/微信收藏.png", title: "收藏"),
sepectorLine(),
DiscoverCell(imageName: "images/微信相册.png", title: "相册"),
sepectorLine(),
DiscoverCell(imageName: "images/微信卡包.png", title: "卡包"),
sepectorLine(),
DiscoverCell(imageName: "images/微信表情.png", title: "表情"),
SizedBox(height: 8),
DiscoverCell(imageName: "images/微信设置.png", title: "设置")
],
),
),
),
Container(
margin: EdgeInsets.only(top: StatusBarHeight(context)),
padding: EdgeInsets.only(right: 16),
// 状态栏间距
height: 25,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [Image(image: AssetImage("images/相机.png"))],
),
)
],
));
}
// 头部视图
Widget headerView() {
return Container(
padding: EdgeInsets.only(
top: StatusBarHeight(context) + 35,
left: 20,
right: 10,
bottom: 30),
color: Colors.white,
child: Container(
child: Row(
children: <Widget>[
// 头像
Container(
width: 55,
height: 55,
// 圆角 (需要宽高)
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.0), // 设置圆角
image: DecorationImage( // 这是图片,fill填充
image: AssetImage("images/HT.png"), fit: BoxFit.fill))),
// 昵称等
Container(
margin: EdgeInsets.only(left: 20),
child: Container(
width: ScreenWidth(context) - 85 - 20,
child: Column(
children: [
Container(
height: 20,
child: Container(alignment: Alignment.centerLeft, child: Text("H", style: TextStyle(color: Colors.black, fontSize: 20, fontWeight: FontWeight.bold))),
),
Container(
margin: EdgeInsets.only(top: 10),
height: 20,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
child: Text("微信号: wechat_mark", style: TextStyle(color: Colors.grey,)),
),// 微信号
Row(
children: [
Image(image: AssetImage("images/微信二维码.png"), width: 16, height: 16,),
SizedBox(width: 8),
Image(image: AssetImage("images/icon_right.png"), width: 14, height: 14,)
]
)
],
),
)
],
),
),
)
],
),
));
}
// 分割线
Widget sepectorLine() {
return Container(
height: 1,
child: Row(children: [
Container(width: 40, color: Colors.white),
Container()
]));
}
}
3.【通讯录】页面
- 页面分析:
页面分为
导航栏
、滚动内容
、索引栏
。(滚动视图
与索引栏
使用Stack
布局)一、 导航栏:
- 设置
背景颜色
、字体颜色
,隐藏分割线
- 添加
新增朋友
按钮(通过AppBar
的actions
属性添加手势Widget
)二、滚动内容
- 【布局】每个数据都是一个cell视图,都包含
组头
(灰色字母头部)和好友信息(头像
、名称
、分割线
)。组头
由cell数据
决定是否创建。
组头
和好友信息
使用Column
布局,好友信息
内部头像
和名称+分割线
使用Row
布局,根据屏幕宽度计算宽度
。名称
和分割线
使用Column
布局,指定高度
。2.【数据】
_headDatas
记录顶部4个固定数据(新的朋友、群聊、标签、公众号
),_listDatas
记录其余好友数据
,根据首字母
进行排序
。
在initState()
时,使用_groupOffsets
(Map结构)记录每个索引
的offset偏移值
。
- 【交互】 使用
ScrollController
滚动对象记录当前的listView
,IndexBar索引栏
选中索引时,通过indexBarCallBack
回调函数的index参数
,在_groupOffsets
(Map结构)中获取到指定高度
,通过_scrollController.animateTo
滚动到listView指定高度。三、索引栏
- 【布局】 有
索引栏
和选中气泡
两大部件
,使用Row
布局。
分为选中
(背景透明、字体黑色、无气泡)和未选中
(背景灰色、字体白色、展示气泡)两种状态。- 【数据】
INDEX_WORDS
包含搜索
、星标
、A~Z
28组数据。- 【交互】
选中
与未选中
会在自身布局
上发生变化
。也会通过indexBarCallBack
函数,传递int
类型的索引Index
值,告知外部(外部listView
会联动响应
)- 【优化点】
index
值默认值
为-1
,避免每次进入
页面时,listView滚动值
与index索引
不一致。
滚动时,index值
未变化不会触发indexBarCallBack
回调四、缺陷
- 每次
进入页面
,数据均刷新了,未保存状态
。listView
的偏移值过大时(剩余内容高度不足listView满屏),不应滚动
。
-
文件夹:
新建friends
文件夹,新增cell
、数据Model
、IndexBar索引部件
文件。将friends_page.dart
主页面移到friends
文件夹:
cell文件代码(
friends_cell.dart
):
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:wechat_demo/const.dart';
class FriendCell extends StatelessWidget {
final String imageAssets; // 本地图片
final String imageUrl; // 网络图片链接
final String name; // 名称
final String groupTitle;
FriendCell({this.imageAssets, this.imageUrl, @required this.name, this.groupTitle}); // 首字母
@override
Widget build(BuildContext context) {
// 字母段写在cell上,根据内容动态展示
return Container(
child: Column(
children: [
// 字母头部
groupTitle != null ? Container(
height: 30,
padding: EdgeInsets.only(left: 10),
alignment: Alignment.centerLeft,
child: Text(groupTitle, style: TextStyle(color: Colors.grey)),
) : Container(),
// 图片和名称
Container(
color: Colors.white,
height: 54,
child: Row(
children: [
// 头像
Container(
width: 34,height: 34,
margin: EdgeInsets.all(10),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6.0),
image: DecorationImage(image: imageAssets != null ? AssetImage(imageAssets) : NetworkImage(imageUrl))
)),
//昵称+分割线
Container(
width: ScreenWidth(context) - 54,
child: Column(
children: [
Container(
height: 53.5,
alignment: Alignment.centerLeft,
child: Text(name, style: TextStyle(fontSize: 18),)
), // 昵称
Container(height: 0.5, color: Wechat_themeColor), // 分割线
],
),
)
],
),
)
],
),
);
}
}
- data数据代码(
friends_data.dart
):
class Friends {
final String imageUrl;
final String name;
final String indexLetter;
final String message;
final String time;
Friends({this.imageUrl, this.name, this.indexLetter, this.message, this.time});
}
List <Friends>datas = [
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/57.jpg',
name: 'Lina',
indexLetter: 'L',
message: 'hello hank !😁',
time: '下午 3:45',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/70.jpg',
name: '菲儿',
indexLetter: 'F',
message: '忙完了吗?',
time: '下午 3:25',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/60.jpg',
name: '安莉',
indexLetter: 'A',
message: '我在看看,稍等。',
time: '下午 2:45',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/men/91.jpg',
name: '阿贵',
indexLetter: 'A',
message: '我没弄明白...',
time: '昨天',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/22.jpg',
name: '贝拉',
indexLetter: 'B',
message: '这个时候刷圈?',
time: '下午 3:45',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/57.jpg',
name: 'Lina',
indexLetter: 'L',
message: 'hello hank !😁',
time: '下午 3:45',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/18.jpg',
name: 'Nancy',
indexLetter: 'N',
message: '麻烦通过一下😁',
time: '下午 4:05',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/men/47.jpg',
name: '扣扣',
indexLetter: 'K',
message: '你好啊😁',
time: '下午 2:45',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/men/3.jpg',
name: 'Jack',
indexLetter: 'J',
message: '好久不见 ',
time: '下午 4:15',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/5.jpg',
name: 'Emma',
indexLetter: 'E',
message: '在忙什么呢 ',
time: '下午 3:55',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/44.jpg',
name: 'Abby',
indexLetter: 'A',
message: ' 最近还好吗😁',
time: '下午 1:45',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/men/65.jpg',
name: 'Betty',
indexLetter: 'B',
message: '什么时候有空呢😁',
time: '上午 8:45',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/men/83.jpg',
name: 'Tony',
indexLetter: 'T',
message: '一起出去玩吧😁',
time: '上午 7:15',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/men/36.jpg',
name: 'Jerry',
indexLetter: 'J',
message: 'How are you?😁',
time: '上午 9:10',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/men/66.jpg',
name: 'Colin',
indexLetter: 'C',
message: '谢谢你',
time: '下午 3:40',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/58.jpg',
name: 'Haha',
indexLetter: 'H',
message: '最近去健身了么😁',
time: '下午 5:40',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/60.jpg',
name: 'Ketty',
indexLetter: 'K',
message: '你喜欢哪个明星呀😁',
time: '下午 3:45',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/57.jpg',
name: 'Lina',
indexLetter: 'L',
message: 'hello hank !😁',
time: '下午 3:45',
),
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/57.jpg',
name: 'Lina',
indexLetter: 'L',
message: 'hello hank !😁',
time: '下午 3:45',
)
];
-
索引栏
文件代码(index_bar.dart
):
import 'package:flutter/material.dart';
import '../../const.dart';
class IndexBar extends StatefulWidget {
// indexBar高度
final double indexBarHeight;
// 函数传递给外界(回调)
final void Function(int index) indexBarCallBack;
const IndexBar({Key key, this.indexBarHeight = 480, this.indexBarCallBack}) : super(key: key);
@override
_IndexBarState createState() => _IndexBarState();
}
class _IndexBarState extends State<IndexBar> {
// 总高度
final double indexBarHeight = 400;
// 当前选中的Index索引
int _currentIndex = -1;
// 背景颜色
Color _bgColor = Color.fromRGBO(1, 1, 1, 0);
// 文字颜色
Color _indexColor = Colors.black;
// 垂直拖拽(按下)
void onVerticalDragDown(DragDownDetails details) {
if (getCurrentIndex(context, details.globalPosition) != _currentIndex) {
_currentIndex = getCurrentIndex(context, details.globalPosition);
widget.indexBarCallBack(_currentIndex);
}
setIndexBarColor(true);
setState(() {});
}
// 垂直拖拽(拖拽中)
void onVerticalDragUpdate(DragUpdateDetails details) {
// 传递参数(delegate)
if (getCurrentIndex(context, details.globalPosition) != _currentIndex) {
_currentIndex = getCurrentIndex(context, details.globalPosition);
widget.indexBarCallBack(_currentIndex);
setState(() {});
}
}
// 垂直拖拽(拖拽结束)
void onVerticalDragEnd(DragEndDetails details) {
_currentIndex = -1; // 每次回归-1
setIndexBarColor(false);
setState(() {});
}
@override
Widget build(BuildContext context) {
return Container(
height: widget.indexBarHeight,
child: Row(
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_currentIndex != -1 ? Container(
// 控制气泡位置
alignment: Alignment(0, getBubble()),
height: widget.indexBarHeight,
width: 100,
child: Stack(
// 对齐(x: 中心左移20%, y: 居中)
alignment: Alignment(-0.2,0),
children: [
Image(image: AssetImage("images/气泡.png"), width: 60),
Text(INDEX_WORDS[_currentIndex] , style: TextStyle(color: Colors.white,fontSize: 35))
]
),
) : Container(),
Container(
width: 30,
color: _bgColor,
child: GestureDetector(
child: Column(children: getIndexBarItems()),
onVerticalDragDown: onVerticalDragDown,
onVerticalDragUpdate: onVerticalDragUpdate,
onVerticalDragEnd: onVerticalDragEnd,
),
)
],
),
);
}
// 索引元素
List<Widget> getIndexBarItems() {
List<Widget> list = [];
for (int i = 0; i < INDEX_WORDS.length; i++) {
list.add(Expanded(
child: Text(INDEX_WORDS[i], style: TextStyle(color: _indexColor))));
}
return list;
}
// 设置IndexBar颜色
void setIndexBarColor(bool isSelected) {
_bgColor =
isSelected ? Color.fromRGBO(1, 1, 1, 0.5) : Color.fromRGBO(0, 0, 0, 0);
_indexColor = isSelected ? Colors.white : Colors.black;
}
// 获取当前选中的Index
int getCurrentIndex(BuildContext context, Offset offset) {
// 相对坐标
// print(offset.dy);
// 绝对坐标
RenderBox box = context.findRenderObject();
final moveY = box.globalToLocal(offset).dy;
// ~/ 整除
final index = moveY ~/ (widget.indexBarHeight / INDEX_WORDS.length);
return index.clamp(0, INDEX_WORDS.length - 1); // 设置区间边界
}
// 获取气泡的垂直位置
double getBubble() {
if (_currentIndex < 1 ) { return -1.1; }
return 2.2 / (INDEX_WORDS.length - 1) * _currentIndex - 1.1;
}
}
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.dart
:
import 'dart:async';
import 'dart:ffi';
import 'package:flutter/material.dart';
import 'package:wechat_demo/const.dart';
import 'package:wechat_demo/pages/discover/discover_cell.dart';
import 'package:wechat_demo/pages/discover/discover_child_page.dart';
import 'package:wechat_demo/pages/friends/index_bar.dart';
import 'friends_cell.dart';
import 'friends_data.dart';
class FriendsPage extends StatefulWidget {
@override
_FriendsPageState createState() => _FriendsPageState();
}
class _FriendsPageState extends State<FriendsPage> {
// 滚动控制器对象(初始化后,ListView使用,其他地方通过对象调用方法)
ScrollController _scrollController = ScrollController();
// 数据
final List<Friends> _listDatas = [];
// 索引对应偏移值
final Map _groupOffsets = {
INDEX_WORDS[0]: 0.0,
INDEX_WORDS[1]: 0.0,
};
@override
void initState() {
super.initState();
// 多创建一倍数据(使用..是强制返回自身,可链式操作)
_listDatas..addAll(datas)..addAll(datas);
// 排序
_listDatas.sort((Friends a, Friends b) {
return a.indexLetter.compareTo(b.indexLetter);
});
// 索引对应偏移值
var offset = _headDatas.length * 54.0;
for (int i = 0; i < _listDatas.length; i++) {
// 首位
if (i == 0) {
_groupOffsets.addAll({_listDatas[i].indexLetter: offset});
offset += 84;
}
// 字母相同
else if (_listDatas[i].indexLetter == _listDatas[i - 1].indexLetter) {
offset += 54;
}
// 字母不同
else {
_groupOffsets.addAll({_listDatas[i].indexLetter: offset});
offset += 84;
}
}
}
final List<Friends> _headDatas = [
Friends(imageUrl: "images/新的朋友.png", name: "新的朋友"),
Friends(imageUrl: "images/群聊.png", name: "群聊"),
Friends(imageUrl: "images/标签.png", name: "标签"),
Friends(imageUrl: "images/公众号.png", name: "公众号"),
];
// 点击新增朋友
void tapAddFriends() {
// 跳转新页面
Navigator.of(context).push(MaterialPageRoute(
builder: (BuildContext context) => DiscoverChildPage(title: "新增朋友")));
}
// 滚动到GroupOffset位置
void moveToGrounpOffset(int index) {
if (_groupOffsets[INDEX_WORDS[index]] != null) {
_scrollController.animateTo(_groupOffsets[INDEX_WORDS[index]],
duration: Duration(milliseconds: 300), curve: Curves.easeIn);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Wechat_themeColor,
centerTitle: true,
// 安卓的导航栏标题未居中,可以设置居中
title: Text("通讯录"),
elevation: 0.0,
// 去除分割线
// 导航栏按钮
actions: <Widget>[
GestureDetector(
child: Container(
margin: EdgeInsets.only(right: 10),
width: 20,
height: 20,
child: Image(image: AssetImage("images/icon_friends_add.png")),
),
onTap: tapAddFriends,
)
],
),
body: Container(
color: Wechat_themeColor,
child: Stack(
children: [
ListView.builder(
controller: _scrollController,
itemCount: _headDatas.length + _listDatas.length,
itemBuilder: cellForRow),
Positioned(
right: 0,
// 正确top应该 (屏幕高度 - 导航栏高度 - 底部Bar高度 - 480) / 2
top: (ScreenHeight(context) - 480) / 4 ,
child: IndexBar(
indexBarHeight: 480, // 外部可指定indexBar高度
indexBarCallBack: moveToGrounpOffset,
)),
],
),
),
);
}
Widget cellForRow(BuildContext context, int index) {
if (index < _headDatas.length) {
return FriendCell(
imageAssets: _headDatas[index].imageUrl,
name: _headDatas[index].name);
}
// 记录是否展示字母头部(当前首字母与上一个不同,就展示)
final bool _haveGroup = index > _headDatas.length
? _listDatas[index - _headDatas.length].indexLetter !=
_listDatas[index - _headDatas.length - 1].indexLetter
: true;
return FriendCell(
imageUrl: _listDatas[index - _headDatas.length].imageUrl,
name: _listDatas[index - _headDatas.length].name,
groupTitle: _haveGroup
? _listDatas[index - _headDatas.length].indexLetter
: null);
}
}
- 建议阅读时,代码粘贴到
Andriod Studio
应用中,[command
+option
+-
] 折叠所有代码观看
。
本系列为基础入门篇
,一步步入门
,适合初学者练手
。主要是UI布局
和基础思想
的熟悉。一次性
把优化
点处理完,对初学者不太友好