likes
comments
collection
share

flutter-仿微信项目实战三(联系人、索引)

作者站长头像
站长
· 阅读数 64

前言

这篇文章功能性稍微多一些,主要介绍了联系人列表索引功能

联系人列表采用非SectionList的方式巧妙实现,毕竟系统没提供,另外介绍右侧索引条,且保证能和左侧联系人形成互动

源码地址

ps: 可以根据需要自己封装一个 SectionList

如下图所示,主要分了四个关键单,联系人列表默认item、联系人列表、索引、导航添加好友

flutter-仿微信项目实战三(联系人、索引)

联系人功能,主要封装了两个控件,ContactIndexs为索引功能,ContactCell为列表的 item,且了处理Section问题

导航添加好友

AppBar的左侧定制属性为leading,设置单个组件,而右侧为 actions,可设置多个组件,这里简单在重复一下导航跳转,如下所示

Scaffold(
  appBar: AppBar(
    title: const Text("联系人"),
    foregroundColor: Colors.black,
    backgroundColor: const Color.fromRGBO(0xe1, 0xe1, 0xe1, 1),
    elevation: 0, //去掉阴影
    actions: [
      TextButton(
        onPressed: () {
          //跳转到下一个页面
          Navigator.of(context).push(MaterialPageRoute(builder: 
              (BuildContext context) => const AddFriend(title: "添加朋友")));
          //如果要主动跳转回来
          //Navigator.pop(context);
        },
        child: Image.asset(
          "images/添加朋友.png",
          width: 28,
          height: 28,
        ),
      ),
    ],
  ),
  //这是是联系人列表实际功能
  body: Stack(
    children: [
      ListView.builder(
        //这个controller用于 ListView滚动到指定位置
        controller: _controller,
        itemBuilder: itemForRow,
        itemCount: headerItems.length + friends.length,
      ),
    ],
  ),
);

联系人列表

联系人列表主要包括列表固定头部动态内容索引后面介绍

固定头部:这里先生成数据,使用动态内容的 item 数据结构,只不过只有默认的图片和名字参数

headerItems = [
  Friends(imageUrl: 'images/新的朋友.png', name: "新的朋友"),
  Friends(imageUrl: 'images/群聊.png', name: "群聊"),
  Friends(imageUrl: 'images/标签.png', name: "标签"),
  Friends(imageUrl: 'images/公众号.png', name: "公众号"),
];

动态内容:这是将获取的数据,按照字母letter进行排序即可,这里不多介绍

friends.sort((a, b) => a.letter.compareTo(b.letter));

联系人列表将头部和尾部合并成一个,数量两个数组集合,在显示内容row里面分离两个

ListView.builder(
    controller: _controller,
    itemBuilder: itemForRow,
    itemCount: headerItems.length + friends.length,
),

row 实现如下所示

//设置Row,这里通过巧妙的方式设置section效果
//只有一种带标题的cell,如果letter字母和上一个一样,就不显示标题,否则显示
//这样一种cell就可以表示全部内容
Widget itemForRow(BuildContext context, int index) {
  if (index < 4) {
    //前四个固定标题的处理
    final item = headerItems[index];
    return ContactCell(imageUrl: item.imageUrl, text: item.name);
  }else {
    //后面列表的处理,由于使用的不是一个数组,需要重新调整索引,减去固定标题长度
    final item = friends[index - 4];
    //动态列表第一个,一定有标题
    if (index == 4) {
      return ContactCell(imageUrl: item.imageUrl, text: item.name, isShowLetter: true, letter: item.letter);
    }else {
      //后面的都和上面一个比较,一样就不显示标题
      final lastItem = friends[index -  5];
      return ContactCell(imageUrl: item.imageUrl, text: item.name, isShowLetter: item.letter != lastItem.letter, letter: item.letter);
    }
  }
}

ContactCell的逻辑如下所示,主要是在基础cell的基础上添加了一个标题头,根据参数控制是否显示,参数由外部控制,letter和上一个一样就不显示标题,否则显示

 Column(
  children: [
    //头部 letter,采用三目运算符控制顶部标题显示,如果letter和上一个一样就不显示
    widget.isShowLetter ?
    Container(
      height: 28,
      color: const Color.fromRGBO(0xe1, 0xe1, 0xe1, 1),
      padding: const EdgeInsets.only(left: 16),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.start,
        children: [
          Text(widget.letter),
          widget.letter == '☆' ? Row(
            children: const [
              SizedBox(width: 4),
              Text('星标朋友',
                style: TextStyle(
                  fontSize: 12
                )
              )
            ]
          ) : Container()
        ]
      )
    ) : Container(),
    
    //内部item 头像和文字,和发现页类似,逻辑较为简单,省略
    ...Container(),
    
    下划线
    ...line
  ],
)

