前言
最近接到一个小的需求:选择不同城市,显示对应城市的天气。其实这种需求在很多生活类的APP中也有,实现的功能也相差无几。对于一个经常点外卖的人来说,美团、饿了么用的比较多。打开美团看了一下,然后决定模仿美团做一个城市选择。用了大概两天时间做出来,先看看效果图吧(效果图里面的定位失败是因为使用的是模拟器)。
功能分析
页面主要由两个重要的部分组成:左边城市选择列表和右边字母导航条。城市选择列表由一个header和一个列表组成,列表数据按照字母A到Z进行排序。点击和滑动字母导航条时,屏幕中出现选中的字母方块,列表内容跳转到选中的字母开头的城市数据item位置。
具体实现
既然我们把整个页面分成了两个部分,那么我们就分为两个部分来依次完成。
城市选择列表的实现
城市选择列表,主要就是一个由一个带头部的列表组成。其中列表控件必须具有主动滚动的功能,这里我们可以用到ScrollController.scrollTo()\ScrollController.jumpTo()这两种方法,主要的区别是scrollTo可以加入duration参数。那么怎么样才能实现点击右边导航条中的字母,列表就滚动到对应字母的header处呢?来看看具体代码是怎么样实现的。布局代码我就不贴出了,主要看 一些核心的代码。
1.首先获取城市数据
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 al = a.codeUnits;
List 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是分别存储所有城市数据和所有城市首字母数据的。其中对_mapKeysList中的数据进行了A-Z的排序。这里呢用到了一个PinyinHelper来获取城市名的首字母,这是一个汉字转拼音的库,感兴趣的朋友可以深入研究一下。所需要的数据 整理完毕后,就需要构建我们想要的列表了。
2.构建显示城市的列表
//构建显示所有城市的列表
Widget _buildAllCityList() {
return ScrollablePositionedList.builder(
itemCount: _dataMap == null ? 0 : _dataMap.length,
itemBuilder: (context, index) {
String key = _mapKeysList[index];
List cityList = _dataMap[key];
return Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
);
}
在这个列表中使用了ScrollablePositionedList和ListView进行嵌套。ScrollablePositionedList提供了一个itemScrollController.jumpTo(index:index )方法,可以直接滚动到index处。ScrollablePositionedList的itemBuilder返回的是一个Text+ListView的组合,Text用于显示首字母,ListView用于展示当前首字母下所有的城市。注意:两个List列表嵌套时NeverScrollableScrollPhysics()不能少,它能解决嵌套出现的滑动冲突的问题。
此时左边的列表基本已经完成,剩下的就是右边导航条。
字母导航条的实现
先来分析一下。这个导航条显示的内容是列表中所有城市的首字母,点击导航条和在导航条上滑动时屏幕中件位置会出现对应字母的方块,并且列表会滚到对应字母的Item位置。滚动方法我们已经有了,只需要找到 触发事件时对应的字母就行了。因为这里涉及到了点击和滑动两个方法,所以自然就想到了Flutter中的GestureDetector,它可以提供多种点击、滑动事件的回调。这里主要涉及到了onVerticalDragDown、onVerticalDragUpdate、onVerticalDragEnd、onTapUp四个事件回调。这里我就直接贴出导航条的全部代码,可以直接使用。
///垂直导航条
class VerticalGuideView extends StatefulWidget {
VerticalGuideView({Key key, this.dataList, this.onTap})
: assert(dataList != null),
super(key: key);
final List dataList;
final Function(DragSelectedInfo dragSelectedInfo) onTap;
@override
State createState() {
return VerticalGuideViewState();
}
}
class VerticalGuideViewState extends State {
double _widgetTop = -1; //整个布局Y轴上高度
final double itemHeight = 32.w; //单个item高度
bool _isTapDown = false;
@override
Widget build(BuildContext context) {
List 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;
//手指触及到时开始计算 整个布局的初始高度 _widgetTop
if (_widgetTop < 0) {
RenderBox box = context.findRenderObject();
Offset topLeftPosition = box.localToGlobal(Offset.zero);
_widgetTop = topLeftPosition.dy;
}
//手指触及的位置 - 布局高度 计算出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时 对应的字母index
int _getIndex(double offsetY) {
//触及位置到布局顶部距离 ~/ 单个item高度 计算出index
int index = (offsetY ~/ itemHeight);
if (index >= widget.dataList.length || index < 0) return -1;
return index;
}
//触发事件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中,关键的步骤有注释。主要就是去计算手指触及导航条的位置到布局顶部的距离,再由这个距离整除单个Item的高度计算出此时的index即导航条中的具体字母。
好的现在两个主要构成部分都已经完成了,接下来就是这两个部分的联动。
整体布局和联动
@override
Widget build(BuildContext context) {
return Material(
color: Colours.color_f4,
child: Stack(
children: [
Column(
children: [
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: [
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: [
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: '没有找到对应城市')
: 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: [
Offstage(
offstage: !_searchEmpty,
child: VerticalGuideView(
dataList: _mapKeysList,
onTap: (DragSelectedInfo info) {
_handleDragSelectedInfo(info);
},
),
),
],
),
),
],
),
);
}
布局比较简单,使用Stack作为大的容器,利用Positioned放置VerticalGuideView导航条。比较核心的方法是_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(() {});
}
可以看到当DragStatus == DragDown或者DragUpdate时都会触发 _itemScrollController.jumpTo(index: index)也就是列表滚动,同时还会执行 _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;
}
这个方法中使用了Overlay.of(context).insert(_overlayEntry),不熟悉Overlay的同学可以去学习一下。这个widget用处还是比较多的。
结语
到此这个城市联动选择便已经完成了。其实涉及到的难点不多,而且实现的方法也是多种多样。再次分享一下我在这里用到过的两个库PinyinHelper 和 ScrollablePositionedList 。
有什么建议和意见请下方留言吧,看到会第一时间回复的。喜欢的请点个赞吧,谢谢啦!