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;
      }
    }
  }
}
posted @   qqcc1388  阅读(339)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
点击右上角即可分享
微信分享提示