圆形滑动输入条(滑块-Slider)-Javascript✨✨✨

1,500 阅读8分钟

写在开头

哈喽呀,早上好各位!😀

今天...Em...好像也没有什么好唠叨的,奥运会也闭幕了,单调的生活又少了一点乐子,归于平静。

世人都说平淡的生活,才是人生的常态,是幸福的基石。嗯...应该是吧,就这样。😋

正题!这次要分享的是圆形滑动输入条的内容,具体效果如下,请诸君按需食用。

2024813-1.gif

Em...没错,又是和拖动相关的呢😋,已经连续写了好几篇文章都有拖动的操作,快玩出花了。

前因

最近,小编在业务中使用到了 element-plus滑块组件

image.png

当然,这已经足够用了,仅是普通业务而已。

不过,当时在想为什么没有圆形的滑块呢❓不好用吗❓不是也挺有趣的❓🙊

小编还查看了 ant-designarco-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>

效果:

2024813-2.gif

很简单哈,还是一样熟悉的配方。😋

接下来,我们要来解决如何才能让小球沿着圆来拖动呢❓

咱们现在拥有 dxdy 两个变量,它们是小球的拖动距离。

我们知道每次拖动,会产生很多 (dx1, dy1)/(dx2, dy2)/(dx3, dy3)/... 的坐标,将这些坐标不断赋值给到小球,小球就能顺着这些坐标运动起来;而圆在页面也可以看成是 (rx1, ry1)/(rx2, ry2)/(rx3, ry3)/... 的坐标集合;那如果咱们将每次拖动产生的 dx/dy 变量转化成按圆的坐标集合,再赋值给到小球,那么小球就能沿着圆运动了。

Em...原理大概就是这么个意思吧🙊,但要完成这个过程,也没那么简单,需要涉及一些基础的数学知识,三角函数、勾股定理都知道吧。😃

image.png

维基百科

先来看看代码的实现过程吧,讲多了也没啥用。

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.xcenter.y 是一样的,所以简化为 center

image.png

知道这个距离后,用这个距离再根据三角函数公式就能计算出 sinValuecosValue,它们代表了从中心点到拖动点的直角三角形的正弦和余弦值。

image.png

而有了正弦和余弦值后,再结合半径,继续根据三角函数公式计算,就能再次算出新的位置信息了。

效果:

2024813-3.gif

虽然稍微有点绕😵,但这应该是大部分人能慢慢推导出来的结果的。

咱们再来看看"高级"一点的形式,算是优化吧。😋

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)`;
}

通过调用 JSMath.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() 时,效果:

image.png

init(0.25) 时,效果:

image.png

init(0.5) 时,效果:

image.png

其实咱们无非就是要给 dxdy 一个初始值,它们初始值不再是 0 了。❌

而计算它们的值,咱们还是可以通过 Math.atan2 函数的形式计算,只是咱们加了一个 initialRadian 参数来控制初始的位置,方便后续的其他扩展。

  • Math.PI * 2:表示圆的弧长,就是我们常说的 ,应该都知道吧。
  • initialRadian * Math.PI * 2:确定初始弧度值在整个圆周上所占的比例,initialRadian 值范围是0~1。
  • - Math.PI:减去 π 是将参考点(或初始点)从圆的右侧(或3点钟位置)转移到圆的左侧(或9点钟位置)。

- Math.PI:不减其实也没事,或者减去 都可以,但你要记好初始点位置,小编的初始点位置是从左边端点开始。注意,一旦减去,后续的其他计算都要考虑到这个操作,计算的时候要加回来❗

显示进度

滑块组件,一般拖动的时候,都会显示一个数值来表示进度状态。🔢

image.png

咱们也给自己的圆形滑动输入条加上一个进度状态,我们以百分比的形式来展示,这更符合圆形状态。当然,你也可以转化成其他的数值。

结构:

<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)}%`;
}

效果:

2024813-4.gif

逻辑就增加了四行代码😃,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 就行,其他逻辑应该是照旧。

轨道样式

咱们已经成功打造了圆形滑动输入条的核心功能,它可以流畅拖动,清晰展示进度,基本满足了用户的输入需求。

但现在,我们要给它穿上华丽的外衣(怎么也要穿件衣服吧😋),让它不仅仅止步于功能的实用,而是要颜值与实力并存。开玩笑的,反正就让它好看一点吧,如下的样子:

image.png

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 元素遮住了中间部分而已。

固定刻度滑动

解决完组件样子问题后,咱们可以再来给组件升升级,增加一些其他功能。

比如,像滑块这种"离散值"的功能:

image.png

😯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;
}

效果:

image.png

先假定生成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 的生成就可以,具体就看上面的注释啦。😉

2024813-5.gif

那么,到此就完结了,总得来说,也挺简单吧,百行代码而已,就是一些计算麻烦一点,下面放了完整的源码,需要可以再瞅瞅,告辞。👻👻👻

完整源码

传送门 👈👈👈





至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。