Flutter实战-搭建微信项目(四)

3,163 阅读5分钟

「这是我参与11月更文挑战的第4天,活动详情查看:2021最后一次更文挑战

最终效果

image.png

AppBar上的actions

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

class _FriendPageState extends State<FriendPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('通讯录'),
        actions: [
          GestureDetector(
            onTap: () {
              Navigator.of(context).push(MaterialPageRoute(
                  builder: (BuildContext context) =>
                      DiscoverChildPage(title: '添加朋友')));
            },
            child: Container(
              padding: EdgeInsets.only(right: 10),
              child: Image(
                image: AssetImage('images/添加朋友.png'),
                width: 32,
              ),
            ),
          )
        ],
      ),
      body: Center(
        child: Text('通讯录页面'),
      ),
    );
  }
}

image.png

点击右上角的添加按钮

image.png

itemBuilder

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

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

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

class _FriendCell extends StatelessWidget {
  final String? imageUrl;
  final String? assetImage;
  final String? name;
  final String? groupTitle;
  _FriendCell({this.imageUrl, this.assetImage, this.name, this.groupTitle});
  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: Row(
        children: [
          Container(
              margin: EdgeInsets.all(10),
              width: 34,
              height: 34,
              decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(6),
                  image: DecorationImage(
                      image: assetImage == null
                          ? NetworkImage(imageUrl!)
                          : AssetImage(assetImage!) as ImageProvider))),
          Container(
            child: Text(
              name!,
              style: TextStyle(fontSize: 18),
            ),
          )
        ],
      ),
    );
  }
}

数据-模型

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

