前言
最近接到一個(gè)小的需求:選擇不同城市裙椭,顯示對(duì)應(yīng)城市的天氣驴娃。其實(shí)這種需求在很多生活類的APP中也有,實(shí)現(xiàn)的功能也相差無幾岩瘦。對(duì)于一個(gè)經(jīng)常點(diǎn)外賣的人來說未巫,美團(tuán)、餓了么用的比較多启昧。打開美團(tuán)看了一下叙凡,然后決定模仿美團(tuán)做一個(gè)城市選擇。用了大概兩天時(shí)間做出來密末,先看看效果圖吧(效果圖里面的定位失敗是因?yàn)槭褂玫氖悄M器)握爷。
功能分析
頁面主要由兩個(gè)重要的部分組成:左邊城市選擇列表和右邊字母導(dǎo)航條。城市選擇列表由一個(gè)header和一個(gè)列表組成严里,列表數(shù)據(jù)按照字母A到Z進(jìn)行排序新啼。點(diǎn)擊和滑動(dòng)字母導(dǎo)航條時(shí),屏幕中出現(xiàn)選中的字母方塊刹碾,列表內(nèi)容跳轉(zhuǎn)到選中的字母開頭的城市數(shù)據(jù)item位置燥撞。
具體實(shí)現(xiàn)
既然我們把整個(gè)頁面分成了兩個(gè)部分,那么我們就分為兩個(gè)部分來依次完成教硫。
城市選擇列表的實(shí)現(xiàn)
城市選擇列表叨吮,主要就是一個(gè)由一個(gè)帶頭部的列表組成。其中列表控件必須具有主動(dòng)滾動(dòng)的功能瞬矩,這里我們可以用到ScrollController.scrollTo()\ScrollController.jumpTo()這兩種方法茶鉴,主要的區(qū)別是scrollTo可以加入duration參數(shù)。那么怎么樣才能實(shí)現(xiàn)點(diǎn)擊右邊導(dǎo)航條中的字母景用,列表就滾動(dòng)到對(duì)應(yīng)字母的header處呢涵叮?來看看具體代碼是怎么樣實(shí)現(xiàn)的。布局代碼我就不貼出了伞插,主要看 一些核心的代碼割粮。
1.首先獲取城市數(shù)據(jù)
void _findBaseDictCity() {
ApiInterface.findBaseDictCity(context).then((value) {
(value['data'] as List).forEach((element) {
CityEntity entity = CityEntity().fromJson(element);
String cityName = entity.name;
String firstPinyin = PinyinHelper.getFirstWordPinyin(cityName).substring(0, 1).toUpperCase();
_dataMap.putIfAbsent(firstPinyin, () => List()).add(entity);
});
_mapKeysList = _dataMap.keys.toList();
_mapKeysList.sort((a, b) {
List<int> al = a.codeUnits;
List<int> bl = b.codeUnits;
for (int i = 0; i < al.length; i++) {
if (bl.length <= i) return 1;
if (al[i] > bl[i]) {
return 1;
} else if (al[i] < bl[i]) return -1;
}
return 0;
});
if (mounted) {
setState(() {});
}
}).catchError((e) {});
}
這里我用到的_dataMap和_mapKeysList是分別存儲(chǔ)所有城市數(shù)據(jù)和所有城市首字母數(shù)據(jù)的。其中對(duì)_mapKeysList中的數(shù)據(jù)進(jìn)行了A-Z的排序媚污。這里呢用到了一個(gè)PinyinHelper來獲取城市名的首字母舀瓢,這是一個(gè)漢字轉(zhuǎn)拼音的庫,感興趣的朋友可以深入研究一下耗美。所需要的數(shù)據(jù) 整理完畢后京髓,就需要構(gòu)建我們想要的列表了航缀。
2.構(gòu)建顯示城市的列表
//構(gòu)建顯示所有城市的列表
Widget _buildAllCityList() {
return ScrollablePositionedList.builder(
itemCount: _dataMap == null ? 0 : _dataMap.length,
itemBuilder: (context, index) {
String key = _mapKeysList[index];
List<CityEntity> cityList = _dataMap[key];
return Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Offstage(
offstage: index != 0,
child: _buildPageHeader(),
),
Padding(
padding: EdgeInsets.only(left: 20, right: 20),
child: Text('$key'),
),
ListView.builder(
itemBuilder: (context, index) {
CityEntity entity = cityList[index];
return _buildCityItem(entity);
},
itemCount: cityList == null ? 0 : cityList.length,
shrinkWrap: true,
padding: EdgeInsets.all(0),
physics: NeverScrollableScrollPhysics(),
)
],
),
);
},
itemScrollController: _itemScrollController,
itemPositionsListener: _itemPositionsListener,
);
}
在這個(gè)列表中使用了ScrollablePositionedList和ListView進(jìn)行嵌套。ScrollablePositionedList提供了一個(gè)itemScrollController.jumpTo(index:index )方法堰怨,可以直接滾動(dòng)到index處芥玉。ScrollablePositionedList的itemBuilder返回的是一個(gè)Text+ListView的組合,Text用于顯示首字母备图,ListView用于展示當(dāng)前首字母下所有的城市灿巧。注意:兩個(gè)List列表嵌套時(shí)NeverScrollableScrollPhysics()不能少,它能解決嵌套出現(xiàn)的滑動(dòng)沖突的問題揽涮。
此時(shí)左邊的列表基本已經(jīng)完成抠藕,剩下的就是右邊導(dǎo)航條。
字母導(dǎo)航條的實(shí)現(xiàn)
先來分析一下绞吁。這個(gè)導(dǎo)航條顯示的內(nèi)容是列表中所有城市的首字母幢痘,點(diǎn)擊導(dǎo)航條和在導(dǎo)航條上滑動(dòng)時(shí)屏幕中件位置會(huì)出現(xiàn)對(duì)應(yīng)字母的方塊,并且列表會(huì)滾到對(duì)應(yīng)字母的Item位置家破。滾動(dòng)方法我們已經(jīng)有了颜说,只需要找到 觸發(fā)事件時(shí)對(duì)應(yīng)的字母就行了。因?yàn)檫@里涉及到了點(diǎn)擊和滑動(dòng)兩個(gè)方法汰聋,所以自然就想到了Flutter中的GestureDetector门粪,它可以提供多種點(diǎn)擊、滑動(dòng)事件的回調(diào)烹困。這里主要涉及到了onVerticalDragDown玄妈、onVerticalDragUpdate、onVerticalDragEnd髓梅、onTapUp四個(gè)事件回調(diào)拟蜻。這里我就直接貼出導(dǎo)航條的全部代碼,可以直接使用枯饿。
///垂直導(dǎo)航條
class VerticalGuideView extends StatefulWidget {
VerticalGuideView({Key key, this.dataList, this.onTap})
: assert(dataList != null),
super(key: key);
final List<String> dataList;
final Function(DragSelectedInfo dragSelectedInfo) onTap;
@override
State<StatefulWidget> createState() {
return VerticalGuideViewState();
}
}
class VerticalGuideViewState extends State<VerticalGuideView> {
double _widgetTop = -1; //整個(gè)布局Y軸上高度
final double itemHeight = 32.w; //單個(gè)item高度
bool _isTapDown = false;
@override
Widget build(BuildContext context) {
List<Widget> children = List();
widget.dataList.forEach((element) {
children.add(SizedBox(
height: itemHeight,
width: itemHeight,
child: Text(
'$element',
style: TextStyle(
fontSize: 28.sp,
color: Colours.color_22,
fontWeight: FontWeight.w400,
),
textAlign: TextAlign.center,
),
));
});
return Container(
color: _isTapDown ? Colours.translucent : Colors.transparent,
alignment: Alignment.center,
child: GestureDetector(
onVerticalDragDown: (detail) {
_isTapDown = true;
//手指觸及到時(shí)開始計(jì)算 整個(gè)布局的初始高度 _widgetTop
if (_widgetTop < 0) {
RenderBox box = context.findRenderObject();
Offset topLeftPosition = box.localToGlobal(Offset.zero);
_widgetTop = topLeftPosition.dy;
}
//手指觸及的位置 - 布局高度 計(jì)算出offSetY即 觸及位置到布局頂部距離
double offsetY = detail.globalPosition.dy - _widgetTop;
int index = _getIndex(offsetY);
if (index != -1) {
_triggerTouchEvent(DragSelectedInfo(index: index, tag: widget.dataList[index], dragStatus: DragStatus.DragDown));
}
},
onVerticalDragEnd: (detail) {
_isTapDown = false;
_triggerTouchEvent(DragSelectedInfo(dragStatus: DragStatus.DragEnd));
},
onVerticalDragUpdate: (detail) {
double offsetY = detail.globalPosition.dy - _widgetTop;
int index = _getIndex(offsetY);
if (index != -1) {
_triggerTouchEvent(DragSelectedInfo(index: index, tag: widget.dataList[index], dragStatus: DragStatus.DragUpdate));
}
},
onTapUp: (details) {
_isTapDown = false;
_triggerTouchEvent(DragSelectedInfo(dragStatus: DragStatus.TapUp));
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: children,
),
),
);
}
//獲取手指觸及到widget時(shí) 對(duì)應(yīng)的字母index
int _getIndex(double offsetY) {
//觸及位置到布局頂部距離 ~/ 單個(gè)item高度 計(jì)算出index
int index = (offsetY ~/ itemHeight);
if (index >= widget.dataList.length || index < 0) return -1;
return index;
}
//觸發(fā)事件onTap
_triggerTouchEvent(DragSelectedInfo dragSelectedInfo) {
if (widget.onTap != null) {
widget.onTap(dragSelectedInfo);
}
}
}
class DragSelectedInfo {
int index;
String tag;
DragStatus dragStatus;
DragSelectedInfo({this.index, this.tag, this.dragStatus});
}
enum DragStatus {
TapDown,
TapUp,
DragDown,
DragEnd,
DragUpdate,
}
其中核心代碼是在GestureDetector中酝锅,關(guān)鍵的步驟有注釋。主要就是去計(jì)算手指觸及導(dǎo)航條的位置到布局頂部的距離奢方,再由這個(gè)距離整除單個(gè)Item的高度計(jì)算出此時(shí)的index即導(dǎo)航條中的具體字母搔扁。
好的現(xiàn)在兩個(gè)主要構(gòu)成部分都已經(jīng)完成了,接下來就是這兩個(gè)部分的聯(lián)動(dòng)蟋字。
整體布局和聯(lián)動(dòng)
@override
Widget build(BuildContext context) {
return Material(
color: Colours.color_f4,
child: Stack(
children: <Widget>[
Column(
children: <Widget>[
Container(
color: Colours.white,
margin: EdgeInsets.only(top: Global.statusHeight),
padding: EdgeInsets.only(left: 30.w, right: 30.w, top: 20.w, bottom: 20.w),
child: Row(
children: <Widget>[
ContainerView(
onPressed: () {
Navigator.of(context).pop();
},
child: ImageUtils.imageShow('icon_back_black', width: 50.w),
),
Expanded(
child: Container(
height: 65.w,
alignment: Alignment.center,
padding: EdgeInsets.only(left: 30.w, right: 30.w),
decoration: BoxDecoration(color: Colours.color_ed, borderRadius: BorderRadius.all(Radius.circular(32.w))),
child: Row(
children: <Widget>[
Image.asset(
ImageUtils.getImgPath('icon_search_gray'),
width: 35.w,
height: 35.w,
),
Expanded(
child: InputTextField(
isDense: true,
noBorder: true,
hintText: '搜索城市',
contentPadding: EdgeInsets.only(left: 20.w, right: 20.w),
keyboardType: ITextInputType.text,
style: TextStyle(fontSize: 26.sp, fontWeight: FontWeight.w300, color: Colours.color_22),
controller: _searchController,
onChanged: (String str) {
_searchEmpty = StringUtils.isEmpty(str);
if (!_searchEmpty) {
_buildLocalSearchData(str);
}
setState(() {});
},
),
),
],
),
),
),
],
),
),
Expanded(
child: _searchEmpty
? _buildAllCityList()
: (_searchList == null || _searchList.length == 0)
? NoData.show(contentText: '沒有找到對(duì)應(yīng)城市')
: ListView.builder(
itemBuilder: (context, index) {
return _buildCityItem(_searchList[index]);
},
itemCount: _searchList == null ? 0 : _searchList.length,
shrinkWrap: true,
),
)
],
),
Positioned(
right: 20.w,
top: 0,
bottom: 0,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Offstage(
offstage: !_searchEmpty,
child: VerticalGuideView(
dataList: _mapKeysList,
onTap: (DragSelectedInfo info) {
_handleDragSelectedInfo(info);
},
),
),
],
),
),
],
),
);
}
布局比較簡(jiǎn)單稿蹲,使用Stack作為大的容器,利用Positioned放置VerticalGuideView導(dǎo)航條鹊奖。比較核心的方法是_handleDragSelectedInfo(info)
//處理dragSelectedInfo事件
void _handleDragSelectedInfo(DragSelectedInfo info) {
DragStatus status = info.dragStatus;
int index = info.index;
if (status == DragStatus.DragDown) {
_selectedTag = info.tag;
_insertCenterGuider();
_itemScrollController.jumpTo(index: index);
} else if (status == DragStatus.DragUpdate) {
_removeCenterGuider();
_selectedTag = info.tag;
_insertCenterGuider();
_itemScrollController.jumpTo(index: index);
} else if (status == DragStatus.DragEnd) {
_removeCenterGuider();
} else if (status == DragStatus.TapUp) {
_removeCenterGuider();
}
setState(() {});
}
可以看到當(dāng)DragStatus == DragDown或者DragUpdate時(shí)都會(huì)觸發(fā) _itemScrollController.jumpTo(index: index)也就是列表滾動(dòng)苛聘,同時(shí)還會(huì)執(zhí)行 _insertCenterGuider(),此方法主要是在屏幕中顯示字母方塊的。
//顯示屏幕中間 方塊
void _insertCenterGuider() {
_overlayEntry = OverlayEntry(builder: (_) {
return Align(
child: Container(
width: 120.w,
height: 120.w,
alignment: Alignment.center,
color: Colors.black.withOpacity(0.5),
child: Text(
'$_selectedTag',
style: TextStyle(
fontSize: 40.sp,
color: Colours.white,
fontWeight: FontWeight.w600,
decoration: TextDecoration.none,
),
),
),
alignment: Alignment.center,
);
});
Overlay.of(context).insert(_overlayEntry);
}
//移除屏幕中間 方塊
void _removeCenterGuider() {
_overlayEntry.remove();
_overlayEntry = null;
}
這個(gè)方法中使用了Overlay.of(context).insert(_overlayEntry)设哗,不熟悉Overlay的同學(xué)可以去學(xué)習(xí)一下璧尸。這個(gè)widget用處還是比較多的。
結(jié)語
到此這個(gè)城市聯(lián)動(dòng)選擇便已經(jīng)完成了熬拒。其實(shí)涉及到的難點(diǎn)不多,而且實(shí)現(xiàn)的方法也是多種多樣垫竞。再次分享一下我在這里用到過的兩個(gè)庫PinyinHelper 和 ScrollablePositionedList 澎粟。
有什么建議和意見請(qǐng)下方留言吧,看到會(huì)第一時(shí)間回復(fù)的欢瞪。喜歡的請(qǐng)點(diǎn)個(gè)贊吧活烙,謝謝啦!