UniApp多功能选择器:打造符合鸿蒙设计规范的高性能组件

在移动应用开发中,选择器是最常用的交互组件之一。本文将深入探讨如何在UniApp中实现一套功能丰富、性能出色且符合鸿蒙设计规范的选择器组件库。我们将从基础选择器开始,逐步深入到更复杂的场景应用。

为什么需要自定义选择器?

虽然UniApp提供了基础的选择器组件,但在实际开发中往往难以满足产品的个性化需求:

  1. 样式定制有限,难以适配产品设计规范
  2. 交互体验不够流畅,特别是在鸿蒙系统上
  3. 功能相对简单,无法满足复杂的业务场景
  4. 性能优化空间有限,在大数据量时表现欠佳

技术方案设计

1. 核心功能规划

  • 支持单选、多选、级联选择等多种模式
  • 提供平滑的动画效果和触控反馈
  • 优化大数据量下的渲染性能
  • 适配鸿蒙系统的交互特性

2. 基础选择器组件

首先实现一个基础的单选选择器:

<!-- components/base-picker/base-picker.vue -->
<template>
  <view 
    class="picker-container"
    :class="{ 
      'picker-container--harmony': isHarmonyOS,
      'picker-container--visible': visible 
    }"
  >
    <!-- 蒙层 -->
    <view 
      class="picker-mask"
      @tap="handleMaskTap"
      :style="{ opacity: maskOpacity }"
    ></view>
    
    <!-- 选择器主体 -->
    <view 
      class="picker-content"
      :class="{ 'picker-content--harmony': isHarmonyOS }"
      :style="contentStyle"
    >
      <!-- 顶部工具栏 -->
      <view class="picker-toolbar">
        <text 
          class="picker-cancel"
          @tap="handleCancel"
        >取消</text>
        <text class="picker-title">{{ title }}</text>
        <text 
          class="picker-confirm"
          @tap="handleConfirm"
        >确定</text>
      </view>
      
      <!-- 选项列表 -->
      <view 
        class="picker-list"
        @touchstart="handleTouchStart"
        @touchmove="handleTouchMove"
        @touchend="handleTouchEnd"
      >
        <view 
          class="picker-item"
          v-for="(item, index) in options"
          :key="index"
          :style="getItemStyle(index)"
        >
          {{ item.label }}
        </view>
        
        <!-- 选中区域指示器 -->
        <view class="picker-indicator"></view>
      </view>
    </view>
  </view>
</template>

<script>
const ITEM_HEIGHT = 44 // 选项高度
const VISIBLE_ITEMS = 5 // 可见选项数量

