最终效果

image.png

AppBar上的actions

首先,在导航栏上有一个添加朋友的按钮。这个可以使用AppBar的actions来设置,其次点击这里的actions的时候会响应事件,可以跟之前的发现页面一样,使用GestureDetectoronTap手势

  1. class _FriendPageState extends State<FriendPage> {
  2. @override
  3. Widget build(BuildContext context) {
  4. return Scaffold(
  5. appBar: AppBar(
  6. title: Text('通讯录'),
  7. actions: [
  8. GestureDetector(
  9. onTap: () {
  10. Navigator.of(context).push(MaterialPageRoute(
  11. builder: (BuildContext context) =>
  12. DiscoverChildPage(title: '添加朋友')));
  13. },
  14. child: Container(
  15. padding: EdgeInsets.only(right: 10),
  16. child: Image(
  17. image: AssetImage('images/添加朋友.png'),
  18. width: 32,
  19. ),
  20. ),
  21. )
  22. ],
  23. ),
  24. body: Center(
  25. child: Text('通讯录页面'),
  26. ),
  27. );
  28. }
  29. }

image.png
点击右上角的添加按钮
image.png

itemBuilder

分析:这里自定义的cell需要有4个元素

  • 前面四个加载的是本地的图片,这里需要有一个assetImage
  • 后面是从网络获取,所以这里需要有一个imageUrl
  • 图片后面的文字name
  • 在Flutter中没有分组的概念,所以需要有groupTitle

使用Expanded包装一个Column上面是名字,下面是分割线

  1. class _FriendCell extends StatelessWidget {
  2. final String? imageUrl;
  3. final String? assetImage;
  4. final String? name;
  5. final String? groupTitle;
  6. _FriendCell({this.imageUrl, this.assetImage, this.name, this.groupTitle});
  7. @override
  8. Widget build(BuildContext context) {
  9. return Container(
  10. color: Colors.white,
  11. child: Row(
  12. children: [
  13. Container(
  14. margin: EdgeInsets.all(10),
  15. width: 34,
  16. height: 34,
  17. decoration: BoxDecoration(
  18. borderRadius: BorderRadius.circular(6),
  19. image: DecorationImage(
  20. image: assetImage == null
  21. ? NetworkImage(imageUrl!)
  22. : AssetImage(assetImage!) as ImageProvider))),
  23. Container(
  24. child: Text(
  25. name!,
  26. style: TextStyle(fontSize: 18),
  27. ),
  28. )
  29. ],
  30. ),
  31. );
  32. }
  33. }

数据-模型