class Friends {
  final String? imageUrl;
  final String? assetImage;
  final String? name;
  final String? indexLetter;
  Friends({this.imageUrl, this.assetImage, 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'),
];

ListView

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

import 'package:flutter/material.dart';
import 'package:flutter/src/painting/image_provider.dart';
import 'discover_child_page.dart';
import 'friend_data.dart';

class FriendPage extends StatefulWidget {
  const FriendPage({Key? key}) : super(key: key);

  @override
  _FriendPageState createState() => _FriendPageState();
}

class _FriendPageState extends State<FriendPage> {
  final List<Friends> _headerData = [
    Friends(assetImage: 'images/新的朋友.png', name: '新的朋友'),
    Friends(assetImage: 'images/群聊.png', name: '群聊'),
    Friends(assetImage: 'images/标签.png', name: '标签'),
    Friends(assetImage: 'images/公众号.png', name: '公众号'),
  ];

  Widget _itemForRow(BuildContext context, int index) {
    if (index < _headerData.length) {
      return _FriendCell(
          assetImage: _headerData[index].assetImage,
          name: _headerData[index].name);
    } else {
      return _FriendCell(
        imageUrl: datas[index - 4].imageUrl,
        name: datas[index - 4].name,
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: Color.fromRGBO(238, 238, 238, 1),
        appBar: AppBar(
          title: Text('通讯录'),
          actions: [
            GestureDetector(
              onTap: () {
                Navigator.of(context).push(MaterialPageRoute(
                    builder: (BuildContext context) =>
                        DiscoverChildPage(title: '添加朋友')));
              },
              child: Container(
                padding: EdgeInsets.only(right: 10),
                child: Image(
                  image: AssetImage('images/添加朋友.png'),
                  width: 32,
                ),
              ),
            )
          ],
        ),
        body: Container(
          child: ListView.builder(
              itemBuilder: _itemForRow,
              itemCount: datas.length + _headerData.length),
        ));
  }
}

image.png

分组-groupTitle

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

  // 下面数据源
  final List<Friends> _listData = [];
  final List<Friends> _headerData = [
    Friends(assetImage: 'images/新的朋友.png', name: '新的朋友'),
    Friends(assetImage: 'images/群聊.png', name: '群聊'),
    Friends(assetImage: 'images/标签.png', name: '标签'),
    Friends(assetImage: 'images/公众号.png', name: '公众号'),
  ];

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    _listData..addAll(datas)..addAll(datas);
    _listData.sort((Friends a, Friends b) {
      return a.indexLetter!.compareTo(b.indexLetter!);
    });
  }

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

  Widget _itemForRow(BuildContext context, int index) {
    if (index < _headerData.length) {
      return _FriendCell(
          assetImage: _headerData[index].assetImage,
          name: _headerData[index].name);
    } else {
      bool _hiddenGroupTitle = index - 4 > 0 &&
          _listData[index - 4].indexLetter == _listData[index - 5].indexLetter;
      return _FriendCell(
        imageUrl: _listData[index - 4].imageUrl,
        name: _listData[index - 4].name,
        groupTitle: _hiddenGroupTitle ? null : _listData[index - 4].indexLetter,
      );
    }
  }

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

Container(
  padding: EdgeInsets.only(left: 10),
  alignment: Alignment.centerLeft,
  height: groupTitle == null ? 0 : 20,
  color: Color.fromRGBO(238, 238, 238, 1),
  child: groupTitle == null
           ? null
           : Text(
                groupTitle!,
                style: TextStyle(fontSize: 17, color: Colors.grey),
             ),
 ),

image.png

索引条

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

Positioned(
   child: Column(
   	 children: _widgetData,
   ),
   right: 0,
   top: screenHeight(context) / 8,
   height: screenHeight(context) / 2,
   width: 30,
)

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


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'
];

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

image.png

索引条添加事件

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

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

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

GestureDetector(
        onVerticalDragDown: (DragDownDetails details) {
          setState(() {
            _backColor = Color.fromRGBO(1, 1, 1, 0.5);
            _textColor = Colors.white;
          });
        },
        onVerticalDragEnd: (DragEndDetails details) {
          setState(() {
            _backColor = Color.fromRGBO(1, 1, 1, 0);
            _textColor = Colors.grey;
          });
        },
        onVerticalDragUpdate: (DragUpdateDetails details) {
          String str = getIndexWord(context, details);
          print('选中的是' + str);
        },
        child: Container(
          child: Column(children: _widgetData),
          color: _backColor, // 背景颜色赋值
        ),
      ),

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

import 'package:flutter/material.dart';

import 'const_data.dart';
import 'friend_data.dart';

class IndexBar extends StatefulWidget {
  @override
  _IndexBarState createState() => _IndexBarState();
}

class _IndexBarState extends State<IndexBar> {
  Color _backColor = Color.fromRGBO(1, 1, 1, 0);
  Color _textColor = Colors.grey;
  @override
  void initState() {
    // TODO: implement initState
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    final List<Widget> _widgetData = [];
    for (int i = 0; i < INDEX_WORDS.length; i++) {
      _widgetData.add(Expanded(
          child: Text(INDEX_WORDS[i],
              style: TextStyle(fontSize: 10, color: _textColor))));
    }
    return Positioned(
      right: 0,
      top: screenHeight(context) / 8,
      height: screenHeight(context) / 2,
      width: 30,
      child: GestureDetector(
        onVerticalDragDown: (DragDownDetails details) {
          setState(() {
            _backColor = Color.fromRGBO(1, 1, 1, 0.5);
            _textColor = Colors.white;
          });
        },
        onVerticalDragEnd: (DragEndDetails details) {
          setState(() {
            _backColor = Color.fromRGBO(1, 1, 1, 0);
            _textColor = Colors.grey;
          });
        },
        onVerticalDragUpdate: (DragUpdateDetails details) {
          String str = getIndexWord(context, details);
          print('选中的是' + str);
        },
        child: Container(
          child: Column(children: _widgetData),
          color: _backColor,
        ),
      ),
    );
  }
}

String getIndexWord(BuildContext context, DragUpdateDetails details) {
  // 找到当前渲染对象 
  RenderBox box = context.findRenderObject() as RenderBox;
  // offset,globalToLocal当前位置距离父视图的偏移
  Offset y = box.globalToLocal(details.globalPosition);
  // 算出字符高度
  var itemHeight = screenHeight(context) / 2 / INDEX_WORDS.length;
  // 算出第几个item  ~/代表取整   clamp函数给定最大和最小值 
  int index = (y.dy ~/ itemHeight).clamp(0, INDEX_WORDS.length - 1);
  return INDEX_WORDS[index];
}

image.png

image.png

最后贴上资料和代码的地址:

二维码.png