从零开始,手撸一个uni-app滑动刻度尺组件吧

379 阅读4分钟

135d8efc5f456e1c89d6fec266c7ac84.gif

<script setup>
import { getCurrentInstance } from 'vue'

const instance = getCurrentInstance()

// 辅助函数,根据值计算单位
const unitFunction = (value) => {
  // 如果是数值,则默认为rpx
  if (typeof value === 'number') {
    return value + 'rpx'
  }
  return value
}

// 组件属性
const props = defineProps({
  // 组件高度,即ruler-container的高度, 默认200rpx, 在uni-app中建议使用rpx
  height: {
    type: [Number, String],
    default: 200,
  },
  // canvasId, 默认为ruler-canvas, 用于区分多个组件,因为同一页面canvasId不能重复
  canvasId: {
    type: String,
    default: 'ruler-canvas',
  },
  // 刻度线宽度
  lineWidth: {
    type: Number,
    default: 1,
  },
  // 刻度线高度
  lineHeight: {
    type: Number,
    default: 10,
  },
  // 刻度线颜色
  lineColor: {
    type: String,
    default: '#767676',
  },
  // 字体颜色
  textColor: {
    type: String,
    default: '#3b3b3b',
  },
  // 字体大小
  textSize: {
    type: Number,
    default: 14,
  },
  // 刻度开始值
  start: {
    type: Number,
    default: 0,
  },
  // 刻度结束值
  end: {
    type: Number,
    default: 100,
  },
  // 大小刻度间隔,即没间隔多少刻度会有一个大刻度, 用于区分大刻度与小刻度,即刻度线高度的区别
  interval: {
    type: Number,
    default: 5,
  },
  // 刻度之间的间隔
  gap: {
    type: Number,
    default: 20,
  },
  // 默认值
  defaultValue: {
    type: Number,
    default: 50,
  },
  // 惯性动画执行时间
  animationDuration: {
    type: Number,
    default: 300,
  },
  // 文字展示逻辑
  textFunction: {
    type: Function,
    default: (value) => value,
  }
})

// 组件事件发射器
const emits = defineEmits(['change'])

// canvas上下文
let ctx = null

// canvas数据
const canvasData = ref({
  width: 0,                   // canvas宽度
  height: 0,                  // canvas高度
  totalWidth: 0,              // canvas总宽度
  leftOffset: 0,              // 左边的最小偏移量
  rightOffset: 0,             // 右边的最大偏移量
  currentOffset: 0,           // 当前的偏移量
  isAnimating: false,         // 是否正在动画
  lastOffset: 0,              // 上一次的偏移量
  startX: 0,                  // 触摸开始的位置
  animationFrameId: 0,        // 动画id
  lastVibrateValue: 0,        // 上次触发震动的刻度值
})

// 初始化canvas
const initCanvas = () => {
  // 获取canvas容器的尺寸,给canvas设置尺寸
  const query = uni.createSelectorQuery().in(instance.proxy)
  query
  .select(`.ruler-container`)
  .boundingClientRect((res) => {
    canvasData.value.width = res.width
    canvasData.value.height = res.height
  })
  .exec()
  
  // 获取canvas上下文
  ctx = uni.createCanvasContext(props.canvasId, instance.proxy)
  
  // 设置canvas的总宽度,即刻度尺的总宽度
  canvasData.value.totalWidth = (props.end - props.start) * (props.gap + props.lineWidth)
  
  // 设置最大/最小偏移量(防止滑动越界, 刻度尺只能滑到最中间)
  canvasData.value.leftOffset = -(canvasData.value.width / 2)
  canvasData.value.rightOffset = canvasData.value.totalWidth - canvasData.value.width / 2
  
  // 重绘
  drawRuler();
  
  scrollToValue(props.defaultValue, true)
}

// 初始化刻度尺
const drawRuler = () => {
  const {
    width,
    height,
    currentOffset
  } = canvasData.value
  
  // 清空canvas
  ctx.clearRect(0, 0, width, height)
  // 设置填充样式
  ctx.setFillStyle(props.textColor)
  // 设置描边样式
  ctx.setStrokeStyle(props.lineColor)
  // 设置线条宽度
  ctx.setLineWidth(props.lineWidth)
  // 设置线条端点样式
  ctx.setLineCap('round')
  // 文字居中
  ctx.setTextAlign('center')
  // 设置字体大小
  ctx.font = `bold ${ props.textSize }px Arial`
  ctx.save()
  
  // 大刻度线多增加的高度
  const bigHeight = 10
  
  // 将canvas原点位置从(0,0)变为(0,height / 1.6)
  ctx.translate(0, height / 1.6)
  
  // 为了优化性能,仅绘制可见范围内的刻度线
  const startValue = Math.floor((currentOffset - width / 2) / (props.gap + props.lineWidth) + props.start)
  const endValue = Math.ceil((currentOffset + width) / (props.gap + props.lineWidth) + props.start)
  
  // 缓存计算结果
  const gapAndLineWidth = props.gap + props.lineWidth
  
  // 绘制刻度线
  for (let i = startValue; i <= endValue; i++) {
    if (i < props.start || i > props.end) continue
    // 计算x轴坐标(相对于start的偏移)
    const x = (i - props.start) * gapAndLineWidth - currentOffset
    // 如果是大刻度,则在大刻度下面绘制文字,并且大刻度的线条更长一点
    if (i % props.interval === 0) {
      ctx.moveTo(x, 0)
      ctx.lineTo(x, -(props.lineHeight + bigHeight))
      // 设置文字位置
      ctx.fillText(props.textFunction(i), x, props.lineHeight + bigHeight)
    } else {
      ctx.moveTo(x, 0)
      ctx.lineTo(x, -props.lineHeight)
    }
  }
  
  ctx.stroke()
  ctx.draw()
}