索引条

所以条,就是字母letter集合,将他们纵向布局到右侧即可,然后才是气泡跳转指定位置

通信录页面使用 Stack 如下所示,然后包裹了索引条组件 ContactIndexs

Stack(
  children: [
    ListView.builder(
      controller: _controller,
      itemBuilder: itemForRow,
      itemCount: headerItems.length + friends.length,
    ),
    ContactIndexs(
      onUpdateCallback: (String letter, int index) {
        print(letter);
        jumpToLetter(letter);
      },
      letterList: letters,
    ),
  ],
),

索引的部分实现如下所示,先看看索引条,我们前面使用了 Stack 布局,就是为了此时,直接使用Positioned布局将内容放到最右侧

//设置控件相对父布局Stack位置,局右侧,高度拉满
//距离右、上、下距离为零,因此左侧位置根据内容拉伸
Positioned(
  top: 0,
  right: 0,
  bottom: 0,
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      //左边气泡,采用alignment的方式调整位置
      //直接使用 Stack + Position 索引的方式更简单,使用计算好的点击的letter坐标,减去顶部差即可
      //这里挑战一下更复杂的,采用alignment的方式,按照百分比调整
      Container(
        //通过alignment来调整位置
        alignment: Alignment(-0.15, bubbleAligmentY),
        //通过索引更新气泡显隐
        child: lastIndex > -1
          ? Stack(
              alignment: const Alignment(-0.15, 0),
              children: [
                Image.asset(
                  'images/气泡.png',
                  width: 60,
                  height: 60,
                ),
                Text(
                  widget.letterList[lastIndex],
                  style: const TextStyle(fontSize: 18),
                )
              ],
            )
          : null,
      ),
      
      //右边索引
      //通过拖拽手势处理,当按下或者移动的时候,需要显示气泡,且更新外部联系人位置
      //一次需要通过拖拽点击的位置,进行计算
      GestureDetector(
        onVerticalDragUpdate: (DragUpdateDetails details) {
          //拖拽更新,pan实际走的也是这个,不信看看参数😂
          //世界坐标details.globalPosition
          //本地坐标details.localPosition
          onUpdate(details.localPosition.dy);
        },
        onVerticalDragStart: (DragStartDetails details) {
          //拖拽点击时
          onUpdate(details.localPosition.dy);
        },
        onVerticalDragEnd: (DragEndDetails details) {
          //拖拽结束
          lastIndex = -1;
          setState(() {});
        },
        //处理点击的
        onTapDown: (TapDownDetails details) {
          onUpdate(details.localPosition.dy);
        },
        //取消手势,取消后,更新索引,同时可以用索引更新气泡显隐
        onTapCancel: () {
          lastIndex = -1;
          setState(() {});
        },
        //点击抬起手势
        onTapUp: (TapUpDetails details) {
          lastIndex = -1;
          setState(() {});
        },
        //放置字母集合
        child: SizedBox(
          width: 20,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: words,
          ),
        ),
      ),
    ],
  ),
);

words 直接生成一个字母组件集合,以便于展开到快速展开到索引中,只要设置好每个字符高度即可,可以更方便计算,配合父组件的Column默认次轴居中,很容易就实现效果了

//也可以使用 .map来直接返回
List<Widget> words = [];
for (String item in widget.letterList) {
  final wordWidget = SizedBox(
      height: 14, child: Text(item, style: const TextStyle(fontSize: 10)));
  words.add(wordWidget);
}

这里是索引位置计算的核心逻辑,不仅计算出了点击到的索引是哪一个,还计算出了对应的 alignment百分比

另外气泡使用的是 alignment,需要先普及一下, Alignment属性分别是 x、y,值域均为-1~1之间,当一个值为0的时候,默认居中,默认布局中(reverse的正好相反),-1表示上、左,1表示下、右

int lastIndex = -1; //用于保存上一次点击的letter索引,避免连续回调
double bubbleAligmentY = 0; //用于更新气泡的align

