
<script setup>
import { getCurrentInstance } from 'vue'
const instance = getCurrentInstance()
const unitFunction = (value) => {
if (typeof value === 'number') {
return value + 'rpx'
}
return value
}
const props = defineProps({
height: {
type: [Number, String],
default: 200,
},
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'])
let ctx = null
const canvasData = ref({
width: 0,
height: 0,
totalWidth: 0,
leftOffset: 0,
rightOffset: 0,
currentOffset: 0,
isAnimating: false,
lastOffset: 0,
startX: 0,
animationFrameId: 0,
lastVibrateValue: 0,
})
const initCanvas = () => {
const query = uni.createSelectorQuery().in(instance.proxy)
query
.select(`.ruler-container`)
.boundingClientRect((res) => {
canvasData.value.width = res.width
canvasData.value.height = res.height
})
.exec()
ctx = uni.createCanvasContext(props.canvasId, instance.proxy)
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
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
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
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()
}
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
if (distance === 0) return
canvasData.value.isAnimating = true
const startTime = Date.now()
const animate = () => {
const now = Date.now()
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()
}
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(() => {
if (props.start >= props.end) {
console.error('start 必须小于 end');
const temp = props.start;
props.start = props.end;
props.end = temp;
}
initCanvas()
})
</script>