export default {
  name: 'BasePicker',
  props: {
    // 选择器标题
    title: {
      type: String,
      default: '请选择'
    },
    // 选项列表
    options: {
      type: Array,
      default: () => []
    },
    // 默认选中值
    value: {
      type: [String, Number],
      default: ''
    },
    // 是否显示
    visible: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      isHarmonyOS: false,
      currentIndex: 0,
      scrollOffset: 0,
      touchStartY: 0,
      lastTouchY: 0,
      scrollAnimation: null,
      maskOpacity: 0
    }
  },
  computed: {
    contentStyle() {
      return {
        transform: `translate3d(0, ${this.visible ? '0' : '100%'}, 0)`
      }
    },
    selectedValue() {
      return this.options[this.currentIndex]?.value
    }
  },
  watch: {
    value: {
      immediate: true,
      handler(newVal) {
        const index = this.options.findIndex(item => item.value === newVal)
        if (index > -1) {
          this.currentIndex = index
          this.scrollOffset = -index * ITEM_HEIGHT
        }
      }
    },
    visible(newVal) {
      if (newVal) {
        this.maskOpacity = 0.5
      } else {
        this.maskOpacity = 0
      }
    }
  },
  created() {
    // 检测系统类型
    const systemInfo = uni.getSystemInfoSync()
    this.isHarmonyOS = systemInfo.osName === 'HarmonyOS'
    
    // 鸿蒙系统特殊处理
    if (this.isHarmonyOS) {
      this.initHarmonyOptimization()
    }
  },
  methods: {
    initHarmonyOptimization() {
      // 优化动画性能
      this.animationDuration = 200 // 更快的动画响应
      this.touchSensitivity = 1.2 // 提高触控灵敏度
      
      // 启用硬件加速
      this.enableHardwareAcceleration()
    },
    enableHardwareAcceleration() {
      // 针对鸿蒙系统的硬件加速优化
      if (this.isHarmonyOS) {
        this.accelerationStyle = {
          transform: 'translateZ(0)',
          'will-change': 'transform'
        }
      }
    },
    getItemStyle(index) {
      const offset = this.scrollOffset + index * ITEM_HEIGHT
      const translateY = `translateY(${offset}px)`
      
      // 计算透明度
      const center = ITEM_HEIGHT * Math.floor(VISIBLE_ITEMS / 2)
      const distance = Math.abs(offset - center)
      const opacity = Math.max(0, 1 - distance / (ITEM_HEIGHT * 2))
      
      return {
        transform: translateY,
        opacity: opacity.toFixed(2)
      }
    },
    handleTouchStart(event) {
      this.touchStartY = event.touches[0].pageY
      this.lastTouchY = this.touchStartY
      
      // 清除惯性滚动
      if (this.scrollAnimation) {
        cancelAnimationFrame(this.scrollAnimation)
      }
    },
    handleTouchMove(event) {
      const currentY = event.touches[0].pageY
      const deltaY = currentY - this.lastTouchY
      this.lastTouchY = currentY
      
      // 应用灵敏度系数
      const sensitivity = this.isHarmonyOS ? this.touchSensitivity : 1
      
      // 更新滚动位置
      this.updateScrollPosition(deltaY * sensitivity)
    },
    handleTouchEnd(event) {
      const endY = event.changedTouches[0].pageY
      const velocity = (endY - this.touchStartY) / 16 // 计算滑动速度
      
      // 处理惯性滚动
      this.handleInertiaScroll(velocity)
    },
    updateScrollPosition(delta) {
      const newOffset = this.scrollOffset + delta
      
      // 限制滚动范围
      const maxOffset = 0
      const minOffset = -(this.options.length - 1) * ITEM_HEIGHT
      
      this.scrollOffset = Math.max(minOffset, Math.min(maxOffset, newOffset))
      
      // 更新当前选中项
      this.currentIndex = Math.round(-this.scrollOffset / ITEM_HEIGHT)
    },
    handleInertiaScroll(velocity) {
      let currentVelocity = velocity
      
      const animate = () => {
        if (Math.abs(currentVelocity) < 0.1) {
          this.scrollToNearestItem()
          return
        }
        
        currentVelocity *= 0.95 // 减速因子
        this.updateScrollPosition(currentVelocity)
        
        this.scrollAnimation = requestAnimationFrame(animate)
      }
      
      animate()
    },
    scrollToNearestItem() {
      const targetOffset = -this.currentIndex * ITEM_HEIGHT
      const distance = targetOffset - this.scrollOffset
      
      if (Math.abs(distance) < 0.5) {
        this.scrollOffset = targetOffset
        return
      }
      
      // 平滑滚动到目标位置
      const animate = () => {
        const currentDistance = targetOffset - this.scrollOffset
        const step = currentDistance * 0.3
        
        if (Math.abs(step) < 0.5) {
          this.scrollOffset = targetOffset
          return
        }
        
        this.scrollOffset += step
        this.scrollAnimation = requestAnimationFrame(animate)
      }
      
      animate()
    },
    handleMaskTap() {
      this.$emit('cancel')
    },
    handleCancel() {
      this.$emit('cancel')
    },
    handleConfirm() {
      this.$emit('confirm', {
        value: this.selectedValue,
        index: this.currentIndex
      })
    }
  }
}
</script>

<style>
.picker-container {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 999;
  visibility: hidden;
}

.picker-container--visible {
  visibility: visible;
}

