[封装自己的Ui库] 走马灯遇到的难点

263 阅读4分钟

问题

在实现走马灯的时候想到了几个问题

  • 1、结构如何指定
  • 2、走马灯不能只实现图片的轮播,轮播模板需要用户自定义?
  • 3、如何实现滚动?
  • 4、需要实现无缝滚动,具体可以使用复制首尾项填充和动态偏移距离的方法?如何操作?
  • 5、过渡效果如何添加?
  • 6、垂直轮播效果?

效果(展示部分)

  • 基础

基础.gif

  • 无缝

无缝.gif

  • 垂直

垂直.gif

思路(只有难点)

结构如何搭建
        <ty-carousel height="300px">
            <ty-carousel-item v-for="ele in imgs" :key="ele.id">
                <ty-image fit="contain" alt="1234" :src="ele.url" :lazy="true">
                </ty-image>
            </ty-carousel-item>
        </ty-carousel>

也就是说需要创建ty-carousel, ty-carousel-item.

通过插槽用户即可自定义循环模板

ty-carousel-item

<template>
    <div ref="item" class='ty-carousel-item' :style="{ 'transform': positionOffset }">
        <slot></slot>
    </div>
</template>



<style lang='less' scoped>
.ty-carousel-item {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    background-color: #d3dce6;
}
</style>

ty-carousel

<template>
    <div class='ty-carousel' @mouseenter="enterMain" @mouseleave="leaveMain">

        <!-- 主体 -->
        <div class="main" :style="{ 'height': height }">
            <!-- 指示器 -->
            <div class="indicator" v-if="!isOutSide && isIndicator">
                <div class="dot" v-for="(c, index) in playControl.positionArray" :key="index">
                    <span :style="{ 'background': index == playControl.currentPage ? 'pink' : '#f0f1f3' }"
                        @click.stop="pushPage(index)"></span>
                </div>
            </div>

            <transition name="leftBtnAnimation">
                <!-- 左侧按钮 -->
                <div class="btn_left" v-show="isControlShow || arrowMode == 'show'" @click.stop="withLeft">
                    <ty-icon type="arrow-double-left"></ty-icon>
                </div>
            </transition>

            <transition name="rightBtnAnimation">
                <!-- 右按钮 -->
                <div class="btn_right" v-show="isControlShow || arrowMode == 'show'" @click.stop="withRight">
                    <ty-icon type="arrow-double-right"></ty-icon>
                </div>
            </transition>


            <div class="show">
                <slot></slot>
            </div>
        </div>

        <!-- 指示器 -->
        <div class="outside" v-if="isOutSide && isIndicator">
            <div class="dot" v-for="(c, index) in playControl.positionArray" :key="index">
                <span :style="{ 'background': index == playControl.currentPage ? 'pink' : '#f0f1f3' }"
                    @click.stop="pushPage(index)"></span>
            </div>
        </div>
    </div>
</template>

<script>
export default {
    name: 'ty-carousel',

    props: {
        // 盒子整体高度
        height: {
            type: [String],
            default: '300px'
        },
        // 初始化激活页面
        initialIndex: {
            type: [Number],
            default: 0
        },
        // 是否自动切换
        autoPlay: {
            type: [Boolean],
            default: true
        },
        // 切换间隔时间
        interval: {
            type: [Number],
            default: 2000
        },
        // 指示器是否显示在外侧
        indicatorOutside: {
            type: [Boolean],
            default: false
        },
        // 是否显示指示器
        indicator: {
            type: [Boolean],
            default: true
        },
        // 箭头展示方式
        arrow: {
            type: [String],
            default: 'none'
        },
        // 切换时触发的回调
        change: {
            type: Function
        },
        // 轮播方向
        verticalPlay: {
            type: [Boolean],
            default: false
        }
    },
}
</script>

<style lang='less' scoped>
@import '../../../css/carousel.less';
</style>
如何实现滚动与无缝

滚动的方式可以使用定位或者2d偏移等等,这里我使用transform2d偏移,

无缝滚动的实现方式1:

复制首尾项反向填充当对应的节点首尾中,当节点激活到末尾或者首项时,若继续此方向轮播, 则(迅速)跳转到反向的指定页码上,再动画过渡到指定的'最后页码'上, 反之亦如此

无缝实现思路2:

在轮播的时候每个子组件都获取最新的相较于激活页的偏移值,根据偏移值来控制展示, 当激活页左侧没有子组件时,将右侧的最后一个组件偏移到最左边,当激活页右侧没有子组件时,将左侧的第一个组件偏移到最右边

这里我使用的是第二种方法,毕竟无需用户有其他的操作

具体