这里还没有涉及到网络请求,所以数据都暂时写在本地,网络请求的后面再介绍~

  1. class Friends {
  2. final String? imageUrl;
  3. final String? assetImage;
  4. final String? name;
  5. final String? indexLetter;
  6. Friends({this.imageUrl, this.assetImage, this.name, this.indexLetter});
  7. }
  8. List<Friends> datas = [
  9. Friends(
  10. imageUrl: 'https://randomuser.me/api/portraits/women/27.jpg',
  11. name: 'Lina',
  12. indexLetter: 'L'),
  13. Friends(
  14. imageUrl: 'https://randomuser.me/api/portraits/women/17.jpg',
  15. name: '菲儿',
  16. indexLetter: 'F'),
  17. Friends(
  18. imageUrl: 'https://randomuser.me/api/portraits/women/16.jpg',
  19. name: '安莉',
  20. indexLetter: 'A'),
  21. Friends(
  22. imageUrl: 'https://randomuser.me/api/portraits/men/31.jpg',
  23. name: '阿贵',
  24. indexLetter: 'A'),
  25. Friends(
  26. imageUrl: 'https://randomuser.me/api/portraits/women/22.jpg',
  27. name: '贝拉',
  28. indexLetter: 'B'),
  29. Friends(
  30. imageUrl: 'https://randomuser.me/api/portraits/women/37.jpg',
  31. name: 'Lina',
  32. indexLetter: 'L'),
  33. Friends(
  34. imageUrl: 'https://randomuser.me/api/portraits/women/18.jpg',
  35. name: 'Nancy',
  36. indexLetter: 'N'),
  37. Friends(
  38. imageUrl: 'https://randomuser.me/api/portraits/men/47.jpg',
  39. name: '扣扣',
  40. indexLetter: 'K'),
  41. Friends(
  42. imageUrl: 'https://randomuser.me/api/portraits/men/3.jpg',
  43. name: 'Jack',
  44. indexLetter: 'J'),
  45. Friends(
  46. imageUrl: 'https://randomuser.me/api/portraits/women/5.jpg',
  47. name: 'Emma',
  48. indexLetter: 'E'),
  49. Friends(
  50. imageUrl: 'https://randomuser.me/api/portraits/women/24.jpg',
  51. name: 'Abby',
  52. indexLetter: 'A'),
  53. Friends(
  54. imageUrl: 'https://randomuser.me/api/portraits/men/15.jpg',
  55. name: 'Betty',
  56. indexLetter: 'B'),
  57. Friends(
  58. imageUrl: 'https://randomuser.me/api/portraits/men/13.jpg',
  59. name: 'Tony',
  60. indexLetter: 'T'),
  61. Friends(
  62. imageUrl: 'https://randomuser.me/api/portraits/men/26.jpg',
  63. name: 'Jerry',
  64. indexLetter: 'J'),
  65. Friends(
  66. imageUrl: 'https://randomuser.me/api/portraits/men/36.jpg',
  67. name: 'Colin',
  68. indexLetter: 'C'),
  69. Friends(
  70. imageUrl: 'https://randomuser.me/api/portraits/women/12.jpg',
  71. name: 'Haha',
  72. indexLetter: 'H'),
  73. Friends(
  74. imageUrl: 'https://randomuser.me/api/portraits/women/11.jpg',
  75. name: 'Ketty',
  76. indexLetter: 'K'),
  77. Friends(
  78. imageUrl: 'https://randomuser.me/api/portraits/women/13.jpg',
  79. name: 'Lina',
  80. indexLetter: 'L'),
  81. Friends(
  82. imageUrl: 'https://randomuser.me/api/portraits/women/23.jpg',
  83. name: 'Lina',
  84. indexLetter: 'L'),
  85. ];

ListView

好了,有了数据模型和itemBulider,我们创建ListView就很简单了。当index<4的时候加载的是本地的assetImage反之加载的是网络图片imageUrl

  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/src/painting/image_provider.dart';
  3. import 'discover_child_page.dart';
  4. import 'friend_data.dart';
  5. class FriendPage extends StatefulWidget {
  6. const FriendPage({Key? key}) : super(key: key);
  7. @override
  8. _FriendPageState createState() => _FriendPageState();
  9. }
  10. class _FriendPageState extends State<FriendPage> {
  11. final List<Friends> _headerData = [
  12. Friends(assetImage: 'images/新的朋友.png', name: '新的朋友'),
  13. Friends(assetImage: 'images/群聊.png', name: '群聊'),
  14. Friends(assetImage: 'images/标签.png', name: '标签'),
  15. Friends(assetImage: 'images/公众号.png', name: '公众号'),
  16. ];
  17. Widget _itemForRow(BuildContext context, int index) {
  18. if (index < _headerData.length) {
  19. return _FriendCell(
  20. assetImage: _headerData[index].assetImage,
  21. name: _headerData[index].name);
  22. } else {
  23. return _FriendCell(
  24. imageUrl: datas[index - 4].imageUrl,
  25. name: datas[index - 4].name,
  26. );
  27. }
  28. }
  29. @override
  30. Widget build(BuildContext context) {
  31. return Scaffold(
  32. backgroundColor: Color.fromRGBO(238, 238, 238, 1),
  33. appBar: AppBar(
  34. title: Text('通讯录'),
  35. actions: [
  36. GestureDetector(
  37. onTap: () {
  38. Navigator.of(context).push(MaterialPageRoute(
  39. builder: (BuildContext context) =>
  40. DiscoverChildPage(title: '添加朋友')));
  41. },
  42. child: Container(
  43. padding: EdgeInsets.only(right: 10),
  44. child: Image(
  45. image: AssetImage('images/添加朋友.png'),
  46. width: 32,
  47. ),
  48. ),
  49. )
  50. ],
  51. ),
  52. body: Container(
  53. child: ListView.builder(
  54. itemBuilder: _itemForRow,
  55. itemCount: datas.length + _headerData.length),
  56. ));
  57. }
  58. }

