让我们制作一个计算滑块间宽度的多滑块

Avatar of Simdi Jinkins
Simdi Jinkins

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

HTML 有一个 <input type="range">,可以说,这是最简单的比例滑块类型。滑块的滑块最终所在的位置可以表示其前后任何内容的比例(使用 valuemax 属性)。更进一步,可以构建 多滑块。但今天我们还有其他想法……一个带有多个滑块的比例滑块,以及不能重叠的部分。

以下是我们今天要构建的内容

这是一个滑块,但不仅仅是任何滑块。它是一个比例滑块。其各个部分必须全部加起来等于 100%,用户可以在其中调整不同的部分。

为什么要需要这样的东西?

也许您想构建一个预算应用程序,其中这样的滑块将包含您各种计划的支出

我需要这样的东西来制作一个动画电影推荐平台。在研究 UX 模式时,我注意到其他动画电影推荐网站有这种东西,您可以在其中选择标签以获取电影推荐。这很酷,但如果为这些标签添加权重会更酷,以便权重较大的标签的推荐优先于权重较轻的其他标签。我四处寻找其他想法,但没有找到太多。

所以我构建了这个!信不信由你,制作它并没有那么复杂。让我带您逐步了解。

静态滑块

我们将在 React 和 TypeScript 中构建它。但这并不是必需的。这些概念应该可以移植到原生 JavaScript 或任何其他框架中。

让我们使用动态数据来制作这个滑块作为开始。我们将不同的部分保存在一个名为 _tags 的变量数组中,其中包含每个部分的名称和颜色。

const _tags = [
  {
    name: "Action",
    color: "red"
  },
  {
    name: "Romance",
    color: "purple"
  },
  {
    name: "Comedy",
    color: "orange"
  },
  {
    name: "Horror",
    color: "black"
  }
];

每个标签部分的宽度由一个百分比数组控制,这些百分比加起来为 100%。这是通过使用 Array.Fill() 方法 初始化每个宽度的状态来完成的

const [widths, setWidths] = useState<number[]>(new Array(_tags.length).fill(100 / _tags.length))

接下来,我们将创建一个组件来渲染单个标签部分

interface TagSectionProps {
  name: string
  color: string
  width: number
}


const TagSection = ({ name, color, width }: TagSectionProps) => {
  return <div
    className='tag'
    style={{ ...styles.tag, background: color, width: width + '%' }}
>
    <span style={styles.tagText}>{name}</span>
   <div
     style={styles.sliderButton}
     className='slider-button'>        
     <img src={"https://assets.codepen.io/576444/slider-arrows.svg"} height={'30%'} />
    </div>
  </div >
}

然后,我们将通过映射 _tags 数组并返回上面创建的 TagSection 组件来渲染所有部分

const TagSlider = () => {
  const [widths, setWidths] = useState<number[]>((new Array(_tags.length).fill(100 / _tags.length)))
  return <div
    style={{
      width: '100%',
      display: 'flex'
    }}>
    {
    _tags.map((tag, index) => <TagSection
      width={widths[index]}
      key={index}
      name={tag.name}
      color={tag.color}
    />)
    }
  </div>
}

为了使圆角边框并隐藏最后一个滑块按钮,让我们在 CSS 中使用 :first-of-type:last-of-type 伪选择器

.tag:first-of-type {
  border-radius: 50px 0px 0px 50px;
}
.tag:last-of-type {
  border-radius: 0px 50px 50px 0px;
}
.tag:last-of-type>.slider-button {
  display:none !important;
}

到目前为止,我们已经完成了这些。请注意,滑块手柄还没有任何作用!我们接下来会处理它。

可调整的滑块部分

我们希望滑块部分在使用鼠标光标或触摸拖动滑块按钮时进行调整移动。我们将通过确保部分宽度更改对应于滑块按钮拖动的程度来实现这一点。这需要我们回答几个问题

  1. 当单击滑块按钮时,我们如何获取光标位置?
  2. 当拖动滑块按钮时,我们如何获取光标位置?
  3. 我们如何使标签部分的宽度对应于标签部分按钮拖动的程度?

一个接一个地……

当单击滑块按钮时,我们如何获取光标位置?

让我们将 onSliderSelect 事件处理程序添加到 TagSectionProps 接口中

interface TagSectionProps {
  name: string;
  color: string;
  width: number;
  onSliderSelect: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
}

onSliderSelect 事件处理程序附加到 TagSection 组件的 onPointerDown 事件

const TagSection = ({
  name,
  color,
  width,
  onSliderSelect // Highlight
}: TagSectionProps) => {
  return (
    <div
      className="tag"
      style={{
        ...styles.tag,
        background: color,
        width: width + "%"
        }}
    >
      <span style={styles.tagText}>{name}</span>
      <span style={{ ...styles.tagText, fontSize: 12 }}>{width + "%"}</span>
  
      <div
        style={styles.sliderButton}
        onPointerDown={onSliderSelect}
        className="slider-button"
      >
      <img src={"https://animesonar.com/slider-arrows.svg"} height={"30%"} />
      </div>
    </div>
  );
};

我们使用 onPointerDown 而不是 onMouseDown 来捕获鼠标和触摸屏事件。了解更多信息

我们在 onSliderSelect 道具函数中使用 e.pageX 来获取单击滑块按钮时光标的位置

<TagSection
  width={widths[index]}
  key={index}
  name={tag.name}
  onSliderSelect={(e) => {
    const startDragX = e.pageX;
  }}
/>

搞定一个!

拖动滑块按钮时,我们如何获取光标位置?

现在我们需要添加一个事件监听器来监听拖动事件,即 pointermovetouchmove。我们将使用这些事件来覆盖鼠标光标和触摸移动。一旦用户的手指从屏幕上抬起(从而结束拖动),部分宽度需要停止更新:

