先上圖

具體實現
- 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>
