Taro 小程序自定义热门城市选择页
先上一下大致效果
由于业务需要一个单独全国城市筛选页面,然后就网上找了一波,发现没有特别合适的,于是就手动撸一个,需要当前页面具备以下功能:
1.定位当前所在城市
2.展示热门城市信息
3.清空当前城市选择
4.支持本地快捷搜索
5.列表数据支持分页展示(主要是城市数据量太大,页面渲染太慢,一次加载20条数据)
6.没数据情况下展示空白页
下面开始分解需求,并一一实现
1.定位当前城市
由于项目中定位基本都用的高德地图,这里就仍然使用高德地图sdk来获取当前城市定位,不熟悉小程序接入高德地图的可以参考我前面的文章https://www.cnblogs.com/qqcc1388/p/17079437.html ,这里就直接上功能代码了
/// 初始化高德地图
myAmapFun = new amapFile.AMapWX({ key: ThirdPartKey.amapKey })
/// 获取定位信息 并拿到城市的编码
myAmapFun.getRegeo({
success: (data) => {
// markersData = data.markers;
console.log(data)
let { addressComponent } = data[0].regeocodeData
let cityName = addressComponent?.city
this.setState({ cityName })
this.fetchCityList()
},
fail: (err) => {
console.log(err)
this.fetchCityList()
}
})
由于高德地图拿到的城市编码无法直接使用,这里就拿获取到城市名称到数据源中去匹配城市名称,并拿到当前城市对应的编码
fetchCityList = async () => {
const citys = await getLocationCitys()
this.setState({ citys }, () => {
const { cityName } = this.state
if (cityName && cityName.length > 0) {
const searchs = this.filterCityKeyword(cityName);
if (searchs && searchs.length > 0) {
this.setState({
locationCity: searchs[0]
})
}
}
this.getCityList()
})
}
关于数据源这里多说两嘴,我这边的数据源是服务端返回的,然后本地缓存,去拿数据源的时候会先检查缓存是否到期,如果到期,会直接重新发起请求,请求完成之后再缓存起来使用,下面的代码是对应的缓存逻辑,默认缓存7天,如果到期就会再重新缓存一份
/// 获取本地城市列表
export const getLocationCitys = async () => {
return new Promise(async (resolve, reject) => {
const token = getGlobalData('token')
if (!token) return resolve([])
///先查询数据是否存在 如果存在就不请求了
let timestamp = Date.parse(Date())
let expiration = wx.getStorageSync('citylist_expiration')
if (expiration < timestamp) {
/// 重新获取城市列表
return resolve(await handleCityList())
}
/// 如果未过期 获取本地存储的数据
wx.getStorage({
key: 'citylist', success: async (ret) => {
/// 拿到数据了 不重复请求
let datas = ret.data || []
if (datas && datas.length > 0) {
return resolve(datas)
}
/// 如果数据为空 则重新请求数据
return resolve(await handleCityList())
}, fail: async (err) => {
/// 没有获取到数据 重新请求数据
return resolve(await handleCityList())
}
})
})
}
const handleCityList = () => {
/// 获取城市列表
return new Promise((resolve, reject) => {
GetBaseCityTree().then(res => {
/// 处理数据
let citys = res.data?.data || []
if (citys && citys.length > 0) {
/// 设置过期时间
let timestamp = Date.parse(Date())
///设置缓存 7天 86400000 * 7
let expiration = timestamp + 86400000 * 7
wx.setStorage({
key: 'citylist_expiration',
data: expiration
})
wx.setStorage({
key: 'citylist',
data: citys
})
}
resolve(citys)
}).catch(error => {
resolve([])
})
})
}
获取到本地源数据后拿城市名称去筛选,筛选到对应的城市名称并获取到该城市对象信息,name,id等,筛选我这里用的是Js的filter方法快速查询
filterCityKeyword = (value) => {
let citys = SafeCollection(this.state.citys)
return citys.filter(city => city.name.indexOf(value) != -1)
}
这样最终拿到当前定位城市的信息,信息大致如下,参数和字段根据各自需求会有所不同
locationCity = {
name: "杭州市",
id: '330100',
}
拿到城市信息,将城市渲染到页面中
<View className='location-wrap'>
<View className='left'>
<View>当前定位城市:</View>
<View onClick={() => this.onItemClick(locationCity)} style={{ color: locationCity?.name ? '#2560FD' : '#999' }}>{locationCity?.name || '暂无数据'}</View>
</View>
<View className='right'>
<View style={{ color: '#999' }} onClick={() => this.onItemClick(null)}>清空城市选择</View>
</View>
</View>
2.展示热门城市信息
定义好热门城市的数据源,我这里定义的是这几个,可根据需求自行定义
hotCity: [
{
name: "北京市",
id: '110000',
}, {
name: "上海市",
id: '310000',
}, {
name: "广州市",
id: '440100',
}, {
name: "深圳市",
id: '440300',
}, {
name: "杭州市",
id: '330100',
}, {
name: "武汉市",
id: '420100'
}, {
name: "南京市",
id: '320100'
}, {
name: "苏州市",
id: '320500'
}
]
渲染热门城市样式,大致利用 display: flex; flex-wrap: wrap; 将城市列表进行换行布局
<View className='hotcity-wrap'>
<View className='title'>热门城市</View>
<View className='item-wrap'>
{
hotCity.map((res, index) => <View className='hotcity-item' key={index} onClick={() => this.onItemClick(res)}>
{res.name}
</View>)
}
</View>
</View>
.hotcity-wrap {
font-size: 26px;
color: #333;
background-color: white;
border-bottom: 1px solid #eee;
.title {
margin-left: 20px;
// margin-top: 20px;
padding-top: 20px;
}
.item-wrap {
display: flex;
flex-wrap: wrap;
.hotcity-item {
width: calc(calc(100vw - 60px - 40px) / 4);
margin-top: 20px;
height: 56px;
margin-left: 20px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
background-color: #eee;
&:nth-last-of-type(1) {
margin-bottom: 20px;
}
}
}
}
点击热门城市,将信息传递出去, 这里使用Taro自带的eventCenter来发送消息,使用eventCenter需要注意监听和销毁
onItemClick = (item) => {
console.log(item)
/// 数据传出去
Taro.eventCenter.trigger('selectCity', {
info: item,
})
Taro.navigateBack()
}
在需要的时候监听
// 添加监听
Taro.eventCenter.on('selectCity', (data: any) => {
//保存信息
this.setState({
cityName: data?.info?.name,
city: data?.info?.id ? Number.parseInt(data?.info?.id) : null
}, () => {
this.getStationList()
})
})
componentWillUnmount() {
// 页面销毁 移除监听
Taro.eventCenter.off('selectCity')
}
3.清空城市选择
/// 给上级页面传一个null过去
this.onItemClick(null)
onItemClick = (item) => {
console.log(item)
/// 数据传出去
Taro.eventCenter.trigger('selectCity', {
info: item,
})
Taro.navigateBack()
}
4.本地快捷搜索
/// 监听键盘输入
onInputValueChange = (e) => {
const { value } = e.detail
const { citys } = this.state
if (value === '' || value.length == 0) {
this.setState({
keyword: '',
searchs: []
})
return
}
this.setState({ keyword: value })
// Taro.getApp().$app.preventActive(() => {
///筛选
const searchs = this.filterCityKeyword(value);
this.setState({
searchs
})
// })
}
/// 筛选出与之匹配的城市信息
filterCityKeyword = (value) => {
let citys = SafeCollection(this.state.citys)
return citys.filter(city => city.name.indexOf(value) != -1)
}
5.列表数据分页展示
/// 数据分页显示
getCityList = () => {
let { page, pageSize, isScroll, citys, currentDatas } = this.state
/// 根据page取出对应的数据
if (citys && citys.length > 0) {
let start = page * pageSize
let end = start + pageSize
let list = citys.slice(start, end)
isScroll = list.length >= pageSize || false;
currentDatas.push(...list)
this.setState({
currentDatas,
isScroll
})
}
}
/// 触底加载更多
onReachBottom() {
const { page, isScroll } = this.state
if (isScroll) {
this.setState({
page: page + 1,
}, () => this.getCityList())
}
}
列表页面
<View className='city-wrap'>
{
currentDatas.map(res => <View key={res.id} className='city-item' onClick={() => this.onItemClick(res)}>{res.name}</View>)
}
</View>
5.展示搜索空白页
{
showSearch && <View className='search-result-wrap' style={{ top: `${naviTotal}px` }}>
{
searchs.map(res => <View key={res.id} className='city-item' onClick={() => this.onItemClick(res)}>{res.name}</View>)
}
{
searchs.length == 0 && <View className='nodata-wrap'>
<View>暂无数据</View>
</View>
}
</View>
}
完整源码:
import React, { Component } from 'react'
import Taro from '@tarojs/taro'
import { View, Input, Icon, ScrollView } from '@tarojs/components'
import { getGlobalData } from '@/utils/globalData'
import { AtIcon } from 'taro-ui'
import { ThirdPartKey } from '@/utils/const'
import { SafeCollection, getLocationCitys } from '@/utils/usertool'
import amapFile from '../../../utils/libs/amap-wx.js' //高德地图sdk
import './index.scss'
let myAmapFun
interface State {
city: any,
locationCity: any,
hotCity: any[],
citys: any[],
keyword: string,
searchs: any[],
showSearch: boolean,
statusBarHeight: number,
screenHeight: number,
cityName: string,
currentDatas: any[], //分页后的数据
page: number,
pageSize: number,
isScroll: boolean, // 是否加载完数据,true还有数据加载 false加载完成
}
export default class CitySelector extends Component<{}, State> {
state: State = {
city: null,
locationCity: null,
searchs: [],
currentDatas: [],
hotCity: [
{
name: "北京市",
id: '110000',
}, {
name: "上海市",
id: '310000',
}, {
name: "广州市",
id: '440100',
}, {
name: "深圳市",
id: '440300',
}, {
name: "杭州市",
id: '330100',
}, {
name: "武汉市",
id: '420100'
}, {
name: "南京市",
id: '320100'
}, {
name: "苏州市",
id: '320500'
}
],
citys: [],
keyword: '',
showSearch: false,
statusBarHeight: 0,
screenHeight: 0,
cityName: '',
page: 0,
pageSize: 20,
isScroll: false,
}
componentWillMount = () => {
/// 获取状态栏高度
let systemInfo = getGlobalData('systemInfo');
if (systemInfo == null || systemInfo == undefined) {
systemInfo = Taro.getSystemInfoSync();
}
this.setState({
statusBarHeight: systemInfo.statusBarHeight,
screenHeight: systemInfo.screenHeight
})
/// 初始化高德地图
myAmapFun = new amapFile.AMapWX({ key: ThirdPartKey.amapKey })
myAmapFun.getRegeo({
success: (data) => {
// markersData = data.markers;
console.log(data)
let { addressComponent } = data[0].regeocodeData
let cityName = addressComponent?.city
this.setState({ cityName })
this.fetchCityList()
},
fail: (err) => {
console.log(err)
this.fetchCityList()
}
})
}
/// 数据分页显示
getCityList = () => {
let { page, pageSize, isScroll, citys, currentDatas } = this.state
/// 根据page取出对应的数据
if (citys && citys.length > 0) {
let start = page * pageSize
let end = start + pageSize
let list = citys.slice(start, end)
isScroll = list.length >= pageSize || false;
currentDatas.push(...list)
this.setState({
currentDatas,
isScroll
})
}
}
onReachBottom() {
const { page, isScroll } = this.state
// if (isScroll) return
if (isScroll) {
this.setState({
page: page + 1,
}, () => this.getCityList())
}
}
fetchCityList = async () => {
const citys = await getLocationCitys()
this.setState({ citys }, () => {
const { cityName } = this.state
if (cityName && cityName.length > 0) {
const searchs = this.filterCityKeyword(cityName);
if (searchs && searchs.length > 0) {
this.setState({
locationCity: searchs[0]
})
}
}
this.getCityList()
})
}
onClear = () => {
this.setState({
keyword: '',
searchs: [],
})
}
cancleSearch = () => {
this.setState({
showSearch: false,
keyword: '',
searchs: [],
})
}
onInputValueChange = (e) => {
const { value } = e.detail
const { citys } = this.state
if (value === '' || value.length == 0) {
this.setState({
keyword: '',
searchs: []
})
return
}
this.setState({ keyword: value })
// Taro.getApp().$app.preventActive(() => {
///筛选
const searchs = this.filterCityKeyword(value);
this.setState({
searchs
})
// })
}
filterCityKeyword = (value) => {
let citys = SafeCollection(this.state.citys)
return citys.filter(city => city.name.indexOf(value) != -1)
}
onItemClick = (item) => {
console.log(item)
/// 数据传出去
Taro.eventCenter.trigger('selectCity', {
info: item,
})
Taro.navigateBack()
}
render() {
// const { citys, keyword, hotCity, searchs, showSearch } = this.state
let { showSearch, keyword, searchs, statusBarHeight, screenHeight, hotCity, locationCity, currentDatas } = this.state
let naviHeight = statusBarHeight + 44
let naviTotal = naviHeight + 44
/// 屏幕的总高度 - 导航栏高度 - poiWrap高度
let containerHeight = screenHeight - naviTotal;
return <View className='selectCity'>
{/* 导航栏 */}
<View className='navi' style={{ height: `${naviTotal}px` }}>
<View className='navi-back' style={{ marginTop: `${statusBarHeight}px`, height: '44px' }} onClick={() => Taro.navigateBack()}>
<AtIcon value='chevron-left' size='24' color='#fff'></AtIcon>
<View className='title'>城市选择</View>
</View>
<View className='navi-content'>
{/* 搜索 */}
<View className='search-wrap'>
<View className='input-wrap'>
<Icon className='searchIcon' type='search' size='15' color='#fff' />
<Input className='input' placeholder='请输入城市名称搜索' placeholderStyle='color:#eee' value={keyword} onInput={this.onInputValueChange.bind(this)} onFocus={() => this.setState({ showSearch: true })}></Input>
{
keyword.length > 0 && <View className='clear-img' onClick={() => this.onClear()}>
<Icon type='clear' size='13' color='#fff' />
</View>
}
</View>
{
showSearch && <View className='search' onClick={() => this.cancleSearch()}>取消</View>
}
</View>
</View>
</View>
<View style={{ height: `${naviTotal}px` }}></View>
<View className='container' style={{ height: `${containerHeight}px` }}>
<View className='location-wrap'>
<View className='left'>
<View>当前定位城市:</View>
<View onClick={() => this.onItemClick(locationCity)} style={{ color: locationCity?.name ? '#2560FD' : '#999' }}>{locationCity?.name || '暂无数据'}</View>
</View>
<View className='right'>
<View style={{ color: '#999' }} onClick={() => this.onItemClick(null)}>清空城市选择</View>
</View>
</View>
<View className='hotcity-wrap'>
<View className='title'>热门城市</View>
<View className='item-wrap'>
{
hotCity.map((res, index) => <View className='hotcity-item' key={index} onClick={() => this.onItemClick(res)}>
{res.name}
</View>)
}
</View>
</View>
<View className='city-wrap'>
{
currentDatas.map(res => <View key={res.id} className='city-item' onClick={() => this.onItemClick(res)}>{res.name}</View>)
}
</View>
{
showSearch && <View className='search-result-wrap' style={{ top: `${naviTotal}px` }}>
{
searchs.map(res => <View key={res.id} className='city-item' onClick={() => this.onItemClick(res)}>{res.name}</View>)
}
{
searchs.length == 0 && <View className='nodata-wrap'>
<View>暂无数据</View>
</View>
}
</View>
}
</View>
</View>
}
}
scss
.selectCity {
.navi {
background: linear-gradient(180deg, #f4475b 0%, #e6222e 100%);
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 9999;
.navi-back {
position: absolute;
left: 0;
top: 0;
display: flex;
align-items: center;
justify-content: center;
.title {
color: white;
font-size: 30px;
}
}
.navi-content {
.search-wrap {
height: 88px;
position: absolute;
padding: 0 20px;
left: 0;
bottom: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
.input-wrap {
flex: 1;
height: 70%;
padding: 0 15px;
border-radius: 36px;
display: flex;
align-items: center;
background-color: #e2868a;
.searchIcon {
margin-left: 8px;
}
.input {
flex: 1;
height: 100%;
font-size: 26px;
color: white;
margin-left: 14px;
}
.clear-img {
width: 30px;
height: 30px;
// background-color: #e63c3d;
display: flex;
align-items: center;
justify-content: center;
margin-left: 15px;
.img {
width: 100%;
height: 100%;
}
}
}
.search {
width: 80px;
text-align: center;
color: white;
font-size: 28px;
font-weight: 400;
}
}
}
}
.container {
position: relative;
.location-wrap {
font-size: 26px;
color: #333;
display: flex;
background-color: white;
padding: 20px;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
.left {
display: flex;
flex: 1;
}
.right {
}
}
.hotcity-wrap {
font-size: 26px;
color: #333;
background-color: white;
border-bottom: 1px solid #eee;
.title {
margin-left: 20px;
// margin-top: 20px;
padding-top: 20px;
}
.item-wrap {
display: flex;
flex-wrap: wrap;
.hotcity-item {
width: calc(calc(100vw - 60px - 40px) / 4);
margin-top: 20px;
height: 56px;
margin-left: 20px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
background-color: #eee;
&:nth-last-of-type(1) {
margin-bottom: 20px;
}
}
}
}
.city-wrap {
.city-item {
background-color: white;
padding: 15px;
font-size: 26px;
color: #333;
border-bottom: 1px solid #eee;
}
}
.search-result-wrap {
background-color: white;
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
z-index: 99;
overflow: scroll;
.city-item {
background-color: white;
padding: 15px;
font-size: 26px;
color: #333;
border-bottom: 1px solid #eee;
}
.nodata-wrap {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 30px;
}
}
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构