window.addEventListener("pointermove", resize);
window.addEventListener("touchmove", resize);


const removeEventListener = () => {
  window.removeEventListener("pointermove", resize);
  window.removeEventListener("touchmove", resize);
}


const handleEventUp = (e: Event) => {
  e.preventDefault();
  document.body.style.cursor = "initial";
  removeEventListener();
}


window.addEventListener("touchend", handleEventUp);
window.addEventListener("pointerup", handleEventUp);

resize 函数在拖动滑块按钮时给出光标的 X 坐标

const resize = (e: MouseEvent & TouchEvent) => {
  e.preventDefault();
  const endDragX = e.touches ? e.touches[0].pageX : e.pageX
}

resize 函数由 触摸事件 触发时,e.touches 是一个数组值——否则,它为 null,在这种情况下,endDragX 将取 e.pageX 的值。

我们如何使标签部分的宽度对应于标签部分按钮拖动的程度?

为了更改各个标签部分的宽度百分比,让我们获取光标相对于整个滑块宽度的移动距离作为百分比。从那里,我们将为标签部分分配该值。

首先,我们需要使用 React 的 useRef 钩子获取 TagSliderref

const TagSlider = () => {
const TagSliderRef = useRef<HTMLDivElement>(null);
  // TagSlider
  return (
    <div
      ref={TagSliderRef}
// ...

现在让我们使用其引用来获取 offsetWidth 属性来确定滑块的宽度,该属性以整数形式返回元素的布局宽度

onSliderSelect={(e) => {
  e.preventDefault();
  document.body.style.cursor = 'ew-resize';


  const startDragX = e.pageX;
  const sliderWidth = TagSliderRef.current.offsetWidth;
}};

然后我们计算光标相对于整个滑块移动的百分比距离

const getPercentage = (containerWidth: number, distanceMoved: number) => {
  return (distanceMoved / containerWidth) * 100;
};


const resize = (e: MouseEvent & TouchEvent) => {
  e.preventDefault();
  const endDragX = e.touches ? e.touches[0].pageX : e.pageX;
  const distanceMoved = endDragX - startDragX;
  const percentageMoved = getPercentage(sliderWidth, distanceMoved);
}

最后,我们可以将新计算出的部分宽度分配给 _widths 状态变量上的索引

const percentageMoved = getPercentage(sliderWidth, distanceMoved);
const _widths = widths.slice();
const prevPercentage = _widths[index];
const newPercentage = prevPercentage + percentageMoved


_widths[index] = newPercentage;
setWidths(_widths);

但这还没有结束!其他部分的宽度没有改变,百分比最终可能为负数或加起来超过 100%。更不用说,所有部分宽度的总和并不总是等于 100%,因为我们还没有应用限制以防止总体百分比发生变化。

修复其他部分

让我们确保当相邻部分发生变化时,一部分的宽度也会发生变化。

const nextSectionNewPercentage = percentageMoved < 0 
  ? _widths[nextSectionIndex] + Math.abs(percentageMoved)
  : _widths[nextSectionIndex] - Math.abs(percentageMoved)

这会产生这样的效果:如果部分增加,则会减小相邻部分的宽度,反之亦然。我们甚至可以缩短它

const nextSectionNewPercentage = _widths[nextSectionIndex] - percentageMoved

调整部分百分比仅应影响其右侧的相邻部分。这意味着给定部分的最大值应为其宽度加上其相邻宽度,当允许它占据整个相邻空间时。

我们可以通过计算最大百分比值来实现这一点

const maxPercent = widths[index] + widths[index+1]

为了防止出现负宽度值,让我们将宽度限制为大于零但小于最大百分比的值:

const limitNumberWithinRange = (value: number, min: number, max: number):number => {
  return Math.min(Math.max(value,min),max)
}

limitNumberWithinRange 函数既可以防止负值,也可以防止部分总和导致值高于最大百分比的情况。(感谢 此 StackOverflow 线程。)

我们可以将此函数用于当前部分及其相邻部分的宽度

const currentSectionWidth = limitNumberWithinRange(newPercentage, 0, maxPercent)
_widths[index] = currentSectionWidth


const nextSectionWidth = limitNumberWithinRange(nextSectionNewPercentage, 0, maxPercent);
_widths[nextSectionIndex] = nextSectionWidth;

额外操作

现在,滑块将每个部分的宽度计算为整个容器的某个疯狂小数的百分比。这非常精确,但对于这种类型的 UI 来说并不实用。如果我们想使用整数而不是小数,我们可以执行以下操作

const nearestN = (N: number, number: number) => Math.ceil(number / N) * N;
const percentageMoved = nearestN(1, getPercentage(sliderWidth, distanceMoved))

此函数将第二个参数的值近似到最接近的N(由第一个参数指定)。像此示例一样将N设置为1的效果是使百分比变化以整数表示,而不是微小的增量小数。

另一个需要注意的地方是考虑对百分比值为零的部分进行额外处理。由于这些部分不再占用整体宽度的任何比例,因此可能应该完全从滑块中移除。我们可以停止监听这些部分上的事件。

if (tags.length > 2) {
  if (_widths[index] === 0) {
     _widths[nextSectionIndex] = maxPercent;
    _widths.splice(index, 1);
    setTags(tags.filter((t, i) => i !== index));
    removeEventListener();
  }
  if (_widths[nextSectionIndex] === 0) {
    _widths[index] = maxPercent;
    _widths.splice(nextSectionIndex, 1);
    setTags(tags.filter((t, i) => i !== nextSectionIndex));
    removeEventListener();
  }
}

瞧!

这是最终的滑块