Flutter 仿制微信通讯录效果,致效果如下:
有几个技术细节:
- 总体可滑动,少于屏幕长度也可滑动
- 对于数据的处理。昵称 拼音首字母排序,
- 右侧字母导航,点击/滑动;移动到指定位置
- 当点击/滑动 右侧移动到最底部的时候; 防止 回弹现象(值滑动到最下面);
➥总体可滑动,少于屏幕长度也可滑动
主要还是配置 ListView 的 physics 字段
ListView( physics: const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ), controller: _scrollController, children: [], )
➥对于数据的处理。昵称 拼音首字母排序
这个是我通讯录的一个表字段:
主要一个排序处理实在 initState 中的:右侧字母导航栏也是根据 我通讯录数据生成的:
@override void initState() { // TODO: implement initState super.initState(); Map<String, List<Map>> cache = {}; String firstLetter = ''; for (int i = 0; i < widget.mapContact.length; i++) { firstLetter = widget.mapContact[i]['first_letter']; if (cache.containsKey(firstLetter)) { cache[firstLetter]?.add(widget.mapContact[i]); } else { cache[firstLetter] = [widget.mapContact[i]]; } } List<String> keys = cache.keys.toList(); keys.sort((a, b) => (a.compareTo(b))); for (var element in keys) { if (cache[element] != null) { _listContact[element] = cache[element]!; } } }
➥右侧字母导航,点击/滑动;移动到指定位置
右侧的 字母导航栏是根据,通讯录信息生成的:布局采用 Stack -> Positioned 布局,
主要的包裹了一层 GestureDetector,用来监听手势:
Widget _contactIndex() { return Align( alignment: Alignment.centerRight, child: GestureDetector( onVerticalDragDown: _onDragDown, onVerticalDragUpdate: _onDragUpdate, onVerticalDragEnd: _onDragEnd, behavior: HitTestBehavior.opaque, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: _listContact.keys .toList() .asMap() .map( (key, value) => MapEntry( key, Container( height: 45.cale, width: 80.cale, // color: Colors.red, // padding: EdgeInsets.symmetric( // vertical: 5.cale, // horizontal: 20.cale, // ), child: Stack( children: [ if (_currentIndex == key) SizedBox( width: 60.cale, height: 60.cale, child: CustomPaint( painter: PainterLetter( draggingLetter: _draggingLetter, ), ), ), Center( child: Text( value.toUpperCase(), style: AppTextStyle.textStyle_22_000000, ), ) ], ), ), ), ) .values .toList(), ), ), ); }
三个 手势回调函数:
onVerticalDragDown, onVerticalDragUpdate, onVerticalDragEnd,_onDragDown(DragDownDetails details) { int i = details.localPosition.dy ~/ 45.cale; if (i >= 0 && i < _listContact.keys.length) { setState(() { _currentIndex = i; _draggingOffset = details.globalPosition; _draggingLetter = _listContact.keys.toList()[i]; _scrollTo(_listContact.keys.toList()[i]); }); } } _onDragUpdate(DragUpdateDetails details) { int i = details.localPosition.dy ~/ 45.cale; if (i >= 0 && i < _listContact.keys.length) { if (i != _currentIndex) { setState(() { _currentIndex = i; _draggingOffset = details.globalPosition; _draggingLetter = _listContact.keys.toList()[i]; _scrollTo(_listContact.keys.toList()[i]); }); } } } _onDragEnd(DragEndDetails details) { setState(() { _currentIndex = -1; }); }
移动到指定位置:
这里有几个点需要注意下:( cale 是屏幕适配用的)
- 480 是通讯录上面四个 常驻 按钮的高度 红色框
- 60 是通讯录首字母高度 紫色框
- 120 是 通讯录具体人的高度 橙色框
滚动到具体的位置:
为了防止 滚动到最底部出现 反弹的效果;这边加了判断:如果计算高度超过 listView 总高度(_scrollController.position.maxScrollExtent),则移动带 listview 高度度!
_scrollTo(String chat) { double offSet = 480.cale; int index = _listContact.keys.toList().indexOf(chat); if (index > -1) { for (int i = 0; i < index; i++) { offSet += 60.cale; List<Map>? list = _listContact[_listContact.keys.toList()[i]]; if (list != null) { offSet += 120.cale * list.length; } } if (offSet > _scrollController.position.maxScrollExtent) { _scrollController.jumpTo(_scrollController.position.maxScrollExtent); } else { _scrollController.jumpTo(offSet); } } }
➥ 完成代码
对于通讯录模块主要的就是三个文件:
- contact_element.dart: 通讯录中 按字母分割现实的 具体好友
- painter_letter.dart: 拖动时候现实的 椭圆,也可以用图片代替
- tabbar1_contacts.dart 通讯录主要显示
完整代码如下:
contact_element.dart
import 'package:flutter/material.dart'; import 'package:imflutter/wrap/extension/extension.dart'; import '../../const/app_colors.dart'; import '../../const/app_textStyle.dart'; import '../../wrap/widget/app_widget.dart'; class ContactElement extends StatefulWidget { final List<Map> datum; final String keyName; const ContactElement({Key? key, required this.keyName, required this.datum}) : super(key: key); @override State<ContactElement> createState() => _ContactElementState(); } class _ContactElementState extends State<ContactElement> with AutomaticKeepAliveClientMixin { @override Widget build(BuildContext context) { return Container( width: 200.cale, // height: 200.cale, color: Colors.white, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: EdgeInsets.only(left: 30.cale), height: 60.cale, width: double.infinity, color: AppColor.colorEDEDED, child: Align( alignment: Alignment.centerLeft, child: Text( widget.keyName, style: AppTextStyle.textStyle_24_505050, ), ), ), ...widget.datum .asMap() .map( (key, value) => MapEntry( key, Container( padding: EdgeInsets.only(left: 28.cale), height: 120.cale, color: Colors.white, child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(7.cale), child: AppWidget.cachedImage( value['icon'], width: 88.cale, height: 88.cale, ), ), Expanded( child: Container( height: 120.cale, margin: EdgeInsets.only( left: 30.cale, ), padding: EdgeInsets.only( right: 80.cale, ), decoration: BoxDecoration( border: Border( bottom: key == widget.datum.length - 1 ? BorderSide.none : BorderSide( width: 1, color: AppColor.colorEFEFEF), ), ), child: Container( alignment: Alignment.centerLeft, child: Text( value['nick_name'], overflow: TextOverflow.ellipsis, style: AppTextStyle.textStyle_30_000000, ), ), ), ), ], ), ), ), ) .values .toList() ], ), ); } @override // TODO: implement wantKeepAlive bool get wantKeepAlive => true; }
painter_letter.dart
import 'dart:math'; import 'package:flutter/material.dart'; import 'package:imflutter/wrap/extension/extension.dart'; class PainterLetter extends CustomPainter { final String draggingLetter; const PainterLetter({Key? key, required this.draggingLetter}); @override void paint(Canvas canvas, Size size) { // TODO: implement paint // 创建画笔 // final Paint paint = Paint(); // 创建画笔 // paint // ..color = Colors.blue //颜色 // ..strokeWidth = 4 //线宽 // ..style = PaintingStyle.stroke; //模式--线型 // canvas.drawLine(Offset(0, 0), Offset(100, 100), paint); //绘制线 // return; //原点移到左下角 canvas.translate(0, 25.cale); Paint paint = Paint() ..color = Colors.grey ..strokeWidth = 2 ..style = PaintingStyle.fill; Path path = Path(); // 绘制文字 path.lineTo(0, -size.width.cale); // path.conicTo(33, -28, 20, 0, 1); path.arcToPoint(Offset(size.width.cale * 1.2, 0), radius: Radius.circular(size.width.cale * 1.2), largeArc: true, clockwise: true); path.close(); // var bounds = path.getBounds(); canvas.save(); // canvas.translate(-bounds.width / 2.cale, bounds.height / 2.cale); canvas.rotate(pi * 1.2); canvas.drawPath(path, paint); canvas.restore(); // 绘制文字 var textPainter = TextPainter( text: TextSpan( text: draggingLetter.toUpperCase(), style: TextStyle( fontSize: 38.cale, foreground: Paint() ..style = PaintingStyle.fill ..color = Colors.white, )), textAlign: TextAlign.center, textDirection: TextDirection.ltr); textPainter.layout(); canvas.translate(-35.cale, -53.cale); // canvas.translate( // -size.width.cale * 2 - 10.cale, (-size.height / 2).cale - 10.cale); textPainter.paint(canvas, Offset(-25.cale, 37.cale)); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) { // TODO: implement shouldRepaint return true; } }
tabbar1_contacts.dart
import 'package:flutter/material.dart'; import 'package:imflutter/pages/tabbar1/contact_element.dart'; import 'package:imflutter/pages/tabbar1/painter_letter.dart'; import 'package:imflutter/wrap/extension/extension.dart'; import '../../const/app_colors.dart'; import '../../const/app_textStyle.dart'; class TabBar1Contacts extends StatefulWidget { final List<Map> mapContact; const TabBar1Contacts({Key? key, required this.mapContact}) : super(key: key); @override State<TabBar1Contacts> createState() => _TabBar1ContactsState(); } class _TabBar1ContactsState extends State<TabBar1Contacts> { final List<Map> _topOptions = [ {'icon': 'assets/common/ic_new_friend.webp', 'title': '新朋友'}, {'icon': 'assets/common/ic_group.webp', 'title': '群聊'}, {'icon': 'assets/common/ic_tag.webp', 'title': '标签'}, {'icon': 'assets/common/ic_offical.webp', 'title': '公众号'}, ]; final Map<String, List<Map>> _listContact = {}; int _currentIndex = -1; Offset _draggingOffset = const Offset(0, 0); String _draggingLetter = 'd'; final ScrollController _scrollController = ScrollController(); @override void initState() { // TODO: implement initState super.initState(); Map<String, List<Map>> cache = {}; String firstLetter = ''; for (int i = 0; i < widget.mapContact.length; i++) { firstLetter = widget.mapContact[i]['first_letter']; if (cache.containsKey(firstLetter)) { cache[firstLetter]?.add(widget.mapContact[i]); } else { cache[firstLetter] = [widget.mapContact[i]]; } } List<String> keys = cache.keys.toList(); keys.sort((a, b) => (a.compareTo(b))); for (var element in keys) { if (cache[element] != null) { _listContact[element] = cache[element]!; } } } @override Widget build(BuildContext context) { return Stack( children: [ ListView( physics: const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ), controller: _scrollController, children: [ ..._topEle(), ..._listContact.keys .toList() .asMap() .map( (key, value) => MapEntry( key, ContactElement( keyName: _listContact.keys.toList()[key].toUpperCase(), datum: _listContact.values.toList()[key], ), ), ) .values .toList() ], ), Positioned( child: _contactIndex(), ), ], ); } List<Widget> _topEle() { return _topOptions .asMap() .map( (key, value) => MapEntry( key, Container( padding: EdgeInsets.only(left: 28.cale), height: 120.cale, color: Colors.white, child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(7.cale), child: Image.asset( value['icon'], width: 88.cale, height: 88.cale, ), ), Expanded( child: Container( height: 120.cale, margin: EdgeInsets.only(left: 30.cale), decoration: BoxDecoration( border: Border( bottom: key == _topOptions.length - 1 ? BorderSide.none : BorderSide( width: 1, color: AppColor.colorEFEFEF), ), ), child: Align( alignment: Alignment.centerLeft, child: Text( value['title'], style: AppTextStyle.textStyle_30_000000, ), ), ), ), ], ), ), ), ) .values .toList(); } Widget _contactIndex() { return Align( alignment: Alignment.centerRight, child: GestureDetector( onVerticalDragDown: _onDragDown, onVerticalDragUpdate: _onDragUpdate, onVerticalDragEnd: _onDragEnd, behavior: HitTestBehavior.opaque, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: _listContact.keys .toList() .asMap() .map( (key, value) => MapEntry( key, Container( height: 45.cale, width: 80.cale, // color: Colors.red, // padding: EdgeInsets.symmetric( // vertical: 5.cale, // horizontal: 20.cale, // ), child: Stack( children: [ if (_currentIndex == key) SizedBox( width: 60.cale, height: 60.cale, child: CustomPaint( painter: PainterLetter( draggingLetter: _draggingLetter, ), ), ), Center( child: Text( value.toUpperCase(), style: AppTextStyle.textStyle_22_000000, ), ) ], ), ), ), ) .values .toList(), ), ), ); } _onDragDown(DragDownDetails details) { int i = details.localPosition.dy ~/ 45.cale; // print("-----------------details.localPosition:${details.localPosition}"); // print("-----------------_onDragDown:$i"); // print( // "----------------- _listContact.keys.length:${_listContact.keys.length}"); // print( // "----------------- _listContact.keys.length:${_listContact.keys.toList()[i]}"); // if(i< _listContact.keys.length){ // // } if (i >= 0 && i < _listContact.keys.length) { setState(() { _currentIndex = i; _draggingOffset = details.globalPosition; _draggingLetter = _listContact.keys.toList()[i]; _scrollTo(_listContact.keys.toList()[i]); }); } } _onDragUpdate(DragUpdateDetails details) { int i = details.localPosition.dy ~/ 45.cale; if (i >= 0 && i < _listContact.keys.length) { if (i != _currentIndex) { setState(() { _currentIndex = i; _draggingOffset = details.globalPosition; _draggingLetter = _listContact.keys.toList()[i]; _scrollTo(_listContact.keys.toList()[i]); }); } } } _onDragEnd(DragEndDetails details) { setState(() { _currentIndex = -1; }); } _scrollTo(String chat) { double offSet = 480.cale; int index = _listContact.keys.toList().indexOf(chat); if (index > -1) { for (int i = 0; i < index; i++) { offSet += 60.cale; List<Map>? list = _listContact[_listContact.keys.toList()[i]]; if (list != null) { offSet += 120.cale * list.length; } } if (offSet > _scrollController.position.maxScrollExtent) { _scrollController.jumpTo(_scrollController.position.maxScrollExtent); } else { _scrollController.jumpTo(offSet); } } } }