前言
城市联动功能在业务比较常见,一般用于用户自行搜索或者选择其所在地,然后根据地点获取有关当地的一些推荐信息,比如附近商家、景点、娱乐等推荐,引导用户的出行和消费行为。
效果预览
其实,只要换一下数据结构,它也可以变成一个类似于手机通讯录列表,那个圆形气泡联动跟随,就是模仿 魅族
的手机通讯录上下滑动侧边栏时,指示当前字母的效果。
功能简介
可以看到,接下来要实现的功能也并不是很复杂,主要包括:
- 城市搜索,按城市名称或者拼音进行模糊匹配搜索
- 城市列表,从上到下按城市拼音首字母分块排序显示
- 右侧城市首字母导航栏,支持点击和上下滑动字母联动左侧城市列表对应显示,加上当前字母圆形气泡跟随显示
- 选择城市之后,缓存到浏览器localStorage缓存中,保持当前城市的显示状态
项目结构
这里为了方便,我使用了 Vue-cli4
创建项目,项目结构如下:
mock
存放了一份城市列表的json数据,页面数据就靠它了,格式如下:
{
"A": [
{
"id": 56,
"spell": "aba",
"name": "阿坝"
},
{
"id": 57,
"spell": "akesu",
"name": "阿克苏"
},
...
],
"B": [
{
"id": 1,
"spell": "beijing",
"name": "北京"
},
{
"id": 66,
"spell": "baicheng",
"name": "白城"
},
...
],
...
}
题外话,你拿到的数据不一定就是这个格式,而且也没有按字母分类,而是一份偏平化的城市列表数组,那你可以通过安装 pinyinjs
这个库来将城市名转化为拼音,再截取拼音首字母,最后再按字母分类分组。
router
存放路由配置,因为此时只有一个页面,所以简单的配置如下:
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
export default new Router({
routes: [
{
path: '/',
name: 'city',
component: () => import('@/views/city/City.vue')
}
]
})
你也可以不使用路由,那就直接在 App.vue 中引入写好的 City.vue 组件好了。
store
状态管理配置,里面只定义了一个当前城市的属性 currentCity,用于 List 和 Search 组件共享操作,或者也可以使用 this.$emit 来通知父组件更新。utils
里面封装了操作 localStorage 的方法,在 store 配置中引用。views
就是用来存放页面和组件的了,每一个页面对应一个自己命名的文件夹,在这里 city 文件夹中创建 City.vue 页面和其组件目录,根据功能简介来分类,我们可以划分为三个子组件:- Search.vue 城市搜索组件
- LIst.vue 城市列表组件
- Alphabet.vue 城市首字母导航组件
开发依赖
vue-router
vuex
better-scroll
首先单个页面不能一次性显示所有的城市列表,需要根须用户搜索、点击或者滑动的选择字母来显示对应的局部列表,这里使用了better-scroll
滚动插件,它是一个移动端滚动的解决方案,比较好用。axios
模拟后端请求数据。stylus
和stylus-loader
css样式和css预处理,这样可以使用模块化的方法去写css代码。
功能实现
简单的配置完成,接下来我们就可以开始 ‘coding’ 功能实现了,但首先还需要来分析下每个组件需要实现什么功能和需要哪些数据:
- 对于
Serach
组件来说,搜索匹配和搜索结果列表都是在它上面实现的,所以只需要传入城市列表数据即可,当选中某个城市时,需要更新 currentCity; List
组件,因为要显示城市列表和跟随右侧导航栏选中的字母来联动显示,所以它需要传入城市列表数据还有当前选中城市的首字母,然后选中某个城市时,也需要更新 currentCity;Alphabet
组件, 在点击或者滑动时,需要通知List
组件当前被选中的城市首字母,显示并更新圆形气泡显示的字母和在垂直方向上位置实现联动跟随,然后城市列表也滚动到指定的位置。
经过以上分析,可以编写出 City.vue 页面文件:
分别引入 Search、List、Alphabet 三个组件和传入它们对应需要的数据。
<template>
<div>
<search :citiesList="citiesList"/>
<list
:citiesList="citiesList"
:letter="letter"
/>
<alphabet @change="letterSelect" />
</div>
</template>
<script>
import Search from './components/Search'
import List from './components/List'
import Alphabet from './components/Alphabet'
export default {
name: 'City',
data () {
return {
letter: '',
citiesList: {}
}
},
created () {
this.getCityInfo()
},
methods: {
getCityInfo () {
this.$axios.get('./mock/cities.json')
.then(res => {
const { status, data } = res
if (status === 200 && data) {
this.citiesList = data
}
})
.catch(err => {
console.log('getCityInfo -> err', err)
})
},
letterSelect (letter) {
this.letter = letter
}
},
components: {
Search,
List,
Alphabet
}
}
</script>
搜索组件 Search.vue:
- 使用 watch 监听用户的输入变化,根据输入值从城市列表数据中返回符合模糊匹配的结果,再以下拉列表形式显示出来;
- 用户点击列表中的某个城市,通过 vuex 的 mapActions 辅助函数异步更新 store 中 currentCity 的值,然后隐藏搜索列表并更新当前城市;
- 当没有匹配数据时,加了个提示告诉用户没有找到他想要查找的数据;
- 需要注意的是处理这种用户输入监听的操作,会频繁触发请求,所以有必要加个
防抖
来减少请求的次数。
<template>
<div>
<div class="search">
<input
v-model="keyword"
class="search-input"
type="text"
placeholder="输入城市名或拼音"
/>
</div>
<div class="search-content" ref="search" v-show="keyword">
<ul>
<li
class="search-item border-bottom"
v-for="item of searchList"
:key="item.id"
@click="handleCityClick(item.name)"
>
{{ item.name }}
</li>
<li class="search-item border-bottom" v-show="hasNoData">
您输入的内容没有匹配项
</li>
</ul>
</div>
</div>
</template>
<script>
import { mapActions } from 'vuex'
import BScroll from 'better-scroll'
export default {
name: 'Search',
props: {
citiesList: {
type: Object,
default: () => { }
}
},
data () {
return {
keyword: '',
timer: null,
searchList: []
}
},
computed: {
hasNoData () {
return !this.searchList.length
}
},
watch: {
keyword () {
if (this.timer) {
clearTimeout(this.timer)
}
this.timer = setTimeout(() => {
const result = []
if (this.keyword.trim().length) {
for (const i in this.citiesList) {
this.citiesList[i].forEach(item => {
if (item.spell.indexOf(this.keyword) > -1 || item.name.indexOf(this.keyword) > -1) {
result.push(item)
}
})
}
}
this.searchList = result
}, 200)
}
},
mounted () {
this.scroll = new BScroll(this.$refs.search, {
click: true
})
},
methods: {
handleCityClick (city) {
this.keyword = ''
this.changeCity(city)
},
...mapActions(['changeCity'])
}
}
</script>
</style>
<style lang="stylus" scoped>
// 样式省略...
</style>
城市列表组件 List.vue:
- 首次加载显示默认的或者上次选择的当前城市;
- 根据用户点击或者滑动右侧字母导航栏的字母,使用 watch 监听 letter 变化,然后滚动到指定字母的城市列表;
- 用户在列表上点击某个城市,更新当前城市并滚动返回页面最上方。
<template>
<div>
<div class="current-city">
<div class="title">当前城市</div>
<div class="button-wrapper">
<div class="button">{{ currentCity }}</div>
</div>
</div>
<div class="city-list" ref="wrapper">
<div>
<div
class="area"
v-for="(items, key) of citiesList"
:key="key"
:ref="key"
>
<div class="title border-topbottom">{{ key }}</div>
<ul class="item-list">
<li
class="item border-bottom"
v-for="item of items"
:key="item.id"
@click="handleCityClick(item.name)"
>
{{ item.name }}
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex'
import BScroll from 'better-scroll'
export default {
name: 'List',
props: {
letter: {
type: String
},
citiesList: {
type: Object,
default: () => { }
}
},
mounted () {
this.scroll = new BScroll(this.$refs.wrapper, {
click: true
})
},
computed: {
...mapState({
currentCity: 'city'
})
},
watch: {
letter () {
if (this.letter) {
const element = this.$refs[this.letter] && this.$refs[this.letter][0]
this.scroll.scrollToElement(element)
}
}
},
methods: {
handleCityClick (city) {
this.changeCity(city)
this.scroll.scrollToElement(this.$refs.wrapper)
},
...mapActions(['changeCity'])
}
}
</script>
<style lang="stylus" scoped>
// 样式省略...
</style>
首字母导航栏组件 Alphabet.vue:
- 因为是固定的26个英文大写字母,所以没有必要从父组件中引入列表数据再解析,直接手写就好了:
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
。 - 当户点击导航栏字母,通过
$emit
向外触发一个 change 事件,告诉父组件当前选中的是哪个字母,然后父组件再通知 List 组件滚动到指定字母的城市列表。 - 当户滑动导航栏,需要知道当前滑动到哪个字母,然后才能执行第2步操作,这里我们使用了三个触摸事件:
touchstart
,touchmove
,touchend
,它们不像click
事件,可以实时从事件对象中的 target 属性获取到当前字母,而是返回当前触摸屏幕区域的位置信息,所以需要变通下,以字母A
为基点,通过滑动点距离字母A
的相对高度除以
每个字母的固定高度(这里是18),再向下取整得到此时触摸点对应的字母下标值
了,最后有了下标值,就可以从字母数组列表中拿到对应的字母了。 - 至于圆形气泡,它相对于导航栏绝对定位的,所以通过字母
A
距离导航栏顶部的高度,再加上
每个字母的固定高度乘以
当前滑动到的字母下标值,就得到触摸点对应当前字母距离A
在垂直方向上的相对偏移量,最后把这个偏移量赋予给圆形气泡,从而实现跟随字母上下跟随联动显示。 - 这里需要做个容错处理,就是只有在用户滑动时我们才会进行第3、4步的计算,所以在data中增加一个 touchActive 属性,用于判断用户是否是在滑动状态。
- 同样,对于这种滑动或者滚动事件,必定会频繁触发事件,所以需要加入
节流
处理,减少频繁触发带来的资源消耗。
<template>
<ul class="alphabet-list" ref="bar">
<li
class="item"
v-for="item of letters"
:key="item"
:ref="item"
@click="handleLetterClick"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
{{ item }}
</li>
<!-- 圆形跟随气泡 -->
<div
class="active-letter"
:style="{ top: activeLetterTop + 'px' }"
v-show="activeLetter"
>
<span>{{ activeLetter }}</span>
</div>
</ul>
</template>
<script>
export default {
name: 'Alphabet',
data () {
return {
letters: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''),
activeLetter: '',
activeLetterTop: 0,
startY: 0,
touchActive: false,
timer: null
}
},
mounted () {
this.startY = this.$refs.bar.offsetTop + this.$refs.A[0].offsetTop || 0 // 字母A距离文档边框顶部的高度
},
methods: {
handleLetterClick (e) {
this.$emit('change', e.target.innerText)
},
handleTouchStart () {
this.touchActive = true
},
handleTouchMove (e) {
if (this.touchActive) {
// 节流,减少频繁触发带来的资源消耗
if (this.timer) return
this.timer = setTimeout(() => {
const touchY = e.touches[0].clientY || e.touches[0].pageY
const index = Math.floor((touchY - this.startY) / 18) // 18 为字母的高度,通过此计算出字母的下标值
if (index >= 0 && index <= this.letters.length) {
this.activeLetter = this.letters[index]
this.activeLetterTop = this.$refs.A[0].offsetTop + index * 18 // 字母A偏离导航栏顶部相对高度作为初始值,加上滑动到其它字母相对于A的偏移量,实现圆形气泡跟随
this.$emit('change', this.activeLetter)
}
this.timer = null
}, 30)
}
},
handleTouchEnd () {
this.touchActive = false
this.activeLetter = ''
}
}
}
</script>
<style lang="stylus" scoped>
// 样式省略...
</style>
后记
这样一来,我们就实现了一个侧边栏城市联动功能的页面,功能虽然不复杂,但是编写文章和组织语言却花了点时间,相当于重新整理下思路,算是温故而知新吧。