前言:业务上有刻度尺的需求,先前尝试过使用virtual list的方案进行DOM绘制,有个比较大弊端就是当滑动的速度过快,会导致渲染空白,心里一直挂念着,就有了今天的实现,本篇文章只会分享我在实现问题的思路和过程。
1. canvas前置知识点
1.在代码中使用的canvas API,建议从 canvas API 中文文档 中进行相对应的查阅。
2.关于代码中使用dpr去设置canvas的宽高,主要是为了解决移动端的绘制问题,详情可以参考这篇文章 canvas在移动端绘制模糊的原因与解决办法。
3.关于代码中为何要创建两个canvas进行绘制,主要是为了避免闪屏问题。详情可以参考这篇文章 使用双缓存解决 canvas clearRect引起的闪屏问题
2. 组件初始化
首先定义了传入的props interface
其次,开始书写对应的初始化函数,初始化函数的作用,是为了保证传参的正确性,并且获取当前DOM的相关信息进行保存,对相关值进行初始化~
3.编写绘制函数
在绘制前,我们需要先思考一下,一个刻度尺有哪些部分是需要绘制的,在当前的组件中,我将会分别绘制背景色和底线,刻度尺指针,刻度尺刻度,渐变色区域。其实代码很简单,参考着Canvas API 就知道是啥意思了。
稍微组织一下,结合绘制刻度线的操作,便是这样的代码了。
4.给组件加入自定义滚动事件
关于自定义滑动,真的是不断踩坑的过程。原本想借鉴一下UI组件的滑动源码,结果发现别人靠着css写了对应贝塞尔曲线函数就完事了。好在后面经过一番查阅,看到了张鑫旭大佬的这篇文章 如何使用Tween.js各类原生动画运动缓动算法 才算是悟到了精髓。
先给我们的组件加上对应的移动端监听事件
书写上对应的监听函数
挑选心仪的easeout函数
结合easeout写下对应的滚动处理函数~
这样基本就搞定了~
5.关于requestAnimationFrame
这里真的懒,不想去处理兼容问题(谁让它是移动端组件),在不支持这个API下的浏览器,需要替换为setTimeout,这里我要贴出vant ui 的兼容性写法。
6.附上所有代码
import React, { useCallback, useEffect, useRef, useState } from "react"
import './index.less'
const MOMENTUM_LIMIT_TIME = 300
const MOMENTUM_LIMIT_DISTANCE = 40
const defaultSetting = {
/** 刻度线高度 */
scaleHeight: 48,
/** 开始值/最小阈值 */
start:0,
/** 结束值/最大阈值 */
end:0,
/** 间距 */
lineMargin: 10,
/** 精度 */
precision: 0.1
}
/*
* t: current time(当前时间);
* b: beginning value(初始值);
* c: change in value(变化量);
* d: duration(持续时间)。
*/
function easeOut(t: number, b: number, c: number, d: number) {
return c * ((t = t/d - 1) * t * t + 1) + b
}
function range(num: number, min: number, max: number): number {
return Math.min(Math.max(num, min), max);
}
interface ScaleComponentProps{
/** 刻度尺当前值 */
current:number;
/** 刻度尺开始值/最小值 */
start?:number;
/** 刻度尺结束值值/最大值 */
end?:number;
/** 刻度尺精度 */
precision?:number;
/** 回调函数 */
onChange?: (value:number) => void;
}
/** 初始化渲染的canvas信息 */
interface OriginCanvasInfo{
canvas?:HTMLCanvasElement,
context?:CanvasRenderingContext2D,
originCanvasWidth:number,
originCanvasHeight:number,
dprOrginCanvasWidth:number,
dprOriginCanvasHeight:number,
dpr:number
}
const ScaleComponent:React.FC<ScaleComponentProps> = (props) => {
const { current,start,end,precision,onChange } = props;
const canvasRef = useRef<HTMLCanvasElement>(null)
const originCanvasInfo = useRef<OriginCanvasInfo>({
originCanvasWidth:0,
originCanvasHeight:0,
dprOrginCanvasWidth:0,
dprOriginCanvasHeight:0,
dpr:0
})
const startVal = useRef(0)
/** 滑动相关信息记录 */
const touchInfo = useRef({
startX:0,
currentMoveX:0
})
/** 滑动的开始时间 */
const touchStartTime = useRef(0)
const limitThreshole = (value: number) => range(value,defaultSetting.start,defaultSetting.end)
const scrollAction = (distance: number, _duration: number) => {
let targetDistance = startVal.current - distance
const duration = 13
let currentTime = 1
const originVal = startVal.current
const step = () =>{
let value = easeOut(currentTime,originVal,targetDistance-originVal,duration)
if(currentTime < duration){
startVal.current = Math.round(limitThreshole(value) / defaultSetting.precision) * defaultSetting.precision
drawScale()
currentTime+=1
window.requestAnimationFrame(step)
}else{
}
}
window.requestAnimationFrame(step)
}
/** 根据当前精度保留对应的整数或小数位置,再转成数字类型 */
const handlePrecisionNum = (num:number) =>{
const settingPrecision = defaultSetting.precision
if(settingPrecision < 1){
return Number(num.toFixed(settingPrecision * 10))
}
return Number(num.toFixed(settingPrecision - 1))
}
/** 绘制中间线 */
const drawMiddleLine = (context:CanvasRenderingContext2D) =>{
/** ————绘制中间线———— */
const midLineXVal = Math.floor(originCanvasInfo.current.originCanvasWidth / 2)
context.beginPath()
context.lineWidth = 4
context.lineCap = 'round'
context.moveTo(midLineXVal,0)
context.lineTo(midLineXVal ,48)
context.strokeStyle = '#44CD8D'
context.stroke()
context.closePath()
}
/** 绘制背景色和底线 */
const drawBackGroundUnderLine = (tempCanvas: HTMLCanvasElement,tempContext: CanvasRenderingContext2D) =>{
tempContext.fillStyle = '#fff'
tempContext.fillRect(0, 0, tempCanvas.width, 200)
tempContext.beginPath()
tempContext.moveTo(0,0)
tempContext.lineTo(tempCanvas.width,0)
tempContext.strokeStyle = '#9E9E9E'
tempContext.stroke()
tempContext.closePath()
}
/** 绘制两侧渐变区域 */
const drawLinearGradient = (context: CanvasRenderingContext2D) =>{
const originCanvasWidth = originCanvasInfo.current.originCanvasWidth
context.beginPath()
let lineargradient = context.createLinearGradient(65, 31, 0, 31)
lineargradient.addColorStop(0,'rgba(255, 255, 255, 0)')
lineargradient.addColorStop(1,'#FFFFFF')
context.fillStyle = lineargradient
context.fillRect(0, 0, 65, 91)
context.closePath()
context.beginPath()
let lineargradient1 = context.createLinearGradient(originCanvasWidth, 31, originCanvasWidth - 65, 31)
lineargradient1.addColorStop(0,'#FFFFFF')
lineargradient1.addColorStop(1,'rgba(255, 255, 255, 0)')
context.fillStyle = lineargradient1
context.fillRect(originCanvasWidth - 65, 0, 65, 91)
context.closePath()
}
/** 绘制刻度线 */
const drawScale = (useCallback(() =>{
const {context,originCanvasWidth,originCanvasHeight,dprOrginCanvasWidth,dprOriginCanvasHeight,dpr} = originCanvasInfo.current
if(!context) return
let tempCanvas:HTMLCanvasElement = document.createElement('canvas')
let tempContext = tempCanvas.getContext('2d')!
tempCanvas.style.width = `${originCanvasWidth}px`
tempCanvas.style.height = `${originCanvasHeight}px`
tempCanvas.width = dprOrginCanvasWidth
tempCanvas.height = dprOriginCanvasHeight
tempContext.scale(dpr,dpr)
drawBackGroundUnderLine(tempCanvas,tempContext)
/** 当前刻度尺最左侧的刻度值 */
let beginNum = startVal.current - (originCanvasWidth / 2) / defaultSetting.lineMargin * defaultSetting.precision
/** 当前能绘制刻度尺的总数 */
let scaleTotal = originCanvasWidth / defaultSetting.lineMargin | 0
/** 当前刻度值与向上取整的刻度值之间的差值 */
let beginNumDiffVal = Math.ceil(beginNum / defaultSetting.precision) * defaultSetting.precision - beginNum
/** 计算出间距与精度之间的比例值 */
const marginPrecisionRatio = defaultSetting.lineMargin / defaultSetting.precision
/** 需要空出来的位移值 */
const blankMoveVal = beginNumDiffVal * marginPrecisionRatio
for(let i = 0; i < scaleTotal; i++){
let currentNum = Math.ceil(beginNum / defaultSetting.precision + i) * defaultSetting.precision
if (currentNum < defaultSetting.start) {
continue
} else if (currentNum > defaultSetting.end) {
break
}
tempContext.beginPath()
tempContext.strokeStyle = "#9E9E9E"
tempContext.font = '16px SimSun, Songti SC'
tempContext.fillStyle = '#333333'
tempContext.textAlign = 'center'
tempContext.lineWidth = 1
let drawXval = blankMoveVal + i * defaultSetting.lineMargin
if (currentNum % (defaultSetting.precision * 10) === 0) {
tempContext.moveTo(drawXval, 0)
tempContext.strokeStyle = "#666"
tempContext.shadowColor = '#9e9e9e'
tempContext.fillText(String(currentNum),drawXval,defaultSetting.scaleHeight + 18)
tempContext.lineTo(drawXval, defaultSetting.scaleHeight)
} else if (currentNum % (defaultSetting.precision * 5) === 0) {
tempContext.strokeStyle = "#888"
tempContext.moveTo(drawXval, 0)
tempContext.lineTo(drawXval, defaultSetting.scaleHeight - 8)
} else {
tempContext.moveTo(drawXval, 0)
tempContext.lineTo(drawXval, defaultSetting.scaleHeight - 18)
}
tempContext.stroke()
tempContext.closePath()
}
context.clearRect(0, 0, tempCanvas.width, tempCanvas.height)
context.drawImage(tempCanvas, 0, 0, dprOrginCanvasWidth,dprOriginCanvasHeight, 0, 0, originCanvasWidth, originCanvasHeight)
drawMiddleLine(context)
drawLinearGradient(context)
onChange?.(handlePrecisionNum(startVal.current))
},[onChange]))
useEffect(()=>{
/** 初始化 */
const drawScaleInit = () =>{
if(current < (start || 0)){
throw Error('当前值小于开始值?你真是个大聪明')
}else if(current > (end || 0)){
throw Error('当前值大于结束值?你真是个大聪明')
}
if(!canvasRef.current) return
let canvas:HTMLCanvasElement = canvasRef.current
let context = canvas.getContext('2d')!
const { width: originCanvasWidth, height: originCanvasHeight } = canvas.getBoundingClientRect()
canvas.style.width = `${originCanvasWidth}px`
canvas.style.height = `${originCanvasHeight}px`
const dpr = window.devicePixelRatio
canvas.width = dpr * originCanvasWidth
canvas.height = dpr * originCanvasHeight
context.scale(dpr,dpr)
/** 设置当前值 */
startVal.current = current
originCanvasInfo.current = {
canvas,
context,
originCanvasWidth,
originCanvasHeight,
dprOrginCanvasWidth:dpr * originCanvasWidth,
dprOriginCanvasHeight:dpr * originCanvasHeight,
dpr
}
if(start) defaultSetting.start = start
if(end){
defaultSetting.end = end
}else{
defaultSetting.end = current + 100
}
if(precision){
defaultSetting.precision = precision
}
}
drawScaleInit()
drawScale()
},[current,start,end,precision,drawScale])
const onTouchStart = (event: TouchEvent | React.TouchEvent) =>{
touchInfo.current.startX = event.touches[0].pageX
touchInfo.current.currentMoveX = event.touches[0].pageX
touchStartTime.current = Date.now()
}
const onTouchMove = (event: TouchEvent | React.TouchEvent) =>{
const current_x = event.touches[0].pageX
const move_x = current_x - touchInfo.current.currentMoveX
startVal.current = range(startVal.current - move_x / defaultSetting.lineMargin * defaultSetting.precision,defaultSetting.start,defaultSetting.end)
window.requestAnimationFrame(()=>drawScale())
touchInfo.current.currentMoveX = current_x
const now = Date.now()
if (now - touchStartTime.current > MOMENTUM_LIMIT_TIME) {
touchStartTime.current = now
touchInfo.current.startX = current_x
}
}
const onTouchEnd = (event:TouchEvent | React.TouchEvent) =>{
const duration = Date.now() - touchStartTime.current
const distance = (event.changedTouches[0].pageX - touchInfo.current.startX) * defaultSetting.precision
const allowEaseAction = duration < MOMENTUM_LIMIT_TIME && Math.abs(distance) > MOMENTUM_LIMIT_DISTANCE * defaultSetting.precision
if(allowEaseAction){
scrollAction(distance, duration)
}else{
startVal.current = Math.round(limitThreshole(startVal.current) / defaultSetting.precision) * defaultSetting.precision
window.requestAnimationFrame(()=>drawScale())
}
}
return (
<canvas
className="component-scale"
ref={canvasRef}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
</canvas>
)
}
export default ScaleComponent
在页面中调用如下:
<ScaleComponent start={0} end={200} precision={1} current={this.state.numVal} onChange={this.onChange}></ScaleComponent>
效果图如下(我真的懒得去找mac如何录gif了,刚看完电影回来都深夜了,感兴趣可以cv一波到页面看看效果):
7.小总结
在遇到难题时,要敢于思考解决的方案,即使现在解决不了,可以把问题拆分成多个维度去解决,重要的是想解决的心,其次才是能力。明知问题所在,选择摆烂则是最差的行为。