uniapp开发微信小程序自定义Picker组件

在平时使用uniapp开发微信小程序时,我们会经常使用到Picker组件,虽然原生Picker组件的功能很强大,但是在一些业务上以及在深色主题颜色下就显得没那么灵活了,因为原生Picker和Picker-view组件不支持修改指示器上下蒙层的背景颜色,这个时候我们就得自定义一个Picker组件以适应小程序的主题和业务,
下面我们使用uniapp进行自定义Picker组件,该Picker组件支持单列和多列,主要就是使用scroll-view来进行滚动效果,下面是uniapp(vue2版本)的自定义Picker组件代码,需要vue3版本的可以利用Ai进行转换
<template>
  <view v-if="show" class="mp-picker">
    <!-- 遮罩层 -->
    <view class="mp-mask" @tap="cancel"></view>

    <!-- 主体 -->
    <view class="mp-body" :class="{ show: visible }">
      <view class="mp-toolbar">
        <text class="mp-btn" @tap="cancel">取消</text>
        <text class="mp-title">{{ title }}</text>
        <text class="mp-btn sure" @tap="confirm">确定</text>
      </view>

      <view class="mp-columns">
        <view
          v-for="(col, idx) in renderColumns"
          :key="idx"
          class="mp-column"
          :style="{ width: columnWidth(idx) }"
        >
          <!-- 选中区域上下边框 -->
          <view class="mp-indicator">
            <view class="mp-indicator-top"></view>
            <view class="mp-indicator-bottom"></view>
          </view>
          
          <!-- 顶部蒙层 -->
          <view class="mp-overlay-top"></view>
          
          <!-- 滚动容器 -->
          <view class="scroll-container">
            <scroll-view
              class="mp-scroll"
              scroll-y
              :scroll-top="scrollTop[idx]"
              :scroll-with-animation="true"
              @scroll="e => onScroll(e, idx)"
              @scrollend="e => onScrollEnd(e, idx)"
            >
              <view
                v-for="(item, i) in col"
                :key="i"
                class="mp-item-wrapper"
              >
                <view
                  class="mp-item"
                  :class="{ 
                    active: current[idx] === i,
                    disabled: !isValidIndex(i, idx)
                  }"
                  @click="selectItem(i, idx)"
                >
                  {{ item.label || (item.value !== undefined ? item.value : '') }}
                </view>
              </view>
            </scroll-view>
          </view>
          
          <!-- 底部蒙层 -->
          <view class="mp-overlay-bottom"></view>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
const ITEM_HEIGHT = 34; // 需与CSS变量保持一致
const VISIBLE_COUNT = 7; // 需与CSS变量保持一致

