1 導(dǎo)航欄按鈕的添加
導(dǎo)航欄 appBar 使用AppBar()方法創(chuàng)建;主要用到的控件屬性如下:
- title:導(dǎo)航欄標(biāo)題
/// 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:導(dǎo)航欄標(biāo)題前按鈕;即左邊的按鈕欄,返回的是一個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:導(dǎo)航欄標(biāo)題后按鈕;即右邊的按鈕欄,返回的是一個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;
示例實(shí)現(xiàn):
appBar: AppBar(
backgroundColor: WechatThemeColor,
title: Text('通訊錄'),//標(biāo)題
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 通訊錄列表及分組實(shí)現(xiàn)
2.1 通訊錄數(shù)據(jù)的處理
對于每一個用戶模型,需要一個屬性值indexLetter來存儲首字母信息,通過對這個屬性值的排序來確定分組,這是按照微信分組的基本思路:
Friends(
imageUrl: 'https://randomuser.me/api/portraits/women/57.jpg',
name: 'Lina',
indexLetter: 'L', //用于好友分組
message: 'hello YYFast !',
time: '下午 3:45',
),
通過indexLetter屬性值,對數(shù)組內(nèi)的元素進(jìn)行排序:
_ListDatas.sort((Friends a, Friends b) {
return a.indexLetter.compareTo(b.indexLetter);
});
//簡寫:(當(dāng)花括號里面只有一句代碼的時候可以簡寫:)
_ListDatas.sort((Friends a, Friends b) =>
a.indexLetter.compareTo(b.indexLetter);
);
//使用sort函數(shù)對數(shù)組進(jìn)行排序的用法,當(dāng)數(shù)組元素全部為int類型的時候直接使用sort函數(shù)即可:
/**
* 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 通訊錄分組表頭的展示實(shí)現(xiàn)
通訊錄組頭的展示邏輯,在創(chuàng)建ListView返回cell的時候:
- 如果當(dāng)前cell和上一個cell的indexLetter值相同,也就是同一個分組,則當(dāng)前cell不展示頭部;
- 如果當(dāng)前cell和上一個cell的indexLetter值不相同,也就是新分組,則當(dāng)前cell展示頭部;
Widget _CellForRow(BuildContext context, int index) {
//前4個分組為微信固定的 新的朋友,群聊,標(biāo)簽,公眾號4個cell
if (index < header_datas.length) {
return _FriendsCell(
assertImage: header_datas[index].assertImage,
name: header_datas[index].name,
);
}
// 當(dāng)indexLetter值相同的時候,創(chuàng)建cell,使用_FriendsCell方法不傳入groupTitle值,使得當(dāng)前cell不展示頭部;
if (index > 4 &&
_ListDatas[index - 4].indexLetter ==
_ListDatas[index - 5].indexLetter) {
return _FriendsCell(
imageUrl: _ListDatas[index - 4].imageUrl,
name: _ListDatas[index - 4].name,
);
}
// 當(dāng)indexLetter值不相同的時候,創(chuàng)建cell,使用_FriendsCell方法傳入groupTitle值,使得當(dāng)前cell展示頭部;
return _FriendsCell(
imageUrl: _ListDatas[index - 4].imageUrl,
name: _ListDatas[index - 4].name,
groupTitle: _ListDatas[index - 4].indexLetter,
);
}
3 左邊按鈕欄IndexBar實(shí)現(xiàn)

IndexBar需要實(shí)現(xiàn)的效果:點(diǎn)擊其中的一個字母,通訊錄跳轉(zhuǎn)到指定的分組
3.1 IndexBar的封裝
indexBar是一個單獨(dú)的控件,可以使用一個dart文件將其封裝起來:
- 需要一個數(shù)組來將A-Z字母裝起來,這個控件其實(shí)一個個小widget上面放Text就可以實(shí)現(xiàn);
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'
];
- 這些控件是可以點(diǎn)擊的,所以這些widget是有狀態(tài)的,stateful,可以刷新,改變點(diǎn)擊時的狀態(tài);
- 封裝的這個IndexBar控件需要給外界一個回調(diào),回調(diào)到通訊錄頁面知道點(diǎn)擊的是哪個字母,通訊錄滾動到哪里;
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] );
}//重復(fù)點(diǎn)擊添加容錯處理
},
//按下
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數(shù)組中直接添加是Expanded包裝的控件,child是一個Text;
然后在IndexBar控件中,返回的是Positioned(自適應(yīng)控件);
然后使用的是Cloumn上下布局,它的children是一個List<Widget> children;所以可以返回的是一個數(shù)組的元素;
需要添加容錯處理的地方:
//clamp 防止越界
int index = (y ~/ItemHeight).clamp(0, INDEX_WORDS.length - 1);
// index = 獲取當(dāng)前手勢的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: 按下刷新狀態(tài),可以添加容錯防止多次重復(fù)點(diǎn)擊;
onVerticalDragDown: 按下時,此時回調(diào)callBack到通訊錄中滑動到指定的位置;
onVerticalDragEnd: 按下狀態(tài)結(jié)束,改變IndexBar的背景顏色,狀態(tài)等;
4 IndexBar回調(diào),通訊錄滑動到指定分組的位置
在自定義封裝的IndexBar中,需要在選中某個字母的時候,回調(diào)給當(dāng)前頁面選中可某個字母,然后滑動到指定的某個分組的位置;
滑動當(dāng)前的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]等可以滑動的控件;
用法:
創(chuàng)建一個ScrollController實(shí)例化對象;
在創(chuàng)建ListView的時候,傳入一個控制器,傳入當(dāng)前創(chuàng)建的_scrollController;
在滑動的首先添加容錯callBack返回的字符串是不是為空;然后使用_scrollController.animateTo實(shí)現(xiàn)滑動的動畫;
ScrollController _scrollController = ScrollController();
body: Stack(
children: <Widget>[
Container(
child: ListView.builder(
controller: _scrollController, //傳入已經(jīng)創(chuàng)建好的ScrollController實(shí)例化對象
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滑動到指定分組位置的算法實(shí)現(xiàn)
可以在初始化ListView的時候?qū)⒔M或者元素的位置保存通過數(shù)組保存起來;
final Map _groupMap = {
INDEX_WORDS[0]: 0.0,
INDEX_WORDS[1]: 0.0,
};
INDEX_WORDS為存放IndexBar元素A-Z的數(shù)組,_groupMap存放的是字母為key,偏移量offset為value;
在使用的時候可以直接通過key也就是字母取出偏移量offset;
算法簡單實(shí)現(xiàn):
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; 這個是微信原有的新的朋友,群聊,標(biāo)簽,公眾號4個cell的初始化高度,這個是死的;
- i < 1 ,第一個肯定是有頭部的,存放對應(yīng)的key和value值
- 如果兩個的IndexLetter相同,表示是同一個組的元素,不是組頭,這時候不需要往字典中存儲元素
- 如果兩個的IndexLetter不相同,表示當(dāng)前的字母對應(yīng)的為組頭, 這時候需要往字典中存儲元素
- 不是組頭元素的時候_groupOffset +=54;
- 是組頭元素的時候_groupOffset +=84;(組頭的高度是30)。
5 總結(jié)
運(yùn)用Flutter構(gòu)建微信通訊錄界面,實(shí)現(xiàn)難度較之前所仿寫的微信發(fā)現(xiàn)和我的界面難度和復(fù)雜度有了較大的提升,通訊錄界面主要有一下幾大難點(diǎn):
- 通訊錄ListView的組頭如何實(shí)現(xiàn),需要如何巧妙的實(shí)現(xiàn)
- IndexBar的封裝,點(diǎn)擊和回調(diào)的實(shí)現(xiàn),
- ListView滑動到指定的分組的位置,需要實(shí)現(xiàn)算好對應(yīng)字母的偏移量,并存放到數(shù)組中;
頁面還有很多需要優(yōu)化的地方,比如在點(diǎn)擊對應(yīng)字母的分組沒有好友的,這時候不需要跳轉(zhuǎn),這種處理方式不太友好,但是基本的功能是實(shí)現(xiàn)了;
通過學(xué)習(xí)這個頁面,發(fā)現(xiàn)算法的思路在任何一門語言中都是必備的,有一個好的算法和思想,有助于提高自己的邏輯,讓自己的思路更清晰,需要多多積累,步步為營。