前言
最近失业在家,闲来无事,就一直想实现之前很在意的苹果健康中的定时器样式。 苹果手机将睡眠闹钟移动到健康APP里之后,睡眠的定时器功能我非常喜欢。通过拖动端点来确定睡眠时间,移动整个滑块设置时间段,这个交互在前端中不太常见,接下来就分析这个功能如何实现。本篇博客只讨论如何实现UI,其他的功能暂时不实现。
需求分析
首先来分析这个功能的重点:
- 整个可交互的位置只有滑块(slip),所有的功能都围绕着计算如何和滑块交互,这个滑块由左右可拖动的顶点(pointLeft,pointRight)和滑块中间的位置组成。
- 拖动滑块中间的位置可以将整个滑块拖动,此时左右顶点和滑块中间的位置作为一个整体滑动。
- 重点: 当拖动某一顶点的时候,只端点滑动,同时滑块中间的位置相应的伸长或缩短,当到达某一临界值的时候又可以整体滑动,即左右顶点不能相交。
确定好需求之后我们来写代码。
开始
主要的技术:React + TS + canvas
在JS当中想要创建一条弧线,那么首选就是canvas,先进行准备工作。
function Sliper() {
const canvasRef = useRef<HTMLCanvasElement>(null)
// 用于记录当前canvas及元素的信息
const [ctxInfo, setCtxInfo] = useState<{
ctx: CanvasRenderingContext2D
width: number // 画布宽度
height: number // 画布高度
centerX: number // 画布中心位置X
centerY: number // 画布中心位置Y
clockR: number // 中央表盘的半径
sliperWidth: number // 滑块宽度
spaceWidth: number // 滑块和中央表盘的距离
} | null>(null)
useEffect(() => {
if (canvasRef.current) {
// 开始准备工作
const canvas = canvasRef.current
const [width, height] = [canvas.width, canvas.height]
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
setCtxInfo({
ctx,
width,
height,
centerX: width / 2,
centerY: height / 2,
clockR: 100,
sliperWidth: 24,
spaceWidth: 5,
})
}
}, [canvasRef.current])
return <canvas ref={canvasRef} width={800} height={500} />
}
export default Sliper
根据上面分析我们还需要两个变量slipAngleL,slipAngleR,分别用来记录左右顶点当前的角度。PS:作者更习惯坐标系的取值范围是0 ~ 2π,所以本文接下来的所有角度将转化到这个角度范围。
const [slipAngleL, setSlipAngleL] = useState((Math.PI * 3) / 4) // 曲线左顶点的角度
const [slipAngleR, setSlipAngleR] = useState((Math.PI * 1) / 4) // 曲线右顶点的角度
接下来要先确定几个常量
// 判定为顶点的夹角
const POINT_ANGLE = Math.PI / 12
// 格式化角度的函数
const formatAngle = (angle: number) => {
if (angle > Math.PI * 2) return angle - Math.PI * 2
if (angle < 0) return angle + Math.PI * 2
return angle
}
当确定知道了左右顶点的角度,我们就可以根据角度来画出基础的图形。
// 每次根据左右顶点的改变重新绘制图像
useEffect(() => {
if (ctxInfo) {
const {
ctx,
centerX,
centerY,
clockR,
sliperWidth,
width,
height,
spaceWidth,
} = ctxInfo
ctx.clearRect(0, 0, width, height) // 清楚上一次的绘画
ctx.beginPath()
ctx.arc(centerX, centerY, clockR, 0, 2 * Math.PI) // 画出表盘
ctx.fill()
ctx.beginPath()
ctx.lineWidth = sliperWidth
// 根据左右顶点角度画出弧线
ctx.arc(
centerX,
centerY,
clockR + sliperWidth / 2 + spaceWidth,
slipAngleL,
slipAngleR
)
ctx.stroke()
ctx.beginPath()
// 画出左顶点,用红色表示
ctx.strokeStyle = 'red'
ctx.arc(
centerX,
centerY,
clockR + sliperWidth / 2 + spaceWidth,
slipAngleL,
formatAngle(slipAngleL + POINT_ANGLE)
)
ctx.stroke()
ctx.closePath()
ctx.beginPath()
// 画出右顶点,用绿色表示
ctx.strokeStyle = 'green'
ctx.arc(
centerX,
centerY,
clockR + sliperWidth / 2 + spaceWidth,
formatAngle(slipAngleR - POINT_ANGLE),
slipAngleR
)
ctx.stroke()
ctx.strokeStyle = 'black'
ctx.closePath()
}
}, [ctxInfo, slipAngleL, slipAngleR, isDrappingPoint])
打开页面我们可以得出这样的画面
好丑。。。,不过不要紧,重要的是功能
基础完成后需要给图形加上事件,一个重要的问题就是如何判断鼠标是否在滑块上:
- 通过鼠标的位置到中心的位置可以判读是否在滑轨上
- 通过鼠标与水平线的夹角来判断是否在左右顶点之间
那么我们可以写出代码
const [isDrappingPoint, setDrapping] = useState<'l' | 'r' | false>(false) // 是否开始拖动顶点
const isOnSlip = useCallback(
(e: any) => {
if (ctxInfo) {
const [mouseX, mouseY] = [e.nativeEvent.offsetX, e.nativeEvent.offsetY]
const { centerX, centerY, clockR, sliperWidth, spaceWidth } =
ctxInfo
// 鼠标到中心的距离
const r = Math.sqrt(
Math.pow(Math.abs(mouseX - centerX), 2) +
Math.pow(Math.abs(mouseY - centerY), 2)
)
// 鼠标到中心的角度
let angle = Math.atan2(mouseY - centerY, mouseX - centerX)
if (angle < 0) angle = Math.PI * 2 + angle // 统一范围在0 ~ 2π之间
const langle = slipAngleL
// 默认情况下右测节点的角度都比左侧节点角度大,但是也有特殊情况,就是右测节点已经转过一圈了所以要加上2π
const rangle =
slipAngleR < slipAngleL ? slipAngleR + 2 * Math.PI : slipAngleR
const cangle = angle < slipAngleL ? angle + 2 * Math.PI : angle
const result =
r < clockR + spaceWidth + sliperWidth &&
r > clockR + spaceWidth &&
cangle - langle < rangle - langle
// 副作用 - 判断是否鼠标在拖动顶点
// 当鼠标位置与顶点位置夹角小于 Math.PI / 12 被视为在拖动顶点
if (result) {
// 点击位置与左右节点的差值
const ldiff = Math.abs(slipAngleL - angle)
const rdiff = Math.abs(slipAngleR - angle)
if (ldiff > POINT_ANGLE && rdiff > POINT_ANGLE) setDrapping(false)
if (ldiff > POINT_ANGLE && rdiff < POINT_ANGLE) setDrapping('r')
if (ldiff < POINT_ANGLE && rdiff > POINT_ANGLE) setDrapping('l')
} else {
setDrapping(false)
}
// 副作用结束
return result
}
return false
},
[ctxInfo, slipAngleL, slipAngleR]
)
这样isOnSlip
可以判断鼠标是否在滑块上,为后续的事件提供便利
下面来添加事件
鼠标的拖动事件需要添加mouseDown
、mouseUp
、mouseMove
三个事件,
设置一个变量,当鼠标按下的时候设置为true,鼠标移动的时候判断这个变量是否为true,为true的时候开始拖动事件,当鼠标抬起的时候将这个变量设置为false。
// 鼠标按下事件
const mouseDown = useCallback<MouseEventHandler>(
(e) => {
if (isOnSlip(e)) {
setMouseIsDown(true)
// 记录当前鼠标位置
mouseLastPsition.current = {
x: e.nativeEvent.offsetX,
y: e.nativeEvent.offsetY,
}
}
},
[isOnSlip]
)
// 鼠标抬起事件
const mouseUp = useCallback<MouseEventHandler>((e) => {
setMouseIsDown(false)
setDrapping(false)
}, [])
// 鼠标移动事件
const mouseMove = useCallback<MouseEventHandler>(
(e) => {
if (mouseIsDown && ctxInfo) {
const { centerX, centerY } = ctxInfo
const { x: mouseLastX, y: mouseLastY } = mouseLastPsition.current
// 根据鼠标开始的坐标计算初始角度
let startAngle = Math.atan2(mouseLastY - centerY, mouseLastX - centerX)
if (startAngle < 0) startAngle = Math.PI * 2 + startAngle
// 根据鼠标当前的坐标计算当前角度
let mouseAngle = Math.atan2(
e.nativeEvent.offsetY - centerY,
e.nativeEvent.offsetX - centerX
)
if (mouseAngle < 0) mouseAngle = Math.PI * 2 + mouseAngle
mouseLastPsition.current = {
x: e.nativeEvent.offsetX,
y: e.nativeEvent.offsetY,
}
const targetL = formatAngle(slipAngleL + mouseAngle - startAngle)
const targetR = formatAngle(slipAngleR + mouseAngle - startAngle)
// 拖动顶点
if (isDrappingPoint) {
if (isDrappingPoint === 'l') {
// 计算夹角的弧度值的差值
const delta = Math.abs(targetL - slipAngleR)
const angleBetween = Math.min(delta, 2 * Math.PI - delta)
// 边缘判断 设置拖动角度的极限值,被拖动的节点在固定节点的无论左右都不能小于MIN_ANGLE的角度
if (
Number(angleBetween.toFixed(2)) >= Number(MIN_ANGLE.toFixed(2))
) {
setSlipAngleL(targetL)
return
}
} else {
const delta = Math.abs(targetR - slipAngleL)
const angleBetween = Math.min(delta, 2 * Math.PI - delta)
if (
Number(angleBetween.toFixed(2)) >= Number(MIN_ANGLE.toFixed(2))
) {
setSlipAngleR(targetR)
return
}
}
}
// 整体拖动
setSlipAngleL(targetL)
setSlipAngleR(targetR)
}
if (isOnSlip(e)) {
e.currentTarget.setAttribute('style', 'cursor: pointer;')
} else {
e.currentTarget.setAttribute('style', '')
}
},
[slipAngleL, slipAngleR, isOnSlip, mouseIsDown, isDrappingPoint]
)
那么到目前为止,就把基础功能完成了,可以滑块的整体拖动,左右节点的单独拖动和极限值的判断。
线上体验地址:xzgz.top/blog/exampl…
全部代码:
import {
type MouseEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import styled from 'styled-components'
const CanvasStyled = styled.canvas`
background-color: #ccc;
`
// 可拖动的最小夹角
const MIN_ANGLE = (Math.PI * 5) / 24
// 判定为顶点的夹角
const POINT_ANGLE = Math.PI / 12
function Tester() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [ctxInfo, setCtxInfo] = useState<{
ctx: CanvasRenderingContext2D
width: number
height: number
centerX: number
centerY: number
clockR: number
sliperWidth: number
spaceWidth: number
} | null>(null)
const [slipAngleL, setSlipAngleL] = useState((Math.PI * 3) / 4) // 曲线左顶点的角度
const [slipAngleR, setSlipAngleR] = useState((Math.PI * 1) / 4) // 曲线右顶点的角度
const [mouseIsDown, setMouseIsDown] = useState(false) // 鼠标是否点击
const mouseLastPsition = useRef({ x: 0, y: 0 }) // 鼠标上次位移的位置
const [isDrappingPoint, setDrapping] = useState<'l' | 'r' | false>(false) // 是否开始拖动顶点
// 绘制图形时处理角度 控制角度在 0 ~ Math.PI * 2
const formatAngle = useCallback((angle: number) => {
if (angle > Math.PI * 2) return angle - Math.PI * 2
if (angle < 0) return angle + Math.PI * 2
return angle
}, [])
// 每次根据左右顶点的改变重新绘制图像
useEffect(() => {
if (ctxInfo) {
const {
ctx,
centerX,
centerY,
clockR,
sliperWidth,
width,
height,
spaceWidth,
} = ctxInfo
ctx.clearRect(0, 0, width, height)
ctx.beginPath()
ctx.arc(centerX, centerY, clockR, 0, 2 * Math.PI)
ctx.fill()
ctx.beginPath()
ctx.lineWidth = sliperWidth
// ctx.lineCap = 'round'
ctx.arc(
centerX,
centerY,
clockR + sliperWidth / 2 + spaceWidth,
slipAngleL,
slipAngleR
)
ctx.stroke()
ctx.beginPath()
ctx.strokeStyle = 'red'
ctx.arc(
centerX,
centerY,
clockR + sliperWidth / 2 + spaceWidth,
slipAngleL,
formatAngle(slipAngleL + POINT_ANGLE)
)
ctx.stroke()
ctx.closePath()
ctx.beginPath()
ctx.strokeStyle = 'green'
ctx.arc(
centerX,
centerY,
clockR + sliperWidth / 2 + spaceWidth,
formatAngle(slipAngleR - POINT_ANGLE),
slipAngleR
)
ctx.stroke()
ctx.strokeStyle = 'black'
ctx.closePath()
}
}, [ctxInfo, slipAngleL, slipAngleR, isDrappingPoint])
useEffect(() => {
if (canvasRef.current) {
// 开始准备工作
const canvas = canvasRef.current
const [width, height] = [canvas.width, canvas.height]
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
setCtxInfo({
ctx,
width,
height,
centerX: width / 2,
centerY: height / 2,
clockR: 100,
sliperWidth: 24,
spaceWidth: 5,
})
}
}, [canvasRef.current])
// 判断鼠标是否在滑块上
const isOnSlip = useCallback(
(e: any) => {
if (ctxInfo) {
const [mouseX, mouseY] = [e.nativeEvent.offsetX, e.nativeEvent.offsetY]
const { centerX, centerY, clockR, sliperWidth, spaceWidth } = ctxInfo
// 鼠标到中心的距离
const r = Math.sqrt(
Math.pow(Math.abs(mouseX - centerX), 2) +
Math.pow(Math.abs(mouseY - centerY), 2)
)
// 鼠标到中心的角度
let angle = Math.atan2(mouseY - centerY, mouseX - centerX)
if (angle < 0) angle = Math.PI * 2 + angle
const langle = slipAngleL
const rangle =
slipAngleR < slipAngleL ? slipAngleR + 2 * Math.PI : slipAngleR
const cangle = angle < slipAngleL ? angle + 2 * Math.PI : angle
const result =
r < clockR + spaceWidth + sliperWidth &&
r > clockR + spaceWidth &&
cangle - langle < rangle - langle
// 副作用 - 判断是否鼠标在拖动顶点
// 当鼠标位置与顶点位置夹角小于 Math.PI / 12 被视为在拖动顶点
if (result) {
// 点击位置与左右节点的差值
const ldiff = Math.abs(slipAngleL - angle)
const rdiff = Math.abs(slipAngleR - angle)
if (ldiff > POINT_ANGLE && rdiff > POINT_ANGLE) setDrapping(false)
if (ldiff > POINT_ANGLE && rdiff < POINT_ANGLE) setDrapping('r')
if (ldiff < POINT_ANGLE && rdiff > POINT_ANGLE) setDrapping('l')
} else {
setDrapping(false)
}
// 副作用结束
return result
}
return false
},
[slipAngleL, slipAngleR, ctxInfo]
)
// 鼠标按下事件
const mouseDown = useCallback<MouseEventHandler>(
(e) => {
if (isOnSlip(e)) {
setMouseIsDown(true)
mouseLastPsition.current = {
x: e.nativeEvent.offsetX,
y: e.nativeEvent.offsetY,
}
}
},
[isOnSlip]
)
// 鼠标抬起事件
const mouseUp = useCallback<MouseEventHandler>((e) => {
setMouseIsDown(false)
setDrapping(false)
}, [])
// 鼠标移动事件
const mouseMove = useCallback<MouseEventHandler>(
(e) => {
if (mouseIsDown && ctxInfo) {
const { centerX, centerY } = ctxInfo
const { x: mouseLastX, y: mouseLastY } = mouseLastPsition.current
let startAngle = Math.atan2(mouseLastY - centerY, mouseLastX - centerX)
if (startAngle < 0) startAngle = Math.PI * 2 + startAngle
let mouseAngle = Math.atan2(
e.nativeEvent.offsetY - centerY,
e.nativeEvent.offsetX - centerX
)
if (mouseAngle < 0) mouseAngle = Math.PI * 2 + mouseAngle
mouseLastPsition.current = {
x: e.nativeEvent.offsetX,
y: e.nativeEvent.offsetY,
}
const targetL = formatAngle(slipAngleL + mouseAngle - startAngle)
const targetR = formatAngle(slipAngleR + mouseAngle - startAngle)
// 拖动顶点
if (isDrappingPoint) {
if (isDrappingPoint === 'l') {
const delta = Math.abs(targetL - slipAngleR)
// 计算夹角的弧度值
const angleBetween = Math.min(delta, 2 * Math.PI - delta)
if (
Number(angleBetween.toFixed(2)) >= Number(MIN_ANGLE.toFixed(2))
) {
setSlipAngleL(targetL)
return
}
} else {
const delta = Math.abs(targetR - slipAngleL)
// 计算夹角的弧度值
const angleBetween = Math.min(delta, 2 * Math.PI - delta)
if (
Number(angleBetween.toFixed(2)) >= Number(MIN_ANGLE.toFixed(2))
) {
setSlipAngleR(targetR)
return
}
}
}
// 整体拖动
setSlipAngleL(targetL)
setSlipAngleR(targetR)
}
if (isOnSlip(e)) {
e.currentTarget.setAttribute('style', 'cursor: pointer;')
} else {
e.currentTarget.setAttribute('style', '')
}
},
[slipAngleL, slipAngleR, isOnSlip, mouseIsDown, isDrappingPoint, ctxInfo]
)
return (
<CanvasStyled
ref={canvasRef}
width={800}
height={500}
onMouseDown={mouseDown}
onMouseUp={mouseUp}
onMouseMove={mouseMove}
/>
)
}
export default Tester
原创不易,点个赞/关注吧❤️