HTML 有一个 <input type="range">
,可以说,这是最简单的比例滑块类型。滑块的滑块最终所在的位置可以表示其前后任何内容的比例(使用 value
和 max
属性)。更进一步,可以构建 多滑块。但今天我们还有其他想法……一个带有多个滑块的比例滑块,以及不能重叠的部分。
以下是我们今天要构建的内容
这是一个滑块,但不仅仅是任何滑块。它是一个比例滑块。其各个部分必须全部加起来等于 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;
}
到目前为止,我们已经完成了这些。请注意,滑块手柄还没有任何作用!我们接下来会处理它。
可调整的滑块部分
我们希望滑块部分在使用鼠标光标或触摸拖动滑块按钮时进行调整移动。我们将通过确保部分宽度更改对应于滑块按钮拖动的程度来实现这一点。这需要我们回答几个问题
- 当单击滑块按钮时,我们如何获取光标位置?
- 当拖动滑块按钮时,我们如何获取光标位置?
- 我们如何使标签部分的宽度对应于标签部分按钮拖动的程度?
一个接一个地……
当单击滑块按钮时,我们如何获取光标位置?
让我们将 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;
}}
/>
搞定一个!
拖动滑块按钮时,我们如何获取光标位置?
现在我们需要添加一个事件监听器来监听拖动事件,即 pointermove
和 touchmove
。我们将使用这些事件来覆盖鼠标光标和触摸移动。一旦用户的手指从屏幕上抬起(从而结束拖动),部分宽度需要停止更新:
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
钩子获取 TagSlider
的 ref
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();
}
}
瞧!
这是最终的滑块
这是一个很好的想法,可以像这样对不同的类型进行加权。
最终的滑块存在一个问题,即您可以移除4个类型中的2个,并且似乎除了重新加载之外没有其他方法可以恢复它们。
也许在计算中添加最小宽度可以解决此问题?
我认为我对从滑块中移除标签时标签达到0%的唯一抱怨是,它目前在触摸/点击结束之前就执行了此操作。因此,作为用户,如果我不小心滑动得太远,标签现在就消失了。也许可以将移除事件侦听器与触摸/鼠标事件的结束相关联?
这是一个好主意,用户体验似乎有点奇怪。
非常棒!
不过,有点奇怪的是,即使我没有松开鼠标按钮,也可以移除部分。我只会这样做在鼠标松开时。否则,用户将不得不重新加载整个页面以获取他“丢失”的部分,仅仅因为他玩了一会或者因为他将鼠标拖得太远。
嗨,chris,本教程只是为了展示如何构建这种滑块。我没有添加获取部分的方法,因为这会偏离主题。在实际应用中,绝对应该有一种方法可以取回它们,否则会造成糟糕的用户体验。
很棒,感觉很好
有没有办法在开始时设置自定义宽度?