.picker-mask {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background-color: rgba(0, 0, 0, 0.5);
  transition: opacity 0.3s ease-out;
}

.picker-content {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: #fff;
  transform: translate3d(0, 100%, 0);
  transition: transform 0.3s ease-out;
}

.picker-content--harmony {
  border-radius: 24px 24px 0 0;
  background-color: #f8f8f8;
}

.picker-toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 44px;
  padding: 0 16px;
  border-bottom: 1px solid #eee;
}

.picker-title {
  font-size: 16px;
  color: #333;
}

.picker-cancel,
.picker-confirm {
  font-size: 14px;
  padding: 8px;
}

.picker-cancel {
  color: #666;
}

.picker-confirm {
  color: #007AFF;
}

.picker-content--harmony .picker-confirm {
  color: #0A59F7;
}

.picker-list {
  position: relative;
  height: calc(44px * 5);
  overflow: hidden;
}

.picker-item {
  position: absolute;
  left: 0;
  right: 0;
  height: 44px;
  line-height: 44px;
  text-align: center;
  font-size: 16px;
  color: #333;
  transition: transform 0.3s ease-out;
}

.picker-indicator {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  height: 44px;
  transform: translateY(-50%);
  border-top: 1px solid #eee;
  border-bottom: 1px solid #eee;
  pointer-events: none;
}

/* 鸿蒙系统特殊样式 */
.picker-content--harmony .picker-indicator {
  border-color: #e6e6e6;
}

.picker-content--harmony .picker-item {
  font-family: HarmonyOS_Sans_SC;
}
</style>

3. 多列选择器

在基础选择器的基础上,我们实现一个支持多列选择的组件:

<!-- components/multi-picker/multi-picker.vue -->
<template>
  <view class="multi-picker">
    <base-picker
      v-for="(column, index) in columns"
      :key="index"
      :title="column.title"
      :options="column.options"
      :value="selectedValues[index]"
      :visible="visible"
      @confirm="handleColumnConfirm(index, $event)"
      @cancel="handleCancel"
    />
  </view>
</template>

<script>
import BasePicker from '../base-picker/base-picker.vue'

export default {
  name: 'MultiPicker',
  components: {
    BasePicker
  },
  props: {
    columns: {
      type: Array,
      default: () => []
    },
    value: {
      type: Array,
      default: () => []
    },
    visible: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      selectedValues: []
    }
  },
  watch: {
    value: {
      immediate: true,
      handler(newVal) {
        this.selectedValues = [...newVal]
      }
    }
  },
  methods: {
    handleColumnConfirm(columnIndex, event) {
      this.selectedValues[columnIndex] = event.value
      
      // 判断是否所有列都已选择
      const isComplete = this.selectedValues.length === this.columns.length
      
      if (isComplete) {
        this.$emit('confirm', {
          values: this.selectedValues,
          indexes: this.selectedValues.map((value, index) => {
            const column = this.columns[index]
            return column.options.findIndex(option => option.value === value)
          })
        })
      }
    },
    handleCancel() {
      this.$emit('cancel')
    }
  }
}
</script>

4. 级联选择器

最后,我们来实现一个支持数据联动的级联选择器:

<!-- components/cascade-picker/cascade-picker.vue -->
<template>
  <view class="cascade-picker">
    <multi-picker
      :columns="computedColumns"
      :value="selectedValues"
      :visible="visible"
      @confirm="handleConfirm"
      @cancel="handleCancel"
    />
  </view>
</template>

<script>
import MultiPicker from '../multi-picker/multi-picker.vue'

