先上图
具体实现
- HTML + CSS
ps:代码不能直接用,只写了大概,看得懂就行

<div style="fixed"> <div style="flex"> <div @touchstart @touchmove @touchend > <div> <div v-for>{{省}}</div> </div> </div> <div @touchstart @touchmove @touchend > <div> <div v-for>{{市}}</div> </div> </div> <div @touchstart @touchmove @touchend > <div> <div v-for>{{区}}</div> </div> </div> </div> </div>
因为省市区三级结构一样,所以可以单独封装一个组件并复用,同时绝对定位出两条分割线,区别选中与非选中的状态,最终的结构如下:
<div style="fixed"> <div style="line"></div> <div style="line"></div> <div style="flex"> <list-component></list-component> <list-component></list-component> <list-component></list-component> </div> </div>
(接下来的代码都是在组件中完成)
列表可视高度和显示的省市区个数根据具体需求来做修改,这里设置 flex高度为150px line-height为30px 即每列显示5个
为了区别选中与非选中状态,这里的做法是使用动态绑定class的方式
- v-for遍历时,当选中的元素下标Selected与v-for中的index相等时,添加active类(字体变大变粗)
- index===Selected+1||index===Selected-1时,添加small类(字体减小,透明度减小)
- 剩下的其他元素添加smaller类(字体更小,透明度更小)
:class="index == Selected ? 'active' : index == Selected + 1 || index == Selected - 1 ? 'small' : 'smaller'"
- JS
由于列表单独作为组件做了封装,因此数据需要由父组件提供,这里使用props传值(注意当type = Array || Object 时需要使用箭头函数)
props: { List: { type: Array, default: () => [] }, Loading: { //控制加载动画,先不管 type: Boolean, default: false } }
子组件自身的data如下:
touch: { scrollY: 0 } //保存touch事件中的一些数据 Selected: 0 //当前选择的index HEIGHT: 30 //常量,用于上拉到底部时的计算
监听列表touch事件(加上prevent消除其他touch事件的影响)
@touchstart.prevent="ListTouchStart" @touchmove.prevent="ListTouchMove" @touchend.prevent="ListTouchEnd"
首先在ListTouchStart函数中记录所需数据
ListTouchStart(e) { const touch = e.touches[0] this.touch.startY = touch.pageY //startY记录手指按下的位置 this.touch.offsetHeight = this.$refs.wrapper.offsetHeight - this.HEIGHT //offsetHeight记录上拉的最大值,由于List会变化,因此这里在每次touchstart时都计算一次 }
然后是touchmove,页面中的变化几乎都在ListTouchMove函数中实现,这里大概讲一下思路(尝试写过好几种方法来实现,最终这一版经受住了考验活到了最后)
先上代码

ListTouchMove(e) { const touch = e.touches[0]; const deltaY = touch.pageY - this.touch.startY; const scrollY = deltaY - this.Selected * 30; if (this.limit(scrollY, touch.pageY)) return; this.touch.deltaY = deltaY; if (this.touch.deltaY <= -20) { this.Selected += 1; this.touch.startY -= 20; this.changeIndex(); return; } else if (this.touch.deltaY >= 20) { this.Selected -= 1; this.touch.startY += 20; this.changeIndex(); return; } this.$refs.wrapper.style['transform'] = `translateY(` + scrollY + 'px)'; }
首先需要接收touchmove事件派发的数据,得到手指当前的pageY,再减去touchstart中保存的startY值,就得到了手指的位移距离deltaY
这样就能通过改变css(this.$refs.wrapper.style['transform'] = `translateY(` + deltaY + 'px)')来达到滚动的效果(这里使用transform平移实现,如果给列表加上绝对定位也可以通过改变top(bottom)值达到同样的效果)
但显然这样的效果达不到预期,在列表滚动的同时,我们希望知道当前滚动到第几个,这样才能获取下一栏的数据(省——市,市——区) 以及改变元素的css(变大变粗)
同时,用手指滑动的距离来维护滚动的距离成本很高,因为由用户来控制滚动的结果是产生大量计算来维护滚动的距离,才能使得滚动的距离对代码本身而言可控。(母语学的差,想表达自己的想法,奈何词穷)
总之,我们希望用户的滑动是用来提供数据的,而不是控制代码逻辑的。
因此我们需要换个思路,将用户的控制限制在改变selected所需的位移之内,用一个新的数据scrollY来帮助维护移动的距离。具体做法如下:
通过deltaY来维护移动的距离的同时,判断移动的距离是否满足到达下一个元素的条件,每个元素高度为30px
当deltaY=+(-)30时,说明列表滚动到了上(下)一个,此时selected+(-)1,执行changeIndex函数,滚动距离scrollY等于30*selected(此时的selected改变)
注意此时deltaY的绝对值仍大于30,因此需要修改startY(加减30),重新使deltaY从0开始 (为什么呢?答:相信自己,把两只手拿出来比划一下,你会明白的)
小于这个临界值时,不执行changeIndex函数,滚动距离scrollY等于30*selected+deltaY (此时的selected未改变)
当然这个临界值可以根据需求修改,比如想滚动到两个元素之间就自动跳到下一个,可以把30改为15,同样的,加减startY也改为15(示例代码中是20)
到这,touchmove基本写完了,想要实现的效果也基本实现了,但仍存在一个问题需要解决,上拉底部与下拉顶部
上拉底部与下拉顶部时,deltaY同样在改变,所以需要做限制,否则selected仍会改变
用limit函数来监听上拉底部与下拉顶部的清况,下面的limit函数在到达临界点是不做滚动的,同样的可以根据需求修改函数,实现回弹或重新加载等功能。
limit(scrollY, pageY) { if (scrollY > 0) { this.touch.startY = pageY; return true; } else if (scrollY < -this.touch.offsetHeight) { this.$refs.wrapper.style['transform'] = `translateY(` + -this.touch.offsetHeight + 'px)'; this.touch.startY = pageY; return true; } }
最后的收尾工作,在ListTouchEnd中完成。在touchend时,需要对滚动做修正,丢掉move中未达到临界的deltaY,保证中间的两条线包含整个元素
以及告诉父组件,我变了
ListTouchEnd(e) { this.touch.scrollY = 0 - this.Selected * 30; this.$refs.wrapper.style['transform'] = `translateY(` + this.touch.scrollY + 'px)'; this.$emit('change', this.Selected); }
补充:最好在ListTouchStart和ListTouchEnd函数中加一行判断,否则即使列表空白,手指滑动时仍会触发touch事件
if(this.List.length===0) return
至于父组件,由于省市区接口不同做法就会不一样,比如后端的同学给我的接口就有三个,省列表——市列表——区列表,需要通过上一级的值来请求下一级的列表
所以这里就贴个代码用作参考不过多描述,毕竟需求不同。注意如果接口类似我这种,最好给请求接口加上防抖函数。
同时上一级变化时,注意在请求前,重置下一级的某些数据(列表组件代码中的reset函数),如transform初始化,touch初始化
最后,绝不是标题党,使用过elementUI的同学一眼就能看出来ele在哪了,这不是重点,更不是难点。完整代码如下:

<template> <div class="wrapper" v-loading="Loading" element-loading-spinner="el-icon-loading" @touchstart.prevent="ListTouchStart" @touchmove.prevent="ListTouchMove" @touchend.prevent="ListTouchEnd" > <div ref="wrapper"> <div v-for="(item, index) in List" :key="index" class="list" :class="index == Selected ? 'active' : index == Selected + 1 || index == Selected - 1 ? 'small' : 'smaller'" > {{ item.text }} </div> </div> </div> </template> <script> export default { props: { List: { type: Array, default: () => [] }, Loading: { type: Boolean, default: false } }, data() { return { touch: { scrollY: 0 }, Selected: 0, HEIGHT: 30 /*** line-height 30px * margin-top 30px * 30+30=60 30px * ———————————————————— ———————————————————— * 30px 30px ————————>HEIGHT = offsetHeight - scrollY = 30 * ———————————————————— ———————————————————— * 30px * 30px * * 列表初始位置 上拉到底部 ***/ }; }, methods: { reset() { this.touch = { scrollY: 0, oldY: 0, deltaY: 0 } this.Selected = 0 this.$refs.wrapper.style['transform'] = `translateY(0px)` }, changeIndex() { this.touch.scrollY = this.Selected * 30; this.$refs.wrapper.style['transform'] = `translateY(` + this.touch.scrollY + 'px)'; }, limit(scrollY, pageY) { if (scrollY > 0) { this.touch.startY = pageY; return true; } else if (scrollY < -this.touch.offsetHeight) { this.$refs.wrapper.style['transform'] = `translateY(` + -this.touch.offsetHeight + 'px)'; this.touch.startY = pageY; return true; } }, ListTouchStart(e) { const touch = e.touches[0]; this.touch.startY = touch.pageY; this.touch.offsetHeight = this.$refs.wrapper.offsetHeight - this.HEIGHT; }, ListTouchMove(e) { const touch = e.touches[0]; const deltaY = touch.pageY - this.touch.startY; const scrollY = deltaY - this.Selected * 30; if (this.limit(scrollY, touch.pageY)) return; this.touch.deltaY = deltaY; if (this.touch.deltaY <= -20) { this.Selected += 1; this.touch.startY -= 20; this.changeIndex(); return; } else if (this.touch.deltaY >= 20) { this.Selected -= 1; this.touch.startY += 20; this.changeIndex(); return; } this.$refs.wrapper.style['transform'] = `translateY(` + scrollY + 'px)'; }, ListTouchEnd(e) { this.touch.scrollY = 0 - this.Selected * 30; this.$refs.wrapper.style['transform'] = `translateY(` + this.touch.scrollY + 'px)'; this.$emit('change', this.Selected); } } }; </script> <style scoped="scoped" lang="stylus"> .wrapper width 30% position relative top 60px padding 0 5px .list height 30px line-height 30px white-space nowrap overflow hidden text-overflow ellipsis .active font-size 15px color #000000 font-weight 900 opacity 1 .small opacity 0.7 font-size 14px .smaller opacity 0.3 font-size 12px >>>.el-loading-spinner margin-top -70px </style>

<template> <div class="flex"> <div class="line1"></div> <div class="line2"></div> <wrapper :List="Province" @change="ProvinceChange" :Loading="ProvinceLoading" ref="province"></wrapper> <wrapper :List="City" :Loading="CityLoading" @change="CityChange" ref="city"></wrapper> <wrapper :List="Area" :Loading="AreaLoading" @change="AreaChange" ref="area"></wrapper> </div> </template> <script> import wrapper from './wrapper.vue'; import { getListProvince, getListCity, getListArea } from '../../api/api.js'; export default { mounted() { this.getProvince(); }, data() { return { Province: [], //list City: [], Area: [], Timer: null, ProvinceLoading:false, CityLoading: false, AreaLoading: false, ProvinceSelected: undefined, CitySelected: undefined, AreaSelected:undefined }; }, methods: { emitSelected(){ //供父组件使用 if(this.AreaSelected===undefined){ if(this.CitySelected===undefined){ return this.Province[this.ProvinceSelected] }else{ return this.City[this.CitySelected] } }else{ return this.Area[this.AreaSelected] } }, getProvince() { this.ProvinceLoading=true getListProvince().then(res => { this.Province = res.result; this.ProvinceChange(0) this.ProvinceLoading=false }); }, getCity(value) { if (this.CityLoading === false) return; getListCity(value).then(res => { this.City = res.result; this.CityLoading = false; if(this.City.length===0) return this.CityChange(0) }); }, getArea(value) { if (this.AreaLoading === false) return; getListArea(value).then(res => { this.Area = res.result; this.AreaLoading = false; if(this.Area.length===0) return this.AreaChange(0) }); }, ProvinceChange(index) { if (index === this.ProvinceSelected) return; this.resetCity() this.resrtArea() this.ProvinceSelected = index; this.$refs.city.reset(); this.CityLoading = true; if (this.Timer != null) { clearTimeout(this.Timer); } this.Timer = setTimeout(() => { this.getCity(this.Province[index].value); }, 500); }, CityChange(index) { if (index === this.CitySelected) return; this.resrtArea() this.CitySelected = index; this.$refs.area.reset(); this.AreaLoading = true; if (this.Timer != null) { clearTimeout(this.Timer); } this.Timer = setTimeout(() => { this.getArea(this.City[index].value); }, 500); }, AreaChange(index) { this.AreaSelected=this.Area.length===0?undefined:index }, resetCity(){ this.City = []; this.CitySelected=undefined }, resrtArea(){ this.Area = []; this.AreaSelected=undefined } }, components: { wrapper } }; </script> <style scoped="scoped" lang="stylus"> .flex display flex height 100% text-align center color #666 overflow hidden .line1 position fixed bottom 80px left 0 right 0 z-index 2001 border 0.5px solid #ccc .line2 position fixed bottom 110px left 0 right 0 z-index 2001 border 0.5px solid #ccc </style>