//移动点击更新,可以控制气泡,点击了哪个字母
onUpdate(double dy) {
  //计算点击了哪一个,可以返回给外面,每一个高度是14
  final length = widget.letterList.length;
  // final height = MediaQuery.of(context).size.height; 屏幕高度
  final height = context.size!.height; //组件高度
  //获取内容高度
  const letterHeight = 14;
  final allLetterHeight = length * letterHeight;
  //获取第一个索引的起始y坐标
  final startY = (height - allLetterHeight) / 2;

  //由于计算嗾使是以父节点的空间来响应手势,起始坐标在最上方,之前计算好了第一个的位置
  var index = (dy - startY) ~/ letterHeight; //~/取模,即向下取整
  // final index = (length / letterHeight).floor(); //或者这个也行
  //由于往上滑或者往下,会出现越界,处理一下越界问题
  index = index.clamp(0, length - 1); //可以通过 clamp 方法处理数字越界问题
  
  //如果和上一个不一样,直接回调字母,和更新的索引
  if (index != lastIndex) {
    widget.onUpdateCallback(widget.letterList[index], index);
  }
  lastIndex = index;

  //计算气泡,内部使用,还没计算
  //之前已经计算出了start,用position的方式将会更简单,这里我们挑战使用alignment,计算百分比
  //第一个字符中间y坐标
  final firstLetterCy = startY + letterHeight / 2;
  //气泡与第一个字符中心点对齐需要移动坐标
  final halfHeight = height / 2;
  //由于中心点是0,上下是-1,所以计算出索引,要用halfHeight才是相当于中心点的坐标
  //而物体总移动,还需要抛去自己一半的高度(顶部对齐-1,中心对齐0),所以少移动了半个物体的高度
  final radioY =
      (halfHeight - firstLetterCy - index * letterHeight) / (halfHeight - 30);
  
  setState(() {
    bubbleAligmentY = -radioY;
  });
}

到这里索引条的功能基本完毕了,剩下的就是索引定位到指定的位置,这就和索引条功能无关了

索引条和联系人互动

前面索引条已经定位到点击到的letter,现在通过点击的letter定位到指定的字母标题的位置

如下所示,所以条给了一个回调,返回的点击的字母和索引

//作为代理,用于更新ListView跳转位置
final ScrollController _controller = ScrollController();

Stack(
  children: [
    ListView.builder(
      //代理
      controller: _controller,
      itemBuilder: itemForRow,
      itemCount: headerItems.length + friends.length,
    ),
    ContactIndexs(
      onUpdateCallback: (String letter, int index) {
        print(letter);
        jumpToLetter(letter);
      },
      letterList: letters,
    ),
  ],
)

跳到指定位置,一共三个属性,偏移量、动画时间、过渡动画曲线

jumpToLetter(String letter) {
 
  _controller.animateTo(
      lettersLocationMap[letter]!, 
      duration: const Duration(microseconds: 400), 
      curve: Curves.easeIn);
}

偏移量的计算如下所示

已知:单个item的高度、顶部固定item的数量、标题的item位置,计算指定标题item的偏移

1、指定标题item偏移 = 顶部固定item高度 + 上一个组总高度,然后分解为步骤2

2、指定标题item偏移: 第一组则偏移量 = 顶部固定高度 ; 后续偏移量 = 上一组偏移 + 上一组总高度(上一组数量 * index + 标题高度)

//用于保存letter对应的偏移量
Map<String, double> lettersLocationMap = {};

//更新letter的同时,更新偏移量
double offsetY = 47 * 4; //顶部的四个
var offsetIndex = 0; //默认偏移
var isFirst = true;
for (final item in friends) {
  if (!letters.contains(item.letter)) {
    letters.add(item.letter);
    //第一组的偏移
    if (isFirst) {
      offsetY += offsetIndex * 47; //更新offsetY
      isFirst = false;
    }else {
      //后续偏移
      offsetY += (offsetIndex * 47 + 28);
    }
    lettersLocationMap[item.letter] = offsetY;
    offsetIndex = 0;
  }
  offsetIndex++;
}

实际数据量小的话,也可以在移动的时候再计算偏移量也不迟

最后

每次的编写与挑战,都会让我们对组件更加理解,使用起来更加得心应手,快来试试吧

转载自:https://juejin.cn/post/7087422670886666277
评论
请登录