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);
}

至于父组件,由于省市区接口不同做法就会不一样,比如后端的同学给我的接口就有三个,省列表——市列表——区列表,需要通过上一级的值来请求下一级的列表

所以这里就贴个代码用作参考不过多描述,毕竟需求不同。注意如果接口类似我这种,最好给请求接口加上防抖函数。

同时上一级变化时,注意在请求前,重置下一级的某些数据(列表组件代码中的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>
父组件

猜你喜欢

转载自www.cnblogs.com/izerandom/p/11290220.html