使用React Hooks 结合canvas实现刻度尺组件

3,207 阅读5分钟

前言:业务上有刻度尺的需求,先前尝试过使用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.小总结

在遇到难题时,要敢于思考解决的方案,即使现在解决不了,可以把问题拆分成多个维度去解决,重要的是想解决的心,其次才是能力。明知问题所在,选择摆烂则是最差的行为。