#Flutter项目(3)之仿写微信通讯录界面
1 导航栏按钮的添加
导航栏 appBar 使用AppBar()方法创建;主要用到的控件属性如下:
- title:导航栏标题
/// The primary widget displayed in the app bar.
///
/// Typically a [Text] widget containing a description of the current contents
/// of the app.
final Widget title;
注意:title需要返回的是一个widget, Typically a [Text],一般情况下是一个文本,也可以是一个图片,也可以是一个自定义的widget视图控件;
- leading:导航栏标题前按钮;即左边的按钮栏,返回的是一个Widget控件;
/// A widget to display before the [title].
AppBar(
leading: Builder(
builder: (BuildContext context) {
return IconButton(
icon: const Icon(Icons.menu),
onPressed: () { Scaffold.of(context).openDrawer(); },
tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
);
},
),
)
/// {@end-tool}
///
/// The [Builder] is used in this example to ensure that the `context` refers
/// to that part of the subtree. That way this code snippet can be used even
/// inside the very code that is creating the [Scaffold] (in which case,
/// without the [Builder], the `context` wouldn't be able to see the
/// [Scaffold], since it would refer to an ancestor of that widget).
///
/// See also:
///
/// * [Scaffold.appBar], in which an [AppBar] is usually placed.
/// * [Scaffold.drawer], in which the [Drawer] is usually placed.
final Widget leading;
- actions:导航栏标题后按钮;即右边的按钮栏,返回的是一个List<Widget>集合;
/// Widgets to display after the [title] widget.
///
/// Typically these widgets are [IconButton]s representing common operations.
/// For less common operations, consider using a [PopupMenuButton] as the
/// last action.
final List<Widget> actions;
示例实现:
appBar: AppBar(
backgroundColor: WechatThemeColor,
title: Text('通讯录'),//标题
leading://左按钮
GestureDetector(
child: Container(
margin: EdgeInsets.only(left: 15,top: 15),
child:Text(
'更多',
style: TextStyle(
fontSize: 20,
),
textAlign: TextAlign.center,
)
),
onTap: () {
Navigator.of(context)
.push(MaterialPageRoute(builder: (BuildContext context) {
return SubDiscover_Page(
title: '添加好友',
);
}));
},
),
actions: <Widget>[ //右按钮
GestureDetector(
child: Container(
margin: EdgeInsets.only(right: 15),
child: Image(
image: AssetImage('images/icon_friends_add.png'),
width: 25,
),
),
onTap: () {
Navigator.of(context)
.push(MaterialPageRoute(builder: (BuildContext context) {
return SubDiscover_Page(
title: '添加好友',
);
}));
},
),
],
),
2 通讯录列表及分组实现
2.1 通讯录数据的处理
对于每一个用户模型,需要一个属性值indexLetter来存储首字母信息,通过对这个属性值的排序来确定分组,这是按照微信分组的基本思路:
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/57.jpg',
name: 'Lina',
indexLetter: 'L', //用于好友分组
message: 'hello YYFast !',
time: '下午 3:45',
),
通过indexLetter属性值,对数组内的元素进行排序:
_ListDatas.sort((Friends a, Friends b) {
return a.indexLetter.compareTo(b.indexLetter);
});
//简写:(当花括号里面只有一句代码的时候可以简写:)
_ListDatas.sort((Friends a, Friends b) =>
a.indexLetter.compareTo(b.indexLetter);
);
//使用sort函数对数组进行排序的用法,当数组元素全部为int类型的时候直接使用sort函数即可:
/**
* Sorts this list according to the order specified by the [compare] function.
*
* The [compare] function must act as a [Comparator].
*
* List<String> numbers = ['two', 'three', 'four'];
* // Sort from shortest to longest.
* numbers.sort((a, b) => a.length.compareTo(b.length));
* print(numbers); // [two, four, three]
*
* The default List implementations use [Comparable.compare] if
* [compare] is omitted.
*
* List<int> nums = [13, 2, -11];
* nums.sort();
* print(nums); // [-11, 2, 13]
*
* A [Comparator] may compare objects as equal (return zero), even if they
* are distinct objects.
* The sort function is not guaranteed to be stable, so distinct objects
* that compare as equal may occur in any order in the result:
*
* List<String> numbers = ['one', 'two', 'three', 'four'];
* numbers.sort((a, b) => a.length.compareTo(b.length));
* print(numbers); // [one, two, four, three] OR [two, one, four, three]
*/
2.2 通讯录分组表头的展示实现
通讯录组头的展示逻辑,在创建ListView返回cell的时候:
- 如果当前cell和上一个cell的indexLetter值相同,也就是同一个分组,则当前cell不展示头部;
- 如果当前cell和上一个cell的indexLetter值不相同,也就是新分组,则当前cell展示头部;
Widget _CellForRow(BuildContext context, int index) {
//前4个分组为微信固定的 新的朋友,群聊,标签,公众号4个cell
if (index < header_datas.length) {
return _FriendsCell(
assertImage: header_datas[index].assertImage,
name: header_datas[index].name,
);
}
// 当indexLetter值相同的时候,创建cell,使用_FriendsCell方法不传入groupTitle值,使得当前cell不展示头部;
if (index > 4 &&
_ListDatas[index - 4].indexLetter ==
_ListDatas[index - 5].indexLetter) {
return _FriendsCell(
imageUrl: _ListDatas[index - 4].imageUrl,
name: _ListDatas[index - 4].name,
);
}
// 当indexLetter值不相同的时候,创建cell,使用_FriendsCell方法传入groupTitle值,使得当前cell展示头部;
return _FriendsCell(
imageUrl: _ListDatas[index - 4].imageUrl,
name: _ListDatas[index - 4].name,
groupTitle: _ListDatas[index - 4].indexLetter,
);
}
3 左边按钮栏IndexBar实现
左边indexBar示例IndexBar需要实现的效果:点击其中的一个字母,通讯录跳转到指定的分组
3.1 IndexBar的封装
indexBar是一个单独的控件,可以使用一个dart文件将其封装起来:
- 需要一个数组来将A-Z字母装起来,这个控件其实一个个小widget上面放Text就可以实现;
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'
];
- 这些控件是可以点击的,所以这些widget是有状态的,stateful,可以刷新,改变点击时的状态;
- 封装的这个IndexBar控件需要给外界一个回调,回调到通讯录页面知道点击的是哪个字母,通讯录滚动到哪里;
class IndexBar extends StatefulWidget {
final void Function (String string) indexBarCallBack;
const IndexBar({Key key, this.indexBarCallBack}) : super(key: key);
@override
_IndexBarState createState() => _IndexBarState();
}
int GetIndex(BuildContext context,Offset globalPosition){
RenderBox box = context.findRenderObject();
double y = box.globalToLocal(globalPosition).dy;
//每一个Item的高度
var ItemHeight = ScreenHeignt(context)/2/INDEX_WORDS.length;
//clamp 防止越界
int index = (y ~/ItemHeight).clamp(0, INDEX_WORDS.length - 1);
return index;
print(' index = $index ,${INDEX_WORDS[index]}');
}
class _IndexBarState extends State<IndexBar> {
var _selectedIndex = -1;
Color _IndexBarBackColor = Color.fromRGBO(1, 1, 1, 0.0);
Color _TextColor = Colors.black;
@override
Widget build(BuildContext context) {
List <Widget> _WordsWidget = [];
for(int i = 0; i < INDEX_WORDS.length;i ++){
_WordsWidget.add(Expanded(child: Text(INDEX_WORDS[i],style: TextStyle(color:_TextColor),),));
}
return Positioned(
right: 0.0,
width: 30,
top: ScreenHeignt(context)/8,
height: ScreenHeignt(context)/2,
child: GestureDetector(
child: Container(
color:_IndexBarBackColor,
child: Column(
children: _WordsWidget,
),
),
onVerticalDragUpdate: (DragUpdateDetails details){
if(_selectedIndex != GetIndex(context, details.globalPosition)){
_selectedIndex = GetIndex(context, details.globalPosition);
widget.indexBarCallBack(INDEX_WORDS[_selectedIndex] );
}//重复点击添加容错处理
},
//按下
onVerticalDragDown: (DragDownDetails details){
setState(() {
_IndexBarBackColor = Color.fromRGBO(1, 1, 1, 0.3);
_TextColor = WechatThemeColor;
});
widget.indexBarCallBack(INDEX_WORDS[GetIndex(context, details.globalPosition)] );
},
onVerticalDragEnd: (DragEndDetails details){
setState(() {
_IndexBarBackColor = Color.fromRGBO(1, 1, 1, 0.0);
_TextColor = Colors.black;
});
},
),
);
}
}
_WordsWidget数组中直接添加是Expanded包装的控件,child是一个Text;
然后在IndexBar控件中,返回的是Positioned(自适应控件);
然后使用的是Cloumn上下布局,它的children是一个List<Widget> children;所以可以返回的是一个数组的元素;
需要添加容错处理的地方:
//clamp 防止越界
int index = (y ~/ItemHeight).clamp(0, INDEX_WORDS.length - 1);
// index = 获取当前手势的y方向的偏移量/每个item的高度.clamp(最小值,最大值)
//在dart中相除取整可以使用: ~/
/**
* Returns this [num] clamped to be in the range [lowerLimit]-[upperLimit].
*
* The comparison is done using [compareTo] and therefore takes `-0.0` into
* account. This also implies that [double.nan] is treated as the maximal
* double value.
*
* The arguments [lowerLimit] and [upperLimit] must form a valid range where
* `lowerLimit.compareTo(upperLimit) <= 0`.
*/
num clamp(num lowerLimit, num upperLimit);
-
onVerticalDragUpdate: 按下刷新状态,可以添加容错防止多次重复点击;
-
onVerticalDragDown: 按下时,此时回调callBack到通讯录中滑动到指定的位置;
-
onVerticalDragEnd: 按下状态结束,改变IndexBar的背景颜色,状态等;
4 IndexBar回调,通讯录滑动到指定分组的位置
在自定义封装的IndexBar中,需要在选中某个字母的时候,回调给当前页面选中可某个字母,然后滑动到指定的某个分组的位置;
滑动当前的ListView,需要的一个控制器ScrollController:
/// Controls a scrollable widget.
///
/// Scroll controllers are typically stored as member variables in [State]
/// objects and are reused in each [State.build]. A single scroll controller can
/// be used to control multiple scrollable widgets, but some operations, such
/// as reading the scroll [offset], require the controller to be used with a
/// single scrollable widget.
///
/// A scroll controller creates a [ScrollPosition] to manage the state specific
/// to an individual [Scrollable] widget. To use a custom [ScrollPosition],
/// subclass [ScrollController] and override [createScrollPosition].
///
/// A [ScrollController] is a [Listenable]. It notifies its listeners whenever
/// any of the attached [ScrollPosition]s notify _their_ listeners (i.e.
/// whenever any of them scroll). It does not notify its listeners when the list
/// of attached [ScrollPosition]s changes.
///
/// Typically used with [ListView], [GridView], [CustomScrollView].
///
/// See also:
///
/// * [ListView], [GridView], [CustomScrollView], which can be controlled by a
/// [ScrollController].
/// * [Scrollable], which is the lower-level widget that creates and associates
/// [ScrollPosition] objects with [ScrollController] objects.
/// * [PageController], which is an analogous object for controlling a
/// [PageView].
/// * [ScrollPosition], which manages the scroll offset for an individual
/// scrolling widget.
/// * [ScrollNotification] and [NotificationListener], which can be used to watch
/// the scroll position without using a [ScrollController].
class ScrollController extends ChangeNotifier
ScrollController可以控制[ListView], [GridView], [CustomScrollView]等可以滑动的控件;
用法:
-
创建一个ScrollController实例化对象;
-
在创建ListView的时候,传入一个控制器,传入当前创建的_scrollController;
-
在滑动的首先添加容错callBack返回的字符串是不是为空;然后使用_scrollController.animateTo实现滑动的动画;
ScrollController _scrollController = ScrollController();
body: Stack(
children: <Widget>[
Container(
child: ListView.builder(
controller: _scrollController, //传入已经创建好的ScrollController实例化对象
itemCount: _ListDatas.length + header_datas.length,
itemBuilder: _CellForRow,
),
), //通讯录列表
IndexBar(
indexBarCallBack: (String string) {
print(_groupMap[string]);
if(_groupMap[string]!=null){
_scrollController.animateTo(_groupMap[string],
duration: Duration(milliseconds: 100),
curve: Curves.easeIn);
}
},
),
],
));
4.1 ListView滑动到指定分组位置的算法实现
可以在初始化ListView的时候将组或者元素的位置保存通过数组保存起来;
final Map _groupMap = {
INDEX_WORDS[0]: 0.0,
INDEX_WORDS[1]: 0.0,
};
INDEX_WORDS为存放IndexBar元素A-Z的数组,_groupMap存放的是字母为key,偏移量offset为value;
在使用的时候可以直接通过key也就是字母取出偏移量offset;
算法简单实现:
var _groupOffset = 54.0 * 4;
for (int i = 0; i < _ListDatas.length; i++) {
if(i <1){
//第一个一定是头部
_groupMap.addAll({_ListDatas[i].indexLetter:_groupOffset});
_groupOffset +=84 ;
}else if(_ListDatas[i].indexLetter == _ListDatas[i -1].indexLetter){
//如果没有头
_groupOffset +=54;
}else{
_groupMap.addAll({_ListDatas[i].indexLetter:_groupOffset});
_groupOffset +=84 ;
}
}
print('-----$_groupMap');
- _groupOffset的初始值为54.0 * 4; 这个是微信原有的新的朋友,群聊,标签,公众号4个cell的初始化高度,这个是死的;
- i < 1 ,第一个肯定是有头部的,存放对应的key和value值
- 如果两个的IndexLetter相同,表示是同一个组的元素,不是组头,这时候不需要往字典中存储元素
- 如果两个的IndexLetter不相同,表示当前的字母对应的为组头, 这时候需要往字典中存储元素
- 不是组头元素的时候_groupOffset +=54;
- 是组头元素的时候_groupOffset +=84;(组头的高度是30)。
5 总结
运用Flutter构建微信通讯录界面,实现难度较之前所仿写的微信发现和我的界面难度和复杂度有了较大的提升,通讯录界面主要有一下几大难点:
- 通讯录ListView的组头如何实现,需要如何巧妙的实现
- IndexBar的封装,点击和回调的实现,
- ListView滑动到指定的分组的位置,需要实现算好对应字母的偏移量,并存放到数组中;
页面还有很多需要优化的地方,比如在点击对应字母的分组没有好友的,这时候不需要跳转,这种处理方式不太友好,但是基本的功能是实现了;
通过学习这个页面,发现算法的思路在任何一门语言中都是必备的,有一个好的算法和思想,有助于提高自己的逻辑,让自己的思路更清晰,需要多多积累,步步为营。