前语

这篇文章功用性略微多一些,首要介绍了联系人列表索引功用

联系人列表选用非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++;
}

实践数据量小的话,也能够在移动的时分再核算偏移量也不迟

最终

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