image.png

分组-groupTitle

由由于LIstView没有分组的概念,所以这里添加一个头部视图,根据条件来自动的显示和隐藏来间接达到分组的目的。我们可以使用for循环多添点数据生成的新的数组排序之后再赋值给Cell

  1. // 下面数据源
  2. final List<Friends> _listData = [];
  3. final List<Friends> _headerData = [
  4. Friends(assetImage: 'images/新的朋友.png', name: '新的朋友'),
  5. Friends(assetImage: 'images/群聊.png', name: '群聊'),
  6. Friends(assetImage: 'images/标签.png', name: '标签'),
  7. Friends(assetImage: 'images/公众号.png', name: '公众号'),
  8. ];
  9. @override
  10. void initState() {
  11. // TODO: implement initState
  12. super.initState();
  13. _listData..addAll(datas)..addAll(datas);
  14. _listData.sort((Friends a, Friends b) {
  15. return a.indexLetter!.compareTo(b.indexLetter!);
  16. });
  17. }

这样,就有了一个排好序之后的数据源。我们在cell中的处理_itemForRow:如果首字母相同,那么就没有groupTitle;如果首字母不相同也就是要分组啦,就展示这个groupTitle

  1. Widget _itemForRow(BuildContext context, int index) {
  2. if (index < _headerData.length) {
  3. return _FriendCell(
  4. assetImage: _headerData[index].assetImage,
  5. name: _headerData[index].name);
  6. } else {
  7. bool _hiddenGroupTitle = index - 4 > 0 &&
  8. _listData[index - 4].indexLetter == _listData[index - 5].indexLetter;
  9. return _FriendCell(
  10. imageUrl: _listData[index - 4].imageUrl,
  11. name: _listData[index - 4].name,
  12. groupTitle: _hiddenGroupTitle ? null : _listData[index - 4].indexLetter,
  13. );
  14. }
  15. }

那么此时就要修改_FriendCell的布局啦,不能使用Row需要使用Column了,我们修改下吧,在Column的Children中新增一个头部视图,这个头部视图的高度由groupTitle的值来控制。

  1. Container(
  2. padding: EdgeInsets.only(left: 10),
  3. alignment: Alignment.centerLeft,
  4. height: groupTitle == null ? 0 : 20,
  5. color: Color.fromRGBO(238, 238, 238, 1),
  6. child: groupTitle == null
  7. ? null
  8. : Text(
  9. groupTitle!,
  10. style: TextStyle(fontSize: 17, color: Colors.grey),
  11. ),
  12. ),

image.png

索引条

右边的索引条是固定在屏幕的右边,所以此时要使用Stack布局,第一个是ListView,第二个是索引条。这里使用Positioned布局,设置好上右边距和高度以及宽度即可

  1. Positioned(
  2. child: Column(
  3. children: _widgetData,
  4. ),
  5. right: 0,
  6. top: screenHeight(context) / 8,
  7. height: screenHeight(context) / 2,
  8. width: 30,
  9. )

这里的_widgetData是一个Widget的数组,数据也是来自于本地

  1. const INDEX_WORDS = [
  2. '🔍',
  3. '☆',
  4. 'A',
  5. 'B',
  6. 'C',
  7. 'D',
  8. 'E',
  9. 'F',
  10. 'G',
  11. 'H',
  12. 'I',
  13. 'J',
  14. 'K',
  15. 'L',
  16. 'M',
  17. 'N',
  18. 'O',
  19. 'P',
  20. 'Q',
  21. 'R',
  22. 'S',
  23. 'T',
  24. 'U',
  25. 'V',
  26. 'W',
  27. 'X',
  28. 'Y',
  29. 'Z'
  30. ];

然后在initState()方法里面for循环创建Text组件,文字使用INDEX_WORDS[i],注意这里的在Column的结构上占满,所以此时创建Text的时候使用Expanded包装一层
image.png

索引条添加事件

