先上图

具体实现
- 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>