export default {
  name: 'MpPicker',
  props: {
    show: { type: Boolean, default: false },
    title: { type: String, default: '' },
    columns: { type: Array, default: () => [] },
    defaultIndex: { type: [Number, Array], default: 0 }
  },
  data() {
    return {
      visible: false,
      current: [],
      scrollTop: [],
      isScrolling: false,
      scrollTimer: null,
      snapTarget: [],
      PADDING_COUNT: 3,
      lastScrollTime: 0
    };
  },
  computed: {
    normalizedColumns() {
      if (!this.columns.length) return [];
      
      // 确保每列都是数组,统一数据结构
      return this.columns.every(col => Array.isArray(col)) 
        ? this.columns.map(col => col.map(item => 
            typeof item === 'string' ? { label: item, value: item } : item
          ))
        : [this.columns.map(item => 
            typeof item === 'string' ? { label: item, value: item } : item
          )];
    },
    renderColumns() {
      const empty = Array(this.PADDING_COUNT).fill({ label: '', value: '', __isBlank: true });
      return this.normalizedColumns.map(col => [
        ...empty, 
        ...col, 
        ...empty
      ]);
    }
  },
  watch: {
    show(val) {
      if (val) {
        this.init();
        this.$nextTick(() => {
          this.visible = true;
          // 初始化后强制校准每列位置
          this.normalizedColumns.forEach((_, colIdx) => {
            this.ensureItemVisible(colIdx);
          });
        });
      } else {
        this.visible = false;
      }
    },
    columns: {
      handler() {
        if (this.show) this.init();
      },
      deep: true
    },
    snapTarget: {
      handler() {
        this.$nextTick(() => {
          this.snapTarget.forEach((target, colIdx) => {
            if (target !== null && this.current[colIdx] !== target) {
              this.$set(this.current, colIdx, target);
              this.ensureItemVisible(colIdx);
            }
          });
        });
      },
      deep: true
    }
  },
  methods: {
    columnWidth(colIdx) {
      const colCount = this.normalizedColumns.length;
      return colCount > 1 ? `${100 / colCount}%` : '100%';
    },
    isValidIndex(index, colIdx) {
      const realCol = this.normalizedColumns[colIdx] || [];
      return index >= this.PADDING_COUNT && index < this.PADDING_COUNT + realCol.length;
    },
    toRealIndex(blankIndex) {
      const realIndex = blankIndex - this.PADDING_COUNT;
      return Math.max(0, realIndex); // 确保不小于0
    },
    toBlankIndex(realIndex, colIdx) {
      const realCol = this.normalizedColumns[colIdx] || [];
      const maxRealIndex = realCol.length - 1;
      const safeRealIndex = Math.min(Math.max(realIndex, 0), maxRealIndex < 0 ? 0 : maxRealIndex);
      return safeRealIndex + this.PADDING_COUNT;
    },
    init() {
      const colCount = this.normalizedColumns.length;
      this.current = [];
      this.scrollTop = [];
      this.snapTarget = [];

      // 处理defaultIndex,确保为数组且元素为有效数字
      const normalizedDefaultIndex = Array.isArray(this.defaultIndex) 
        ? [...this.defaultIndex].map(idx => Math.floor(Number(idx) || 0))
        : [Math.floor(Number(this.defaultIndex) || 0)];
      
      // 补全defaultIndex至与列数相同
      while (normalizedDefaultIndex.length < colCount) {
        normalizedDefaultIndex.push(0);
      }

      this.normalizedColumns.forEach((realCol, colIdx) => {
        // 处理空列情况
        if (realCol.length === 0) {
          console.warn(`MpPicker: 第 ${colIdx} 列数据为空`);
          this.current.push(this.PADDING_COUNT);
          this.scrollTop.push(this.calcCenterScrollTop(this.PADDING_COUNT));
          this.snapTarget.push(this.PADDING_COUNT);
          return;
        }

        let realIndex = normalizedDefaultIndex[colIdx] || 0;
        // 确保realIndex在有效范围内
        realIndex = Math.max(0, Math.min(realIndex, realCol.length - 1));
        const blankIndex = this.toBlankIndex(realIndex, colIdx);

        this.current.push(blankIndex);
        // 计算居中的滚动位置
        this.scrollTop.push(this.calcCenterScrollTop(blankIndex));
        this.snapTarget.push(blankIndex);
      });
    },
    // 计算让指定索引项居中的滚动位置
    calcCenterScrollTop(blankIndex) {
      // 中间位置偏移量 = (可见数量 - 1) / 2 * 每项高度
      const centerOffset = (VISIBLE_COUNT - 1) / 2 * ITEM_HEIGHT;
      return blankIndex * ITEM_HEIGHT - centerOffset;
    },
    ensureItemVisible(colIdx) {
      const idx = this.current[colIdx];
      const renderCol = this.renderColumns[colIdx] || [];
      // 计算居中的滚动位置
      const centerOffset = (VISIBLE_COUNT - 1) / 2 * ITEM_HEIGHT;
      let top = idx * ITEM_HEIGHT - centerOffset;
      
      // 限制滚动位置在有效范围内(不超出顶部和底部)
      const maxScrollTop = Math.max(0, (renderCol.length - VISIBLE_COUNT) * ITEM_HEIGHT);
      top = Math.min(Math.max(0, top), maxScrollTop);
      
      // 只有当位置变化明显时才更新(避免无效渲染)
      if (Math.abs(this.scrollTop[colIdx] - top) > 1) {
        this.$set(this.scrollTop, colIdx, top);
      }
    },
    onScroll(e, col) {
      this.isScrolling = true;
      const y = e.detail.scrollTop;
      const renderCol = this.renderColumns[col] || [];
      const realCol = this.normalizedColumns[col] || [];
      if (!renderCol.length) return;

      const centerIndex = Math.round((y + (VISIBLE_COUNT - 1) / 2 * ITEM_HEIGHT) / ITEM_HEIGHT);
      const maxValidIndex = this.PADDING_COUNT + realCol.length - 1;

      clearTimeout(this.scrollTimer);
      this.scrollTimer = setTimeout(() => {
          this.isScrolling = false;
          this.onScrollEnd(e, col);
      }, 200);
      this.lastScrollTime = Date.now();
    },
    onScrollEnd(e, col) {
      console.log('--onScrollEnd--', e, col); 
      this.isScrolling = false;
      const y = e.detail.scrollTop;
      const realCol = this.normalizedColumns[col] || [];
      const centerIndex = Math.round((y + (VISIBLE_COUNT - 1) / 2 * ITEM_HEIGHT) / ITEM_HEIGHT);
      const maxValidIndex = this.PADDING_COUNT + realCol.length - 1;
      const validIndex = Math.min(Math.max(centerIndex, this.PADDING_COUNT), maxValidIndex);

      if (this.snapTarget[col] !== validIndex) {
        this.$set(this.snapTarget, col, validIndex);
      }
      if (this.current[col] !== validIndex) {
        this.$set(this.current, col, validIndex);
      }
      this.ensureItemVisible(col);
    },
    selectItem(index, colIdx) {
      if (this.isValidIndex(index, colIdx)) {
        this.$set(this.current, colIdx, index);
        this.$set(this.snapTarget, colIdx, index);
        this.ensureItemVisible(colIdx);
      }
    },
    confirm() {
      const value = [];
      const index = [];
      const selectedValues = [];

      this.current.forEach((blankIndex, colIdx) => {
        const realCol = this.normalizedColumns[colIdx] || [];
        const realIndex = this.toRealIndex(blankIndex);
        
        // 处理空列情况
        if (realCol.length === 0) {
          value.push(null);
          index.push(-1);
          selectedValues.push(null);
          return;
        }
        
        // 确保获取有效数据
        const item = realCol[realIndex] || realCol[0] || { label: '', value: '' };

        index.push(realIndex);
        value.push(item);
        selectedValues.push(item.value !== undefined ? item.value : item);
      });

      const result = this.normalizedColumns.length === 1 
        ? { value: value[0], index: index[0], selectedValue: selectedValues[0] }
        : { value, index, selectedValues };

      this.$emit('confirm', result);
    },
    cancel() {
      this.$emit('cancel');
    }
  },
  beforeUnmount() {
    clearTimeout(this.scrollTimer);
  }
};
</script>

