写在开头
哈喽呀,早上好各位!😀
今天...Em...好像也没有什么好唠叨的,奥运会也闭幕了,单调的生活又少了一点乐子,归于平静。
世人都说平淡的生活,才是人生的常态,是幸福的基石。嗯...应该是吧,就这样。😋
正题!这次要分享的是圆形滑动输入条的内容,具体效果如下,请诸君按需食用。
Em...没错,又是和拖动相关的呢😋,已经连续写了好几篇文章都有拖动的操作,快玩出花了。
前因
最近,小编在业务中使用到了 element-plus 的滑块组件。
当然,这已经足够用了,仅是普通业务而已。
不过,当时在想为什么没有圆形的滑块呢❓不好用吗❓不是也挺有趣的❓🙊
小编还查看了 ant-design 与 arco-design UI框架,好像是没有想象的这种圆形滑块呢。😶
ant-design与arco-design管滑块组件叫滑动输入条,Em,好像更形象一点。
嘿嘿,那就自己来敲一个耍耍吧,说不定以后能用上也说不定,冲。💪
沿着圆拖动
要想完成圆形滑动输入条功能,首先,最基础、最关键的能力就是能滑动,也就是要能拖动,而且是绕着圆拖动。
咱们循序渐进,先来让元素变得可拖动,且看:
<!DOCTYPE html>
<html>
<head>
<style>
body {
padding: 0;
margin: 0;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.slider {
height: 200px;
width: 200px;
border: 1px solid #e4e7ed;
border-radius: 50%;
box-sizing: border-box;
position: relative;
}
.slider__ball {
height: 16px;
width: 16px;
border-radius: 50%;
background-color: #64748b;
position: absolute;
top: 0;
left: 0;
cursor: move;
user-select: none;
}
</style>
</head>
<body>
<div class="slider">
<div class="slider__ball"></div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const sliderDOM = document.querySelector('.slider');
const sliderBallDOM = document.querySelector('.slider__ball');
let dx = 0;
let dy = 0;
sliderBallDOM.addEventListener("mousedown", handleMouseDown);
function handleMouseDown(e) {
const startPos = {
x: e.clientX - dx,
y: e.clientY - dy,
};
function handleMouseMove(e) {
dx = e.clientX - startPos.x;
dy = e.clientY - startPos.y;
sliderBallDOM.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
};
function handleMouseUp() {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
});
</script>
</body>
</html>
效果:
很简单哈,还是一样熟悉的配方。😋
接下来,我们要来解决如何才能让小球沿着圆来拖动呢❓
咱们现在拥有 dx
与 dy
两个变量,它们是小球的拖动距离。
我们知道每次拖动,会产生很多 (dx1, dy1)/(dx2, dy2)/(dx3, dy3)/...
的坐标,将这些坐标不断赋值给到小球,小球就能顺着这些坐标运动起来;而圆在页面也可以看成是 (rx1, ry1)/(rx2, ry2)/(rx3, ry3)/...
的坐标集合;那如果咱们将每次拖动产生的 dx/dy
变量转化成按圆的坐标集合,再赋值给到小球,那么小球就能沿着圆运动了。
Em...原理大概就是这么个意思吧🙊,但要完成这个过程,也没那么简单,需要涉及一些基础的数学知识,三角函数、勾股定理都知道吧。😃
先来看看代码的实现过程吧,讲多了也没啥用。
document.addEventListener("DOMContentLoaded", () => {
// ...
const { width: sliderWidth } = sliderDOM.getBoundingClientRect();
const { width: sliderBallWidth } = sliderBallDOM.getBoundingClientRect();
// 计算圆的半径
const radius = sliderWidth / 2;
// 计算小球的几何中心
const center = radius - sliderBallWidth / 2;
function handleMouseDown(e) {
// ...
function handleMouseMove(e) {
const dxTemp = e.clientX - startPos.x;
const dyTemp = e.clientY - startPos.y;
// 计算拖动位置到中心的距离和角度
const centerDistance = Math.sqrt(
Math.pow(dxTemp - center, 2) + Math.pow(dyTemp - center, 2)
);
const sinValue = (dyTemp - center) / centerDistance;
const cosValue = (dxTemp - center) / centerDistance;
// 根据角度和半径计算新的位置
dx = center + radius * cosValue;
dy = center + radius * sinValue;
};
}
});
增加了一些代码,咱由上往下讲,圆半径(radius
),呃...就不说了,都懂。😗
小球的几何中心(center
),指的是小球元素的边框(或更准确地说,是左边缘)到圆形容器中心点的水平距离。我们将小球元素放置在这个位置上时,小球的中心将与圆形容器的边缘对齐。
centerDistance
是小球中心点到圆中心点距离,这个公式(Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2))
)用于计算二维空间中两点之间的距离。由于小球的几何中心位置信息 center.x
与 center.y
是一样的,所以简化为 center
。
知道这个距离后,用这个距离再根据三角函数公式就能计算出 sinValue
和 cosValue
,它们代表了从中心点到拖动点的直角三角形的正弦和余弦值。
而有了正弦和余弦值后,再结合半径,继续根据三角函数公式计算,就能再次算出新的位置信息了。
效果:
虽然稍微有点绕😵,但这应该是大部分人能慢慢推导出来的结果的。
咱们再来看看"高级"一点的形式,算是优化吧。😋
function handleMouseMove(e) {
const dxTemp = e.clientX - startPos.x;
const dyTemp = e.clientY - startPos.y;
// 计算拖动的弧度值
const rawAngleRadians = Math.atan2(dyTemp - center, dxTemp - center);
dx = center + radius * Math.cos(rawAngleRadians);
dy = center + radius * Math.sin(rawAngleRadians);
sliderBallDOM.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
}
通过调用 JS
的 Math.atan2 函数(反正切),计算出两个坐标点之间的的平面角度(以弧度为单位),咱就称为弧度值吧。有了这个弧度值后,结合半径并直接使用 JS
中的三角函数,就能很轻松算出新的坐标信息了。
最后的效果也是一样的,挺过这个计算其他的就比较简单一点了,还没完,继续来看。😪
初始化状态
从上面动图中可以看到,初始化时,小球的位置并不在圆上,这是咱们要解决的第二个问题。我们增加一个初始化函数来处理所有的初始情况:
function init(initialRadian = 0) {
// 计算初始弧度值,初始的小球位置(dx/dy)假设都为0
const radians = Math.atan2(0 - center, 0 - center);
// 看下面解释
const radian = initialRadian * Math.PI * 2 - Math.PI;
dx = center + radius * Math.cos(radian);
dy = center + radius * Math.sin(radian);
sliderBallDOM.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
}
init();
当 init()
时,效果:
当 init(0.25)
时,效果:
当 init(0.5)
时,效果:
其实咱们无非就是要给 dx
与 dy
一个初始值,它们初始值不再是 0
了。❌
而计算它们的值,咱们还是可以通过 Math.atan2 函数的形式计算,只是咱们加了一个 initialRadian
参数来控制初始的位置,方便后续的其他扩展。
Math.PI * 2
:表示圆的弧长,就是我们常说的2π
,应该都知道吧。initialRadian * Math.PI * 2
:确定初始弧度值在整个圆周上所占的比例,initialRadian
值范围是0~1。- Math.PI
:减去π
是将参考点(或初始点)从圆的右侧(或3点钟位置)转移到圆的左侧(或9点钟位置)。
- Math.PI
:不减其实也没事,或者减去2π
都可以,但你要记好初始点位置,小编的初始点位置是从左边端点开始。注意,一旦减去,后续的其他计算都要考虑到这个操作,计算的时候要加回来❗
显示进度
滑块组件,一般拖动的时候,都会显示一个数值来表示进度状态。🔢
咱们也给自己的圆形滑动输入条加上一个进度状态,我们以百分比的形式来展示,这更符合圆形状态。当然,你也可以转化成其他的数值。
结构:
<div class="slider">
<div class="slider__ball"></div>
<div class="percent"></div>
</div>
样式:
.slider {
/* ... */
/* 显示在圆正中间 */
display: flex;
justify-content: center;
align-items: center;
}
逻辑实现:
const percentDOM = document.querySelector('.percent');
function handleMouseMove(e) {
// ...
// 计算进度状态
const percent = (rawAngleRadians + Math.PI) / (Math.PI * 2);
percentDOM.innerText = `${Math.round(percent * 100)}%`;
};
function init(initialRadian = 0) {
// ...
percentDOM.innerText = `${Math.round(initialRadian * 100)}%`;
}
效果:
逻辑就增加了四行代码😃,rawAngleRadians
为前面计算出来的弧度值,+ Math.PI
就是因为我们在 init
函数中减掉了一个 π
,这会就要加回来了。然后用其来除以圆的周长(Math.PI * 2
)就能算出占比了。
当然,初始的时候也别忘记了,可以看到在 init
函数中也增加了一行代码。而且呢,你发现没有,刚好 initialAngle
参数的值范围是0~1是最合适的😉,这当然也是有预谋的设定了。
由于存在多次的 DOM
操作,咱们把代码优化一下:
// ...
// 弧度值所占周长大小,0~1
let totalRadian = 0;
function handleMouseMove(e) {
// ...
totalRadian = (rawAngleRadians + Math.PI) / (Math.PI * 2);
updatePageView();
};
function init(initialRadian = 0) {
// ...
totalRadian = initialRadian;
updatePageView();
}
/** @name 统一更新页面视图 **/
function updatePageView() {
sliderBallDOM.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
percentDOM.innerText = `${Math.round(totalRadian * 100)}%`;
}
增加了统一更新 DOM
的方法,后续如果需要把其改造成 Vue
或者 React
组件时,可以直接按照该方法去绑定 DOM
就行,其他逻辑应该是照旧。
轨道样式
咱们已经成功打造了圆形滑动输入条的核心功能,它可以流畅拖动,清晰展示进度,基本满足了用户的输入需求。
但现在,我们要给它穿上华丽的外衣(怎么也要穿件衣服吧😋),让它不仅仅止步于功能的实用,而是要颜值与实力并存。开玩笑的,反正就让它好看一点吧,如下的样子:
Em...有点像环形图呢。👻
结构:
<div class="container">
<div class="slider">
<div class="slider__ball"></div>
<div class="percent"></div>
</div>
<div class="overlay"></div>
<div class="half half__1"></div>
<div class="half half__2"></div>
</div>
包裹了一个容器,同级增加了三个元素。
样式:
/* ... */
.slider {
height: 100%;
width: 100%;
/* width: 200px;*/
/* height: 200px;*/
/* border: 1px solid #e4e7ed; */
/* ... */
}
.container {
width: 200px;
height: 200px;
box-sizing: border-box;
padding: 8px;
border-radius: 50%;
position: relative;
background-color: #cbd5e1;
overflow: hidden;
}
.overlay {
border-radius: 50%;
background: #fff;
position: absolute;
top: 16px;
left: 16px;
right: 16px;
bottom: 16px;
z-index: 5;
}
.half {
position: absolute;
top: 0;
left: 0;
height: 50%;
width: 100%;
transform-origin: 50% 100%;
z-index: 1;
}
.half__1 {
background: #409eff;
}
逻辑实现:
function updatePageView() {
sliderBallDOM.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
percentDOM.innerText = `${Math.round(totalRadian * 100)}%`;
// 轨道
halfDOM.style.backgroundColor = totalRadian > 0.5 ? '#409eff' : 'inherit';
halfDOM.style.transform = `rotate(${totalRadian > 0.5 ? 360 * totalRadian - 180 : 360 * totalRadian}deg)`;
}
上面,咱们拥有一个 totalRadian
的变量,它指的是拖动的弧度值占圆形容器的周长比例,有这玩意,可以把它转换成角度,根据如下的公式:
- 将角度转换为弧度:
const radians = degrees * (Math.PI/180);
- 将弧度转换为角度:
const degrees = radians * (180/Math.PI);
有了角度后,就好办了。😃
不知道你以前有没有单独用 DOM
绘制过饼图,它的原理就是利用旋转,也就是角度来实现的。当然,现在都是使用 Canvas
了,时代变了。🌏
下面小编使用 DOM
单独绘制了两个饼图,你可以仔细瞧瞧其原理过程。而咱们的圆形滑动输入条是环形图,只是多了一个 .overlay
元素遮住了中间部分而已。
固定刻度滑动
解决完组件样子问题后,咱们可以再来给组件升升级,增加一些其他功能。
比如,像滑块这种"离散值"的功能:
😯Em...是个不错的功能,来看看如何实现。
先来生成一些刻度:
// 分成10个刻度
const step = 10;
/** @name 生成刻度 **/
function createTicks(step = 0) {
const container = document.querySelector(".container");
// 通过圆周长计算每个刻度的大小
const angleStep = (2 * Math.PI) / step;
// 遍历每个刻度,创建一个 div
for (let i = 0; i < step; i++) {
const tick = document.createElement("div");
tick.classList.add("tick");
// 计算当前刻度的位置
const angle = angleStep * i;
const x = center + radius * Math.cos(angle);
const y = center + radius * Math.sin(angle);
// 设置刻度的位置
tick.style.position = "absolute";
tick.style.left = `${x + 16 - 3}px`;
tick.style.top = `${y + 16 - 3}px`;
// 添加刻度到容器
container.appendChild(tick);
}
}
createTicks(step);
样式:
/* ... */
.tick {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #fff;
position: absolute;
z-index: 8;
}
效果:
先假定生成10个小刻度,接下来滑动的时候,我们就需要按照这些刻度规规矩矩的滑动,不能随心所欲了。
由于小球运动是由 dx/dy
来决定的,所以按刻度来运动的话,其实也就是限制 dx/dy
变量的生成规则就行了。
const step = 10;
const degreeIncrement = 360 / step; // 最小移动刻度
const degreesToRadians = Math.PI / 180; // 将角度转换为弧度的常数
function handleMouseMove(e) {
const dxTemp = e.clientX - startPos.x;
const dyTemp = e.clientY - startPos.y;
// 计算拖动弧度值
const rawAngleRadians = Math.atan2(dyTemp - center, dxTemp - center);
// 防止在开始拖动时,细微的操作就完成完整拖动了
const radiansTemp = (rawAngleRadians + Math.PI) / (Math.PI * 2);
if (radiansTemp * 360 === 360) return;
// 计算限制后新的弧度值
const roundedAngleRadians = Math.round(
rawAngleRadians / (degreeIncrement * degreesToRadians)
) * (degreeIncrement * degreesToRadians);
// 生成规整的dx/dy
dx = center + radius * Math.cos(roundedAngleRadians);
dy = center + radius * Math.sin(roundedAngleRadians);
// 单独计算弧度值,保持轨道与小球运动同步
const radians = Math.atan2(dy - center, dx - center);
totalRadian = (radians + Math.PI) / (Math.PI * 2);
updatePageView();
};
还是仅需要在 handleMouseMove
函数限制 dx/dy
的生成就可以,具体就看上面的注释啦。😉
那么,到此就完结了,总得来说,也挺简单吧,百行代码而已,就是一些计算麻烦一点,下面放了完整的源码,需要可以再瞅瞅,告辞。👻👻👻
完整源码
传送门 👈👈👈
至此,本篇文章就写完啦,撒花撒花。
希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。