解决思路: 创建一个数组保存每一个组件距离激活页的距离,下表格的数组每一项表示距离当前展示组件的距离
      如: 第一行数组表示在激活页码为0时, 第一个组件距激活组件的距离为0, 第二个组件距激活组件的距离为1
          依次类推
          这样可以获得每一个组件在不同的激活页下距离激活页的距离, 将数组对应的距离值分配给对应的子组件
          子组件根据距离使用transform动态偏移(距离1, 则偏移1*100%, 距离-1 则偏移-1*100%)

   无缝:
        因为这里每个组件是单独偏移,所以无缝推荐使用左右补充的方式(也方便为card模式做准备),不推荐使用
        首张末张复制插入的方式(需要复制组件或vnode节点)

无缝思路
        对循环情况分类:  
        1 左右都有组件 --> 正方向递增, 负方向递减
        2 左没有组件 --> 正方向递增, 留数组末尾为距离-1
        3 右没有组件 --> 负方向递减, 留数组首项为距离1
        具体结合数组思考(其实就是保证激活组件左右都有组件)
                  
                // right点击
                // --0---1---2---3---4---> 对应组件序号(图片)
                // [ 0,  1,  2,  3, -1]  0
                // [-1,  0,  1,  2,  3]  1
                // [-2, -1,  0,  1,  2]  2
                // [-3, -2, -1,  0,  1]  3
                // [ 1, -3, -2, -1,  0]  4
                // [ 0,  1,  2,  3, -1]  0
                //                       激活页码

                // left点击
                // --0---1---2---3---4-
                // [ 0,  1,  2,  3, -1]  0
                // [ 1, -3, -2, -1,  0]  4
                // [-3, -2, -1,  0,  1]  3
                // [-2, -1,  0,  1,  2]  2
                // [-1,  0,  1,  2,  3]  1
                // [ 0,  1,  2,  3, -1]  0
如何添加过渡?

这里不能直接全部子组件添加过渡,因为这样或导致切换的闪烁

当激活页右侧的组件需要直接过渡到最左侧时,会从页面的最上方经过,结合数组就是 距离为正数的组件突然变为了距离负数

可以只根据切换过程中需要展示的组件添加过渡

具体

      在偏移时我们需要 激活页和偏移目标页添加动画效果,其他的不需要(避免因为都有动画导致闪烁)
          所以需要动态的添加和删除组件动画的方法
      实现思路, 创建一个方法接受 两个索引, 根据索引找到对应组件添加动画
          这里根据向左,向右两种情况添加动画
          因为在距离数组中-1表示的是激活页左边的组件,0表示激活页, 1表示激活页右边的组件
          所以我们这里传入对应的距离,在内部方法会找到对应的组件添加过渡
垂直实现?

根据使用者传入的参数来决定子组件是translateX还是translateY

总体难点攻克方法

每次激活页码更新 --> 得到每个子组件的偏移值 --> 给指定组件预制添加动画 --> 传递子组件对应偏移并启动

代码(逻辑)

ty-carousel

