11、Flutter - 项目实战 - 仿微信(五)通讯录

Flutter - 项目实战 - 仿微信(五)通讯录

 

接上篇:10、Flutter - 项目实战 - 仿微信(四)数据准备

 

详细代码参见Demo

Demo地址 -> wechat_demo

 

其他相关联文章

7、Flutter - 项目实战 - 仿微信(一)BottomNavigationBar 4个主页面显示

8、Flutter - 项目实战 - 仿微信(二)发现页面

9、Flutter - 项目实战 - 仿微信(三)我的页面

10、Flutter - 项目实战 - 仿微信(四)数据准备

11、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,
  };

 

以上代码如有错误还望指正,共同进步

 

 

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值