|
|
@@ -0,0 +1,453 @@
|
|
|
+<template>
|
|
|
+ <view class="v-tabs">
|
|
|
+ <scroll-view :id="getDomId" :scroll-x="scroll" :scroll-left="scroll ? scrollLeft : 0"
|
|
|
+ :scroll-with-animation="scroll" :style="{ position: fixed ? 'fixed' : 'relative', zIndex }">
|
|
|
+ <view class="v-tabs__container" :style="{
|
|
|
+ display: scroll ? 'inline-flex' : 'flex',
|
|
|
+ whiteSpace: scroll ? 'nowrap' : 'normal',
|
|
|
+ background: bgColor,
|
|
|
+ padding
|
|
|
+ }">
|
|
|
+ <view :class="['v-tabs__container-item', { disabled: !!v.disabled }, { active: current == i }]"
|
|
|
+ v-for="(v, i) in tabs" :key="i" :style="{
|
|
|
+ color: current == i ? activeColor : color,
|
|
|
+ fontSize: current == i ? fontSize : fontSize,
|
|
|
+ fontWeight: bold && current == i ? 'bold' : '',
|
|
|
+ justifyContent: !scroll ? 'center' : '',
|
|
|
+ flex: scroll ? '' : 1,
|
|
|
+ padding: paddingItem
|
|
|
+ }" @click="change(i,'tab')">
|
|
|
+ <view class="column-c">
|
|
|
+ <image class="wh-60 m-tb10 r-100" :class="current == i?'double-border':''"
|
|
|
+ style="display: block;" :src="v.icon" mode=""></image>
|
|
|
+ <slot :row="v" :index="i">{{ field ? v[field] : v }}</slot>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <template v-if="!!tabs.length">
|
|
|
+
|
|
|
+ <view v-if="lineSlot" class="lineSlot"
|
|
|
+ :class="['v-tabs__container-line', { animation: lineAnimation }]" :style="{
|
|
|
+ transform: `translate3d(${lineLeft}px, 0, 0)`
|
|
|
+ }">
|
|
|
+ <slot name="line" />
|
|
|
+ </view>
|
|
|
+ <view class="column-c" v-else-if="!pills"
|
|
|
+ :class="['v-tabs__container-line', { animation: lineAnimation }]" :style="{
|
|
|
+ width: lineWidth + 'px',
|
|
|
+ height: '30rpx',
|
|
|
+ borderRadius: lineRadius,
|
|
|
+ transform: `translate3d(${lineLeft}px, 0, 0)`
|
|
|
+ }">
|
|
|
+ <view class="triangle"></view>
|
|
|
+ </view>
|
|
|
+ <view v-else :class="['v-tabs__container-pills', { animation: lineAnimation }]" :style="{
|
|
|
+ background: pillsColor,
|
|
|
+ borderRadius: pillsBorderRadius,
|
|
|
+ width: currentWidth + 'px',
|
|
|
+ transform: `translate3d(${pillsLeft}px, 0, 0)`,
|
|
|
+ height
|
|
|
+ }" />
|
|
|
+ </template>
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+ <!-- fixed 的站位高度 -->
|
|
|
+ <view class="v-tabs__placeholder" :style="{ height: fixed ? height : '0', padding }"></view>
|
|
|
+ <view class="v-tabs__content" v-if="tabsSwiper">
|
|
|
+ <swiper class="activity-swiper" v-if="!swiperType" :current="value" :duration="duration"
|
|
|
+ :circular="circular" @change="onSwiperChange"
|
|
|
+ :style="{ height: contentHeight ? contentHeight : `calc(100vh - ${height} - ${paddingBottom})`, paddingBottom: paddingBottom, paddingTop: paddingTop}">
|
|
|
+ <swiper-item class="activity-swiper-item" v-for="(item,index) in tabs" :key="item.key"
|
|
|
+ :item-id="item.key" :style="{ paddingBottom: 0, paddingTop: 0 }">
|
|
|
+ <scroll-view scroll-y class="scroll-list" @scrolltolower="onBottom" :style="{ height: '100%' }">
|
|
|
+ <slot :index="index" name="swiperContent" />
|
|
|
+ </scroll-view>
|
|
|
+ </swiper-item>
|
|
|
+ </swiper>
|
|
|
+ <view class="data_list" @touchstart="touchStart" @touchend="touchEnd" :animation="animationData" v-else>
|
|
|
+ <view class="data_list_content" v-for="(item,index) in tabs" :key="index">
|
|
|
+ <scroll-view scroll-y class="scroll-list" @scrolltolower="onBottom"
|
|
|
+ :style="{ height: contentHeight ? contentHeight : `calc(100vh - ${height} - ${paddingBottom})`, paddingBottom: paddingBottom, paddingTop: paddingTop}">
|
|
|
+ <slot :index="index" name="swiperContent" />
|
|
|
+ </scroll-view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+ import {
|
|
|
+ startMicroTask
|
|
|
+ } from './utils'
|
|
|
+ import props from './props'
|
|
|
+ /**
|
|
|
+ * v-tabs
|
|
|
+ * @property {Number} value 选中的下标
|
|
|
+ * @property {Array} tabs tabs 列表
|
|
|
+ * @property {String} bgColor = '#fff' 背景颜色
|
|
|
+ * @property {String} color = '#333' 默认颜色
|
|
|
+ * @property {String} activeColor = '#2979ff' 选中文字颜色
|
|
|
+ * @property {String} fontSize = '28rpx' 默认文字大小
|
|
|
+ * @property {String} activeFontSize = '28rpx' 选中文字大小
|
|
|
+ * @property {Boolean} bold = [true | false] 选中文字是否加粗
|
|
|
+ * @property {Boolean} scroll = [true | false] 是否滚动
|
|
|
+ * @property {String} height = '60rpx' tab 的高度
|
|
|
+ * @property {String} lineHeight = '10rpx' 下划线的高度
|
|
|
+ * @property {String} lineColor = '#2979ff' 下划线的颜色
|
|
|
+ * @property {Number} lineScale = 0.5 下划线的宽度缩放比例
|
|
|
+ * @property {String} lineRadius = '10rpx' 下划线圆角
|
|
|
+ * @property {Boolean} pills = [true | false] 是否胶囊样式
|
|
|
+ * @property {String} pillsColor = '#2979ff' 胶囊背景色
|
|
|
+ * @property {String} pillsBorderRadius = '10rpx' 胶囊圆角大小
|
|
|
+ * @property {String} field 如果是对象,显示的键名
|
|
|
+ * @property {Boolean} fixed = [true | false] 是否固定
|
|
|
+ * @property {String} paddingItem = '0 22rpx' 选项的边距
|
|
|
+ * @property {Boolean} lineAnimation = [true | false] 下划线是否有动画
|
|
|
+ * @property {Number} zIndex = 1993 默认层级
|
|
|
+ * @property {Boolean} lineSlot true 是否自定义底部滑块
|
|
|
+ * @property {Boolean} tabsSwiper false 是否使用swiper
|
|
|
+ * @property {Boolean} swiperType false swiper类型
|
|
|
+ * @property {Boolean} circular false 是否采用衔接滑动,即播放到末尾后重新回到开头
|
|
|
+ * @property {String} paddingBottom 0rpx swiper下边padding
|
|
|
+ * @property {String} paddingTop 0rpx swiper上边padding
|
|
|
+ * @property {Number} duration 500 swiper滑动动画时长
|
|
|
+ * @property {String} contentHeight '' swiper内容高度
|
|
|
+ *
|
|
|
+ * @event {Function(current)} change 改变标签触发
|
|
|
+ */
|
|
|
+ export default {
|
|
|
+ name: 'kTabsSwiper',
|
|
|
+ props,
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ lineWidth: 30,
|
|
|
+ currentWidth: 0, // 当前选项的宽度
|
|
|
+ lineLeft: 0, // 滑块距离左侧的位置
|
|
|
+ pillsLeft: 0, // 胶囊距离左侧的位置
|
|
|
+ scrollLeft: 0, // 距离左边的位置
|
|
|
+ container: {
|
|
|
+ width: 0,
|
|
|
+ height: 0,
|
|
|
+ left: 0,
|
|
|
+ right: 0
|
|
|
+ }, // 容器的宽高,左右距离
|
|
|
+ current: 0, // 当前选中项
|
|
|
+ scrollWidth: 0, // 可以滚动的宽度
|
|
|
+ lineSlotWidth: 0, //自定义底部的宽度
|
|
|
+ startX: 0,
|
|
|
+ startY: 0,
|
|
|
+ animationData: {}, // 动画
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ getDomId() {
|
|
|
+ const len = 16
|
|
|
+ const $chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678' /****默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1****/
|
|
|
+ const maxPos = $chars.length
|
|
|
+ let pwd = ''
|
|
|
+ for (let i = 0; i < len; i++) {
|
|
|
+ pwd += $chars.charAt(Math.floor(Math.random() * maxPos))
|
|
|
+ }
|
|
|
+ return `xfjpeter_${pwd}`
|
|
|
+ }
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ value: {
|
|
|
+ immediate: true,
|
|
|
+ handler(newVal) {
|
|
|
+ this.current = newVal
|
|
|
+ this.$nextTick(this.update)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ initScroll() {
|
|
|
+ this.scrollY = new Array(10).fill(0);
|
|
|
+ },
|
|
|
+ touchStart(event) {
|
|
|
+ this.startX = event.touches[0].pageX;
|
|
|
+ this.startY = event.touches[0].pageY;
|
|
|
+ },
|
|
|
+ touchEnd(event) {
|
|
|
+ let deltaX = event.changedTouches[0].pageX - this.startX;
|
|
|
+ let deltaY = event.changedTouches[0].pageY - this.startY;
|
|
|
+ if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 60) {
|
|
|
+ if (deltaX < 0) { //往左
|
|
|
+ if (this.current == this.tabs.length - 1) {
|
|
|
+ if (this.circular) {
|
|
|
+ this.change(0);
|
|
|
+ } else {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ this.change(this.current * 1 + 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.animate();
|
|
|
+
|
|
|
+ } else if (deltaX > 0) { //往右
|
|
|
+ if (this.current == 0) {
|
|
|
+ if (this.circular) {
|
|
|
+ this.change(this.tabs.length - 1);
|
|
|
+ } else {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ this.change(this.current * 1 - 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.animate();
|
|
|
+
|
|
|
+ } else { // 挪动距离0
|
|
|
+
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onSwiperChange(e) {
|
|
|
+ this.change(e.detail.current);
|
|
|
+ },
|
|
|
+ onBottom() {
|
|
|
+ this.$emit("load", this.current)
|
|
|
+ },
|
|
|
+ animate() {
|
|
|
+ this.animation.translateX(-this.swiperWidthPx * this.current + "px").step({
|
|
|
+ duration: this.duration
|
|
|
+ })
|
|
|
+ this.animationData = this.animation.export()
|
|
|
+ setTimeout(() => {
|
|
|
+ this.animationData = {}
|
|
|
+ }, this.duration)
|
|
|
+ },
|
|
|
+ // 切换事件
|
|
|
+ change(index, source) {
|
|
|
+
|
|
|
+ const isDisabled = !!this.tabs[index].disabled
|
|
|
+ if (this.current !== index && !isDisabled) {
|
|
|
+ this.current = index
|
|
|
+ this.$emit('input', index)
|
|
|
+ this.$emit('change', index)
|
|
|
+ }
|
|
|
+ //如果是tab切换
|
|
|
+ if (this.swiperType && source == 'tab') {
|
|
|
+ this.animate();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ createQueryHandler() {
|
|
|
+ const query = uni
|
|
|
+ .createSelectorQuery()
|
|
|
+ // #ifndef MP-ALIPAY
|
|
|
+ .in(this)
|
|
|
+ // #endif
|
|
|
+
|
|
|
+ return query
|
|
|
+ },
|
|
|
+ update() {
|
|
|
+ const _this = this
|
|
|
+ startMicroTask(() => {
|
|
|
+ // 没有列表的时候,不执行
|
|
|
+ if (!this.tabs.length) return
|
|
|
+ _this
|
|
|
+ .createQueryHandler()
|
|
|
+ .select(`#${this.getDomId}`)
|
|
|
+ .boundingClientRect(data => {
|
|
|
+ const {
|
|
|
+ width,
|
|
|
+ height,
|
|
|
+ left,
|
|
|
+ right
|
|
|
+ } = data || {}
|
|
|
+ // 获取容器的相关属性
|
|
|
+ this.container = {
|
|
|
+ width,
|
|
|
+ height,
|
|
|
+ left,
|
|
|
+ right: right - width
|
|
|
+ }
|
|
|
+ _this.lineScrollWidth();
|
|
|
+ _this.calcScrollWidth()
|
|
|
+ _this.setScrollLeft()
|
|
|
+ _this.setLine()
|
|
|
+ if (this.swiperType) {
|
|
|
+ _this.getSwiperWidth();
|
|
|
+ _this.initScroll();
|
|
|
+ // 创建动画实例
|
|
|
+ _this.animation = uni.createAnimation({
|
|
|
+ timingFunction: 'ease',
|
|
|
+ duration: 120
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .exec()
|
|
|
+ })
|
|
|
+ },
|
|
|
+ getSwiperWidth() {
|
|
|
+ this.createQueryHandler()
|
|
|
+ .select(`.v-tabs__content`)
|
|
|
+ .boundingClientRect(data => {
|
|
|
+ if (!data) return;
|
|
|
+ this.swiperWidthPx = data.width;
|
|
|
+ })
|
|
|
+ .exec()
|
|
|
+ },
|
|
|
+ // 计算可以滚动的宽度
|
|
|
+ calcScrollWidth(callback) {
|
|
|
+ const view = this.createQueryHandler().select(`#${this.getDomId}`)
|
|
|
+ view.fields({
|
|
|
+ scrollOffset: true
|
|
|
+ })
|
|
|
+ view
|
|
|
+ .scrollOffset(res => {
|
|
|
+ if (typeof callback === 'function') {
|
|
|
+ callback(res)
|
|
|
+ } else {
|
|
|
+ // 获取滚动条的宽度
|
|
|
+ this.scrollWidth = res.scrollWidth
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .exec()
|
|
|
+ },
|
|
|
+ // 设置滚动条滚动的进度
|
|
|
+ setScrollLeft() {
|
|
|
+ this.calcScrollWidth(res => {
|
|
|
+ // 动态读取 scrollLeft
|
|
|
+ let scrollLeft = res.scrollLeft
|
|
|
+ this.createQueryHandler()
|
|
|
+ .select(`#${this.getDomId} .v-tabs__container-item.active`)
|
|
|
+ .boundingClientRect(data => {
|
|
|
+ if (!data) return
|
|
|
+ // 除开当前选项外容器的一半宽度
|
|
|
+ let curHalfWidth = (this.container.width - data.width) / 2
|
|
|
+ let scrollDiff = this.scrollWidth - this.container.width
|
|
|
+ // 在原有滚动条的基础上 + (当前元素距离左侧的距离 - 计算的一半宽度) - 容器的外边距之类的
|
|
|
+ scrollLeft += data.left - curHalfWidth - this.container.left
|
|
|
+ // 已经滚动在左侧了
|
|
|
+ if (scrollLeft < 0) scrollLeft = 0
|
|
|
+ // 已经超出右侧了
|
|
|
+ else if (scrollLeft > scrollDiff) scrollLeft = scrollDiff
|
|
|
+ this.scrollLeft = scrollLeft
|
|
|
+ })
|
|
|
+ .exec()
|
|
|
+ })
|
|
|
+ },
|
|
|
+ lineScrollWidth() {
|
|
|
+ this.createQueryHandler()
|
|
|
+ .select(`#${this.getDomId} .lineSlot`)
|
|
|
+ .boundingClientRect(data => {
|
|
|
+ if (!data) return;
|
|
|
+ this.lineSlotWidth = data.width;
|
|
|
+ })
|
|
|
+ .exec()
|
|
|
+ },
|
|
|
+ setLine() {
|
|
|
+ this.calcScrollWidth(res => {
|
|
|
+ const scrollLeft = res.scrollLeft
|
|
|
+ this.createQueryHandler()
|
|
|
+ .select(`#${this.getDomId} .v-tabs__container-item.active`)
|
|
|
+ .boundingClientRect(data => {
|
|
|
+ if (!data) return
|
|
|
+ if (this.lineSlot) {
|
|
|
+ this.lineWidth = data.width * this.lineScale
|
|
|
+ this.lineLeft = scrollLeft + data.left + data.width / 2 - this.lineSlotWidth /
|
|
|
+ 2 - this.container.left
|
|
|
+ } else if (this.pills) {
|
|
|
+ this.currentWidth = data.width
|
|
|
+ this.pillsLeft = scrollLeft + data.left - this.container.left
|
|
|
+ } else {
|
|
|
+ this.lineWidth = data.width * this.lineScale
|
|
|
+ this.lineLeft = scrollLeft + data.left + (data.width - data.width * this
|
|
|
+ .lineScale) / 2 - this.container.left
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .exec()
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+ .v-tabs {
|
|
|
+ width: 100%;
|
|
|
+ box-sizing: border-box;
|
|
|
+ overflow: hidden;
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 0 20rpx;
|
|
|
+ background: #fff;
|
|
|
+
|
|
|
+ /* #ifdef H5 */
|
|
|
+ ::-webkit-scrollbar {
|
|
|
+ display: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* #endif */
|
|
|
+
|
|
|
+ &__container {
|
|
|
+ min-width: 100%;
|
|
|
+ position: relative;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ white-space: nowrap;
|
|
|
+ overflow: hidden;
|
|
|
+ padding: 20rpx !important;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: #fff;
|
|
|
+
|
|
|
+ &-item {
|
|
|
+ flex-shrink: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ height: 100%;
|
|
|
+ position: relative;
|
|
|
+ z-index: 10;
|
|
|
+ transition: all 0.3s;
|
|
|
+ white-space: nowrap;
|
|
|
+ padding: 10rpx 40rpx !important;
|
|
|
+
|
|
|
+ &.disabled {
|
|
|
+ opacity: 0.5;
|
|
|
+ color: #999;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &-line {
|
|
|
+ position: absolute;
|
|
|
+ left: 0;
|
|
|
+ bottom: -20rpx;
|
|
|
+ }
|
|
|
+
|
|
|
+ &-pills {
|
|
|
+ position: absolute;
|
|
|
+ z-index: 9;
|
|
|
+ }
|
|
|
+
|
|
|
+ &-line,
|
|
|
+ &-pills {
|
|
|
+ &.animation {
|
|
|
+ transition: all 0.3s linear;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .data_list {
|
|
|
+ display: flex;
|
|
|
+
|
|
|
+ &_content {
|
|
|
+ flex-shrink: 0;
|
|
|
+ width: 100%;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .triangle {
|
|
|
+ width: 20rpx;
|
|
|
+ height: 20rpx;
|
|
|
+ background: #10B261;
|
|
|
+ border-radius: 50%;
|
|
|
+ }
|
|
|
+
|
|
|
+ .double-border {
|
|
|
+ box-shadow: 0 0 0 6rpx #fff, 0 0 0 12rpx #10B261;
|
|
|
+ }
|
|
|
+</style>
|