<script>
export default {
    name: 'ty-carousel',

    props: {
        // 盒子整体高度
        height: {
            type: [String],
            default: '300px'
        },
        // 初始化激活页面
        initialIndex: {
            type: [Number],
            default: 0
        },
        // 是否自动切换
        autoPlay: {
            type: [Boolean],
            default: true
        },
        // 切换间隔时间
        interval: {
            type: [Number],
            default: 2000
        },
        // 指示器是否显示在外侧
        indicatorOutside: {
            type: [Boolean],
            default: false
        },
        // 是否显示指示器
        indicator: {
            type: [Boolean],
            default: true
        },
        // 箭头展示方式
        arrow: {
            type: [String],
            default: 'none'
        },
        // 切换时触发的回调
        change: {
            type: Function
        },
        // 轮播方向
        verticalPlay: {
            type: [Boolean],
            default: false
        }
    },

    data() {
        return {
            playControl: {
                // 当前播放的序号
                currentPage: -1,
                // 循环列表
                list: [],
                // 位置列表
                positionArray: [],
            },
            // 节流标识
            bool: true,
            // 方向
            direction: 'right',
            // control 控件是否展示
            isControlShow: false,
            // 循环的定时器标识
            Timer: null,
            // 指示器位置 ouside
            isOutSide: false,
            // 是否显示指示器
            isIndicator: true,
            // 箭头展示方式
            arrowMode: 'none'
        }
    },

    created() {
        // 初始化激活页面
        this.playControl.currentPage = this.initialIndex;

        this.$nextTick(() => {
            // 筛选出符合条件的slot
            this.playControl.list = this.$children.filter(child => child.$options.name === 'ty-carousel-item')

            if (this.playControl.list.length < 2) {
                console.warn('warn 请最少使用2个及以上的数据')
            }
        })
    },

    mounted() {
        // 初始化各组件位置
        this.updateChildItemPosition()
    },

    methods: {
        // 鼠标进入可视区
        enterMain() {
            // 关闭定时器
            this.closeSetInter();
            if (this.arrowMode == 'none') {
                // 展示控件
                this.isControlShow = true
            }
        },
        // 鼠标离开可视区
        leaveMain() {
            // 开启定时器
            if (this.autoPlay) {
                this.openSetInter(this.withRight);
            }
            
            if (this.arrowMode == 'none') {
                // 展示控件
                this.isControlShow = false
            }
        },
        // 点击dot到指定位置
        pushPage(index) {
            // 节流
            if (this.bool) {
                this.bool = false;

                setTimeout(() => {

                    if (index - this.playControl.currentPage > 0) {
                        // 相较激活页向右
                        this.direction = 'right'
                        this.updateChildAnimation()
                        this.playControl.currentPage = index
                        this.updateChildItemPosition()
                    } else {
                        // 相较激活页向左
                        this.direction = 'left'
                        // 更新需要展示的组件动画
                        this.updateChildAnimation()
                        // 更新激活页
                        this.playControl.currentPage = index
                        // 更新子组件位置
                        this.updateChildItemPosition()
                    }
                    this.bool = true
                }, 100)

            }
        },
        // 点击左侧按钮
        withLeft() {
            // 节流
            if (this.bool) {
                this.bool = false
                setTimeout(() => {
                    this.direction = 'left'
                    // 更新需要加载过渡的组件
                    this.updateChildAnimation()
                    // 更新激活页码
                    this.playControl.currentPage = this.playControl.currentPage <= 0 ? this.playControl.list.length - 1 : this.playControl.currentPage - 1
                    // 更新子组件位置
                    this.updateChildItemPosition()
                    this.bool = true
                }, 300)
            }
        },
        // 点击右侧按钮
        withRight() {
            // 同左击事件
            if (this.bool) {
                this.bool = false
                setTimeout(() => {
                    this.direction = 'right'
                    this.updateChildAnimation()
                    this.playControl.currentPage = this.playControl.currentPage >= this.playControl.list.length - 1 ? 0 : this.playControl.currentPage + 1
                    this.updateChildItemPosition()
                    this.bool = true
                }, 300)
            }
        },
        // 更新子组件位置的方法
        updateChildItemPosition() {

            /* 
                解决思路: 创建一个数组保存每一个组件距离激活页的距离,下表格的数组每一项表示距离当前展示组件的距离
                  如: 第一行数组表示在激活页码为0时, 第一个组件距激活组件的距离为0, 第二个组件距激活组件的距离为1
                     依次类推
                  这样可以获得每一个组件在不同的激活页下距离激活页的距离, 将数组对应的距离值分配给对应的子组件
                  子组件根据距离使用transform动态偏移(距离1, 则偏移1*100%, 距离-1 则偏移-1*100%)

                  无缝:
                  因为这里每个组件是单独偏移,所以无缝推荐使用左右补充的方式(也方便为card模式做准备),不推荐使用
                  首张末张复制插入的方式(需要复制组件或vnode节点)

                  无缝思路
                  对循环情况分类:  
                  1 左右都有组件 --> 正方向递增, 负方向递减
                  2 左没有组件 --> 正方向递增, 留数组末尾为距离-1
                  3 右没有组件 --> 负方向递减, 留数组首项为距离1
                  具体结合数组思考
            */

            this.$nextTick(() => {
                // right
                // --0---1---2---3---4---> 对应组件序号
                // [ 0,  1,  2,  3, -1]  0
                // [-1,  0,  1,  2,  3]  1
                // [-2, -1,  0,  1,  2]  2
                // [-3, -2, -1,  0,  1]  3
                // [ 1, -3, -2, -1,  0]  4
                // [ 0,  1,  2,  3, -1]  0
                //                       激活页码

                // left
                // --0---1---2---3---4-
                // [ 0,  1,  2,  3, -1]  0
                // [ 1, -3, -2, -1,  0]  4
                // [-3, -2, -1,  0,  1]  3
                // [-2, -1,  0,  1,  2]  2
                // [-1,  0,  1,  2,  3]  1
                // [ 0,  1,  2,  3, -1]  0

                // '当前在最开始的一项'
                if (this.playControl.currentPage == 0) {
                    // console.log('当前在最开始的一项');

                    this.playControl.positionArray = this.playControl.list.map((item, index) => {
                        if (index == this.playControl.list.length - 1) {// 当激活页为首组件时, 末项为-1
                            return -1
                        } else {
                            return index - this.playControl.currentPage// 正方向递增
                        }
                    })

                    // '当前在最后一项'
                } else if (this.playControl.currentPage == this.playControl.list.length - 1) {
                    // console.log('当前在最后一项');

                    this.playControl.positionArray = this.playControl.list.map((item, index) => {
                        if (index == 0) { // 当激活页为最后的组件时, 首项为1
                            return 1
                        } else {// 负方向递减
                            return index - this.playControl.currentPage
                        }
                    })

                    //'当前在中间项'
                } else {
                    // console.log('当前在中间项');
                    this.playControl.positionArray = this.playControl.list.map((item, index) => {
                        if (index == this.playControl.currentPage) {
                            return 0
                        } else {
                            return index - this.playControl.currentPage // 正方向++, 负方向--
                        }
                    })

                }
                // 获得的不同情况下组件距离列表
                // console.log(this.playControl.positionArray);
                this.playControl.list.forEach((item, index) => {
                    // 触发每个子组件的更新位置方法, 传入对应组件的距离
                    item.updatePositionNew(this.playControl.positionArray[index], this.verticalPlay)
                })
            })
        },
        // 更新子组件动画效果的方法
        updateChildAnimation() {
            /* 
                 在偏移时我们需要 激活页和偏移目标页添加动画效果,其他的不需要(避免因为都有动画导致闪烁)
                 所以需要动态的添加和删除组件动画的方法
                 实现思路, 创建一个方法接受 两个索引, 根据索引找到对应组件添加动画
                 这里根据向左,向右两种情况添加动画
                 因为在距离数组中-1表示的是激活页左边的组件,0表示激活页, 1表示激活页右边的组件
                 所以我们这里传入对应的距离,在内部方法会找到对应的组件添加过渡
             */
            this.direction == 'left' ? this.loadAnimation(-1, 0) : this.loadAnimation(1, 0)

        },
        // 筛选及加载动画
        loadAnimation(start, end) {
            // 找到要添加动画的组件
            // AnimationEleIndexList ---> 添加过渡组件序号array
            // this.playControl.positionArray --> 子组件距离列表
            let AnimationEleIndexList = [this.playControl.positionArray.indexOf(start), this.playControl.positionArray.indexOf(end)];
            // this.playControl.list ---> 子组件列表
            this.playControl.list.forEach((item, index) => {
                if (AnimationEleIndexList.includes(index)) { // 对应组件添加过渡
                    item.$el.style.transition = 'transform .4s'
                } else { // 其他组件删除过渡
                    item.$el.style.transition = ''
                }
            })
        },
        // 开启循环定时器
        openSetInter(callback) {
            // 循环触发向右点击
            this.Timer = setInterval(() => {
                callback();
            }, this.interval)
        },
        // 关闭循环定时器
        closeSetInter() {
            if (this.Timer) {
                clearInterval(this.Timer);
                this.Timer = null;
            }
        }
    },

    watch: {
        autoPlay: {
            handler(newV) {
                if (typeof newV == 'boolean' && newV) {
                    // 开启定时(循环)
                    this.openSetInter(this.withRight)
                } else {
                    // 关闭定时(循环)
                    this.closeSetInter()
                }
            }, immediate: true
        },
        indicatorOutside: {
            handler(newV) {
                if (typeof newV == 'boolean') {
                    this.isOutSide = newV
                }
            }, immediate: true
        },
        indicator: {
            handler(newV) {
                if (typeof newV == 'boolean') {
                    this.isIndicator = newV
                }
            }, immediate: true
        },
        arrow: {
            handler(newV) {
                if (newV == 'show') {
                    this.arrowMode = 'show'
                } else if ((newV == 'hidden')) {
                    this.arrowMode = 'hidden'
                } else {
                    this.arrowMode = 'none'
                }
            }, immediate: true
        },
        'playControl.currentPage': {
            handler(newV) {
                this.$emit('change', newV)
            }
        }
    },

    // 关闭定时器
    beforeDestroy() {
        this.closeSetInter()
    }
}
</script>

<style lang='less' scoped>
@import '../../../css/carousel.less';
</style>

ty-carousel-item

<template>
    <div ref="item" class='ty-carousel-item' :style="{ 'transform': positionOffset }">
        <slot></slot>
    </div>
</template>

<script>
export default {
    name: 'ty-carousel-item',
    data() {
        return {
            // 偏移值
            positionOffset: 0,
        }
    },

    methods: {
        // 更新位置
        // params : 偏移值    是否垂直
        updatePositionNew(position, vertical) {
            this.positionOffset = vertical ? `translateY(${position * 100}%)` : `translateX(${position * 100}%)`
        }
    },
}
</script>

<style lang='less' scoped>
.ty-carousel-item {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    background-color: #d3dce6;
}
</style>