<style scoped>
/* 使用CSS变量统一管理高度相关值 */
:root {
  --item-height: 34px;
  --visible-count: 7;
}

/* 深色主题主色调 #47433F */
.mp-picker {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 999;
}
.mp-mask {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.6); /* 深色遮罩 */
}
.mp-body {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  background: #47433F; /* 主体深色背景 */
  transform: translateY(100%);
  transition: transform 0.3s;
  border-radius: 16rpx 16rpx 0 0;
}
.mp-body.show {
  transform: translateY(0);
}
.mp-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 44px;
  border-bottom: 1rpx solid #5c5854; /* 深色边框 */
  padding: 0 12px;
}
.mp-btn {
  font-size: 15px;
  color: #ccc; /* 取消按钮浅色文字 */
  padding: 8px 16px;
}
.mp-btn.sure {
  color: #C8D92C;
}
.mp-title {
  font-size: 16px;
  color: #fff; /* 标题白色文字 */
  font-weight: 500;
}
.mp-columns {
  display: flex;
  /* 高度 = 可见数量 * 每项高度 */
  height: calc(var(--visible-count) * var(--item-height));
  overflow: hidden;
}
.mp-column {
  flex-shrink: 0;
  position: relative;
  height: 100%;
}
/* 选中区域指示器 */
.mp-indicator {
  position: absolute;
  left: 0;
  right: 0;
  /* 顶部位置 = (可见数量 - 1) / 2 * 每项高度 */
  top: calc((var(--visible-count) - 1) / 2 * var(--item-height));
  height: var(--item-height);
  pointer-events: none;
  z-index: 10;
}
.mp-indicator-top,
.mp-indicator-bottom {
  position: absolute;
  left: 0;
  right: 0;
  height: 1rpx;
  background: #666; /* 深色边框 */
}
.mp-indicator-top {
  top: 0;
}
.mp-indicator-bottom {
  bottom: 0;
}
/* 顶部蒙层 - 深色渐变 */
.mp-overlay-top {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  /* 高度 = (可见数量 - 1) / 2 * 每项高度 */
  height: calc((var(--visible-count) - 1) / 2 * var(--item-height));
  background: linear-gradient(to bottom, #47433F 0%, rgba(71, 67, 63, 0.8) 60%, rgba(71, 67, 63, 0) 100%);
  pointer-events: none;
  z-index: 5;
}
/* 底部蒙层 - 深色渐变 */
.mp-overlay-bottom {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  /* 高度 = (可见数量 - 1) / 2 * 每项高度 */
  height: calc((var(--visible-count) - 1) / 2 * var(--item-height));
  background: linear-gradient(to top, #47433F 0%, rgba(71, 67, 63, 0.8) 60%, rgba(71, 67, 63, 0) 100%);
  pointer-events: none;
  z-index: 5;
}
/* 滚动容器 */
.scroll-container {
  width: 100%;
  height: 100%;
  overflow: hidden;
  position: relative;
}
/* 隐藏滚动条 */
.mp-scroll {
  width: calc(100% + 20rpx);
  height: 100%;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}
.mp-scroll::-webkit-scrollbar {
  display: none;
  width: 0;
  height: 0;
  background: transparent;
}
.mp-scroll {
  scrollbar-width: none;
  scrollbar-color: transparent transparent;
}
.mp-scroll::-ms-scrollbar {
  display: none;
}
.mp-scroll::-ms-track {
  background: transparent;
}
.mp-item-wrapper {
  position: relative;
}
.mp-item {
  height: var(--item-height);
  line-height: var(--item-height);
  text-align: center;
  font-size: 16px;
  color: #e0e0e0; /* 浅色文字 */
  padding: 0 8px;
}
.mp-item.active {
  color: #ffffff;
  font-weight: 500;
}
.mp-item.disabled {
  color: transparent;
}
</style>

使用方法

// 导入Picker组件
import Picker from "@/components/Picker/index.vue";

// 在需要的地方使用组件
/**
*show {Boolean} -- 控制组件的显示和隐藏
*defaultIndex {Array} -- 默认展示的索引,单列的数组里有一个元素,两列的有两个元素
*columns {Array} -- 数据项 ,可以支持一维数组和二维数组,如[{value: 1, label: 1},{value:2, *label: 2}]
*confirm -- 确定点击事件触发
*cancel -- 取消点击事件触发
*/
<pickerCom 
   :show="pickerShow" 
   :defaultIndex="[0, 0]" 
   :columns="Columns" 
   @confirm="confirmFunction" 
   @cancel="cancelFunction"
></pickerCom>

下面是效果图

在这里插入图片描述

更多推荐