export default {
  name: 'CascadePicker',
  components: {
    MultiPicker
  },
  props: {
    options: {
      type: Array,
      default: () => []
    },
    value: {
      type: Array,
      default: () => []
    },
    visible: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      selectedValues: [],
      columnOptions: []
    }
  },
  computed: {
    computedColumns() {
      return this.columnOptions.map((options, index) => ({
        title: `选择${index + 1}`,
        options
      }))
    }
  },
  watch: {
    value: {
      immediate: true,
      handler(newVal) {
        this.selectedValues = [...newVal]
        this.updateColumnOptions()
      }
    },
    options: {
      immediate: true,
      handler() {
        this.updateColumnOptions()
      }
    }
  },
  methods: {
    updateColumnOptions() {
      this.columnOptions = []
      let currentOptions = this.options
      
      // 根据已选值更新每一列的选项
      this.selectedValues.forEach((value, index) => {
        if (!currentOptions) return
        
        this.columnOptions[index] = currentOptions.map(item => ({
          label: item.label,
          value: item.value
        }))
        
        const selectedItem = currentOptions.find(item => item.value === value)
        currentOptions = selectedItem?.children
      })
      
      // 如果还有下一级选项,添加到列表中
      if (currentOptions?.length) {
        this.columnOptions.push(
          currentOptions.map(item => ({
            label: item.label,
            value: item.value
          }))
        )
      }
    },
    handleConfirm(event) {
      this.$emit('confirm', {
        values: event.values,
        indexes: event.indexes
      })
    },
    handleCancel() {
      this.$emit('cancel')
    }
  }
}
</script>

使用示例

下面是一个完整的使用示例:

<!-- pages/picker-demo/index.vue -->
<template>
  <view class="picker-demo">
    <!-- 基础选择器 -->
    <view class="demo-item">
      <text class="demo-title">基础选择器</text>
      <view 
        class="demo-trigger"
        @tap="showBasePicker"
      >
        {{ baseSelected ? baseSelected.label : '请选择' }}
      </view>
      <base-picker
        title="选择项目"
        :options="baseOptions"
        :value="baseValue"
        :visible="baseVisible"
        @confirm="handleBaseConfirm"
        @cancel="handleBaseCancel"
      />
    </view>
    
    <!-- 多列选择器 -->
    <view class="demo-item">
      <text class="demo-title">多列选择器</text>
      <view 
        class="demo-trigger"
        @tap="showMultiPicker"
      >
        {{ multiSelected.join(' - ') || '请选择' }}
      </view>
      <multi-picker
        :columns="multiColumns"
        :value="multiValues"
        :visible="multiVisible"
        @confirm="handleMultiConfirm"
        @cancel="handleMultiCancel"
      />
    </view>
    
    <!-- 级联选择器 -->
    <view class="demo-item">
      <text class="demo-title">级联选择器</text>
      <view 
        class="demo-trigger"
        @tap="showCascadePicker"
      >
        {{ cascadeSelected.join(' / ') || '请选择' }}
      </view>
      <cascade-picker
        :options="cascadeOptions"
        :value="cascadeValues"
        :visible="cascadeVisible"
        @confirm="handleCascadeConfirm"
        @cancel="handleCascadeCancel"
      />
    </view>
  </view>
</template>

<script>
import BasePicker from '@/components/base-picker/base-picker'
import MultiPicker from '@/components/multi-picker/multi-picker'
import CascadePicker from '@/components/cascade-picker/cascade-picker'