顾名思义,这里肯定是要用到GestureDetector这个类,在DragDown的时候索引条的背景变黑,文字变成白色,在DragEnd的时候恢复如初。考虑到这个导航条会比较复杂建议直接抽取一个index_bar文件
首先声明两个记录当前颜色的值

  1. Color _backColor = Color.fromRGBO(1, 1, 1, 0);
  2. Color _textColor = Colors.grey;

接着在手势拖拽状态发生改变的时候,修改这两个值的颜色,同时把当前的值赋值给Widget

  1. GestureDetector(
  2. onVerticalDragDown: (DragDownDetails details) {
  3. setState(() {
  4. _backColor = Color.fromRGBO(1, 1, 1, 0.5);
  5. _textColor = Colors.white;
  6. });
  7. },
  8. onVerticalDragEnd: (DragEndDetails details) {
  9. setState(() {
  10. _backColor = Color.fromRGBO(1, 1, 1, 0);
  11. _textColor = Colors.grey;
  12. });
  13. },
  14. onVerticalDragUpdate: (DragUpdateDetails details) {
  15. String str = getIndexWord(context, details);
  16. print('选中的是' + str);
  17. },
  18. child: Container(
  19. child: Column(children: _widgetData),
  20. color: _backColor, // 背景颜色赋值
  21. ),
  22. ),

要想把_textColor实时的赋值给当前的Text,那么此时Text的初始化就要放到Build方法里面,而不是initState这里了。这里重点介绍下找到当前点击的Index,可以通过计算偏移量/每个字符的高度来拿到。 完整代码如下:

  1. import 'package:flutter/material.dart';
  2. import 'const_data.dart';
  3. import 'friend_data.dart';
  4. class IndexBar extends StatefulWidget {
  5. @override
  6. _IndexBarState createState() => _IndexBarState();
  7. }
  8. class _IndexBarState extends State<IndexBar> {
  9. Color _backColor = Color.fromRGBO(1, 1, 1, 0);
  10. Color _textColor = Colors.grey;
  11. @override
  12. void initState() {
  13. // TODO: implement initState
  14. super.initState();
  15. }
  16. @override
  17. Widget build(BuildContext context) {
  18. final List<Widget> _widgetData = [];
  19. for (int i = 0; i < INDEX_WORDS.length; i++) {
  20. _widgetData.add(Expanded(
  21. child: Text(INDEX_WORDS[i],
  22. style: TextStyle(fontSize: 10, color: _textColor))));
  23. }
  24. return Positioned(
  25. right: 0,
  26. top: screenHeight(context) / 8,
  27. height: screenHeight(context) / 2,
  28. width: 30,
  29. child: GestureDetector(
  30. onVerticalDragDown: (DragDownDetails details) {
  31. setState(() {
  32. _backColor = Color.fromRGBO(1, 1, 1, 0.5);
  33. _textColor = Colors.white;
  34. });
  35. },
  36. onVerticalDragEnd: (DragEndDetails details) {
  37. setState(() {
  38. _backColor = Color.fromRGBO(1, 1, 1, 0);
  39. _textColor = Colors.grey;
  40. });
  41. },
  42. onVerticalDragUpdate: (DragUpdateDetails details) {
  43. String str = getIndexWord(context, details);
  44. print('选中的是' + str);
  45. },
  46. child: Container(
  47. child: Column(children: _widgetData),
  48. color: _backColor,
  49. ),
  50. ),
  51. );
  52. }
  53. }
  54. String getIndexWord(BuildContext context, DragUpdateDetails details) {
  55. // 找到当前渲染对象
  56. RenderBox box = context.findRenderObject() as RenderBox;
  57. // offset,globalToLocal当前位置距离父视图的偏移
  58. Offset y = box.globalToLocal(details.globalPosition);
  59. // 算出字符高度
  60. var itemHeight = screenHeight(context) / 2 / INDEX_WORDS.length;
  61. // 算出第几个item ~/代表取整 clamp函数给定最大和最小值
  62. int index = (y.dy ~/ itemHeight).clamp(0, INDEX_WORDS.length - 1);
  63. return INDEX_WORDS[index];
  64. }

image.png
image.png