// 缓存 calculateCurrentValue 的结果
let cachedCurrentValue = null;
let cachedOffset = null;

const calculateCurrentValue = () => {
  const {
    width,
    currentOffset
  } = canvasData.value;
  const gapAndLineWidth = props.gap + props.lineWidth;
  const currentValue = props.start + (currentOffset + width / 2) / gapAndLineWidth;
  
  // 缓存计算结果
  cachedCurrentValue = currentValue;
  cachedOffset = currentOffset;
  
  return currentValue;
}

// 获取缓存的当前值
const getCachedCurrentValue = () => {
  if (cachedOffset === canvasData.value.currentOffset) {
    return cachedCurrentValue;
  }
  return calculateCurrentValue();
}

// 滚动到某个值
const scrollToValue = (value, withAnimation = true) => {
  // 计算目标偏移量,并进行边界检查
  const targetOffset = Math.max(
    canvasData.value.leftOffset,
    Math.min(
      canvasData.value.rightOffset,
      (value - props.start) * (props.gap + props.lineWidth) - canvasData.value.width / 2
    )
  );
  
  if (withAnimation) {
    // 启动惯性动画
    startInertialAnimation(targetOffset);
  } else {
    // 立即更新偏移量
    canvasData.value.currentOffset = targetOffset;
    // 重绘
    drawRuler();
    
    // 触发最终数值变更事件
    emits('change', Math.floor(getCachedCurrentValue()));
  }
}

// 计算刻度线起始坐标
const lineStart = computed(() => {
  return canvasData.value.height - canvasData.value.height / 1.6 + 'px'
})

// 执行吸附逻辑
const snapToNearestStep = () => {
  const currentValue = getCachedCurrentValue();
  const snappedValue = Math.round(currentValue);
  const targetOffset = (snappedValue - props.start) * (props.gap + props.lineWidth) - canvasData.value.width / 2;
  // 边界限制
  const clampedOffset = Math.max(canvasData.value.leftOffset, Math.min(canvasData.value.rightOffset, targetOffset));
  if (clampedOffset !== canvasData.value.currentOffset) {
    startInertialAnimation(clampedOffset);
  } else {
    // 触发最终数值变更事件
    emits('change', Math.floor(getCachedCurrentValue()))
  }
}

// 启动惯性动画
const startInertialAnimation = (targetOffset) => {
  // 动画起始偏移量
  let startOffset = canvasData.value.currentOffset
  // 需要移动的距离
  let distance = targetOffset - startOffset
  // 如果需要移动的距离为0,则直接返回
  if (distance === 0) return
  canvasData.value.isAnimating = true
  
  const startTime = Date.now()
  
  const animate = () => {
    const now = Date.now()
    // 计算动画进度(0~1)
    const progress = Math.min((now - startTime) / props.animationDuration, 1)
    // 应用缓动函数计算实际进度
    const easeProgress = easeOutCubic(progress)
    
    // 更新当前偏移量
    canvasData.value.currentOffset = Math.round(startOffset + distance * easeProgress)
    
    // 重新绘制
    drawRuler()
    
    // 继续执行动画直到完成
    if (progress < 1) {
      canvasData.value.animationFrameId = requestAnimationFrame(animate)
    } else {
      canvasData.value.isAnimating = false
      // 触发最终数值变更事件
      emits('change', Math.floor(getCachedCurrentValue()))
    }
  }
  
  // 取消旧动画
  cancelAnimationFrame(canvasData.value.animationFrameId)
  // 执行新动画
  animate()
}

// easeOutCubic 缓动函数
const easeOutCubic = (t) => {
  return 1 - Math.pow(1 - t, 3)
}

// 触摸开始事件
const onTouchStart = (e) => {
  if (!canvasData.value.isAnimating) {
    canvasData.value.startX = e.touches[0].pageX
    canvasData.value.lastOffset = canvasData.value.currentOffset
  }
}

// 触摸移动事件
const onTouchMove = (e) => {
  if (!canvasData.value.isAnimating) {
    // 计算移动距离
    const deltaX = canvasData.value.startX - e.touches[0].pageX;
    // 更新偏移量(限制在边界范围内)
    const {
      leftOffset,
      rightOffset,
      lastOffset
    } = canvasData.value;
    canvasData.value.currentOffset = Math.max(leftOffset, Math.min(rightOffset, lastOffset + deltaX));
    // 重绘
    drawRuler();
    // 检测是否需要震动反馈
    checkVibration();
  }
}

// 触摸结束事件
const onTouchEnd = () => {
  if (!canvasData.value.isAnimating) {
    // 触发吸附动画
    snapToNearestStep();
  }
}

// 检测是否需要震动反馈
const checkVibration = () => {
  const currentValue = calculateCurrentValue();
  const diff = Math.abs(currentValue - canvasData.value.lastVibrateValue);
  if (diff > 1) {
    uni.vibrateLong();
    canvasData.value.lastVibrateValue = currentValue;
  }
}

onMounted(() => {
  // 校验start、end的值,如果start大于等于end,则交换两者的值
  if (props.start >= props.end) {
    console.error('start 必须小于 end');
    // 可以交换两者或抛错
    const temp = props.start;
    props.start = props.end;
    props.end = temp;
  }
  initCanvas()
})
</script>