export default {
  components: {
    BasePicker,
    MultiPicker,
    CascadePicker
  },
  data() {
    return {
      // 基础选择器数据
      baseOptions: [
        { label: '选项1', value: '1' },
        { label: '选项2', value: '2' },
        { label: '选项3', value: '3' }
      ],
      baseValue: '',
      baseVisible: false,
      baseSelected: null,
      
      // 多列选择器数据
      multiColumns: [
        {
          title: '省份',
          options: [
            { label: '广东', value: 'gd' },
            { label: '浙江', value: 'zj' }
          ]
        },
        {
          title: '城市',
          options: [
            { label: '广州', value: 'gz' },
            { label: '深圳', value: 'sz' }
          ]
        }
      ],
      multiValues: [],
      multiVisible: false,
      multiSelected: [],
      
      // 级联选择器数据
      cascadeOptions: [
        {
          label: '电子产品',
          value: 'electronics',
          children: [
            {
              label: '手机',
              value: 'phone',
              children: [
                { label: 'iPhone', value: 'iphone' },
                { label: '华为', value: 'huawei' }
              ]
            }
          ]
        }
      ],
      cascadeValues: [],
      cascadeVisible: false,
      cascadeSelected: []
    }
  },
  methods: {
    // 基础选择器方法
    showBasePicker() {
      this.baseVisible = true
    },
    handleBaseConfirm(event) {
      this.baseValue = event.value
      this.baseSelected = this.baseOptions.find(item => item.value === event.value)
      this.baseVisible = false
    },
    handleBaseCancel() {
      this.baseVisible = false
    },
    
    // 多列选择器方法
    showMultiPicker() {
      this.multiVisible = true
    },
    handleMultiConfirm(event) {
      this.multiValues = event.values
      this.multiSelected = event.values.map((value, index) => {
        const column = this.multiColumns[index]
        return column.options.find(option => option.value === value)?.label
      })
      this.multiVisible = false
    },
    handleMultiCancel() {
      this.multiVisible = false
    },
    
    // 级联选择器方法
    showCascadePicker() {
      this.cascadeVisible = true
    },
    handleCascadeConfirm(event) {
      this.cascadeValues = event.values
      this.cascadeSelected = this.getCascadeLabels(event.values)
      this.cascadeVisible = false
    },
    handleCascadeCancel() {
      this.cascadeVisible = false
    },
    getCascadeLabels(values) {
      const labels = []
      let options = this.cascadeOptions
      
      values.forEach(value => {
        const item = options?.find(option => option.value === value)
        if (item) {
          labels.push(item.label)
          options = item.children
        }
      })
      
      return labels
    }
  }
}
</script>

<style>
.picker-demo {
  padding: 16px;
}

.demo-item {
  margin-bottom: 24px;
}

.demo-title {
  font-size: 16px;
  color: #333;
  margin-bottom: 8px;
}

.demo-trigger {
  padding: 12px;
  background-color: #f5f5f5;
  border-radius: 8px;
  color: #333;
}

/* 鸿蒙系统适配 */
@supports (-harmony-os) {
  .demo-trigger {
    background-color: #f8f8f8;
    border-radius: 16px;
    font-family: HarmonyOS_Sans_SC;
  }
}
</style>

性能优化

为了确保选择器在各种场景下都能保持流畅的性能,我们采取了以下优化措施:

  1. 虚拟滚动

    • 只渲染可视区域的选项
    • 复用 DOM 元素
    • 优化大数据量下的性能
  2. 动画优化

    • 使用 transform 代替位置属性
    • 启用硬件加速
    • 优化动画帧率
  3. 触控优化

    • 使用 touch 事件代替 scroll 事件
    • 实现平滑的惯性滚动
    • 优化触控响应速度

鸿蒙系统适配

针对鸿蒙系统,我们进行了特别优化:

  1. UI 适配

    • 遵循鸿蒙设计规范
    • 使用鸿蒙字体
    • 优化视觉层级
  2. 交互优化

    • 适配鸿蒙手势系统
    • 优化触控反馈
    • 提供更自然的操作体验
  3. 性能优化

    • 针对鸿蒙系统优化动画性能
    • 优化内存使用
    • 提供更流畅的滚动体验

最佳实践建议

  1. 组件封装

    • 保持组件的独立性
    • 提供完整的配置选项
    • 遵循单一职责原则
  2. 性能优化

    • 合理使用虚拟滚动
    • 优化重绘和重排
    • 控制内存使用
  3. 用户体验

    • 提供清晰的视觉反馈
    • 确保操作流程简单直观
    • 适配不同的使用场景

总结

通过本文的介绍,我们实现了一套功能完整的选择器组件库。这些组件不仅支持各种选择场景,还特别优化了在鸿蒙系统上的表现。在实际开发中,我们可以根据具体需求进行扩展和定制。

希望这个实现能够帮助开发者快速构建高质量的选择器功能。同时,也欢迎大家在实践中不断改进和完善这些组件。

参考资料

  • UniApp 官方文档
  • HarmonyOS 设计指南
  • Web 性能优化最佳实践
  • 移动端交互设计指南
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