vue+elementUI實現移動端省市區三級聯動(滾動列表)


先上圖

 

 

 

 

 

 

 

 

具體實現

  • 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)';
        }            
ListTouchMove

 

首先需要接收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>
父組件

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM