Vue3滑动输入条(Slider)

简介: 这是一个可高度定制的滑动输入条组件,支持多种配置选项,如宽度、最小值、最大值、是否禁用、双滑块模式等。主要功能包括点击滑动条快速定位并获取数值、拖动滑块调整数值、键盘操作调整数值以及自定义Tooltip显示格式。组件通过监听DOM尺寸变化来动态调整布局,并利用requestAnimationFrame优化动画效果,提供了丰富的交互体验。在线预览和详细代码示例可见[这里](https://themusecatcher.github.io/vue-amazing-ui/guide/components/slider.html)。

可自定义设置以下属性:

  • 滑动输入条宽度(width),单位 px,类型:string | number,默认 '100%'

  • 滑动输入条最小值(min),类型:number,默认 0

  • 滑动输入条最大值(max),类型:number,默认 100

  • 是否禁用(disabled),类型:boolean,默认 false

  • 是否使用双滑块模式(range),类型:boolean,默认 false

  • 步长,取值必须大于0,并且可被 (max - min) 整除(step),类型:number,默认 1

  • 格式化 tooltip 内容函数(formatTooltip),类型:(value: number) => string | number,默认 (value: number) => value,Slider 会把当前值传给 formatTooltip,并在 Tooltip 中显示 formatTooltip 的返回值

  • 是否展示 Tooltip(tooltip),类型:boolean,默认 true

  • 设置当前取值,当 range 为 false 时,使用 number,否则用 [number, number](v-model: value),类型:number | number[],默认 0

主要具有以下功能:

  • 点击滑动输入条直接调转到指定位置,并获取当前数值

  • 可分别拖动左右滑块改变当前数值

  • 点击聚焦任一滑块,然后使用键盘上(up)、下(down)、左(left)、右(right)、箭头改变数值

效果如下图:在线预览

注:组件引用方法 import { rafTimeout, cancelRaf } from '../utils' 请参考以下博客:

使用requestAnimationFrame模拟实现setTimeout和setInterval_theMuseCatcher的博客-CSDN博客使用requestAnimationFrame模拟实现setTimeout和setInterval!icon-default.png?t=N7T8https://blog.csdn.net/Dandrose/article/details/130167061

其中引入使用了以下工具函数:

①创建滑动输入条组件Slider.vue:

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { rafTimeout, cancelRaf, useResizeObserver } from '../utils'
interface Props {
  width?: string | number // 滑动输入条宽度,单位 px
  min?: number // 最小值
  max?: number // 最大值
  disabled?: boolean // 是否禁用
  range?: boolean // 是否使用双滑块模式
  step?: number // 步长,取值必须大于 0,并且可被 (max - min) 整除
  formatTooltip?: (value: number) => string | number // Slider 会把当前值传给 formatTooltip,并在 Tooltip 中显示 formatTooltip 的返回值
  tooltip?: boolean // 是否展示 Tooltip
  value?: number | number[] // (v-model) 设置当前取值,当 range 为 false 时,使用 number,否则用 [number, number]
}
const props = withDefaults(defineProps<Props>(), {
  width: '100%',
  min: 0,
  max: 100,
  disabled: false,
  range: false,
  step: 1,
  formatTooltip: (value: number) => value,
  tooltip: true,
  value: 0
})
const transition = ref(false)
const timer = ref()
const left = ref(0) // 左滑块距离滑动条左端的距离
const right = ref(0) // 右滑动距离滑动条左端的距离
const sliderRef = ref() // slider DOM 引用
const sliderWidth = ref()
const leftHandle = ref() // left handle 模板引用
const leftTooltip = ref() // left tooltip 模板应用
const rightHandle = ref() // right handler 模板引用
const rightTooltip = ref() // right tooltip 模板引用
const precision = computed(() => {
  // 获取 step 数值精度
  const strNumArr = props.step.toString().split('.')
  return strNumArr[1]?.length ?? 0
})
const totalWidth = computed(() => {
  if (typeof props.width === 'number') {
    return props.width + 'px'
  } else {
    return props.width
  }
})
const sliderValue = computed(() => {
  let high
  if (right.value === sliderWidth.value) {
    high = props.max
  } else {
    high = fixedDigit(pixelStepOperation(right.value, '/') * props.step + props.min, precision.value)
    if (props.step > 1) {
      high = Math.round(high / props.step) * props.step
    }
  }
  if (props.range) {
    let low = fixedDigit(pixelStepOperation(left.value, '/') * props.step + props.min, precision.value)
    if (props.step > 1) {
      low = Math.round(low / props.step) * props.step
    }
    return [low, high]
  }
  return high
})
const leftValue = computed(() => {
  if (props.range) {
    return props.formatTooltip((sliderValue.value as number[])[0])
  }
  return null
})
const rightValue = computed(() => {
  if (props.range) {
    return props.formatTooltip((sliderValue.value as number[])[1])
  }
  return props.formatTooltip(sliderValue.value as number)
})
const emits = defineEmits(['update:value', 'change'])
watch(
  () => props.value,
  () => {
    getPosition()
  }
)
watch(sliderValue, (to) => {
  emits('update:value', to)
  emits('change', to)
})
useResizeObserver(sliderRef, () => {
  getSliderWidth()
  getPosition()
})
onMounted(() => {
  getSliderWidth()
  getPosition()
})
function checkLow(value: number): number {
  if (value < props.min) {
    return props.min
  }
  return value
}
function checkHigh(value: number): number {
  if (value > props.max) {
    return props.max
  }
  return value
}
function checkValue(value: number): number {
  if (value < props.min) {
    return props.min
  }
  if (value > props.max) {
    return props.max
  }
  return value
}
function getSliderWidth() {
  sliderWidth.value = sliderRef.value.offsetWidth
}
function getPosition() {
  if (props.range) {
    // 双滑块模式
    const leftValue = pixelStepOperation((checkLow((props.value as number[])[0]) - props.min) / props.step, '*')
    left.value = fixedDigit(leftValue, 2)
    const rightValue = pixelStepOperation((checkHigh((props.value as number[])[1]) - props.min) / props.step, '*')
    right.value = fixedDigit(rightValue, 2)
  } else {
    const rightValue = pixelStepOperation((checkValue(props.value as number) - props.min) / props.step, '*')
    right.value = fixedDigit(rightValue, 2)
  }
}
function fixedDigit(num: number, precision: number) {
  return parseFloat(num.toFixed(precision))
}
function handlerBlur(tooltip: HTMLElement) {
  tooltip.classList.remove('show-handle-tooltip')
}
function handlerFocus(handler: HTMLElement, tooltip: HTMLElement) {
  handler.focus()
  if (props.tooltip) {
    tooltip.classList.add('show-handle-tooltip')
  }
}
function onClickPoint(e: any) {
  // 点击滑动条,移动滑块
  if (transition.value) {
    timer.value && cancelRaf(timer.value)
    timer.value = null
  } else {
    transition.value = true
  }
  timer.value = rafTimeout(() => {
    transition.value = false
  }, 200)
  // 元素是absolute时,e.layerX是相对于自身元素左上角的水平位置
  const value = Math.round(pixelStepOperation(e.layerX, '/'))
  const targetX = fixedDigit(pixelStepOperation(value, '*'), 2) // 鼠标点击位置距离滑动输入条左端的水平距离
  if (props.range) {
    // 双滑块模式
    if (targetX <= left.value) {
      left.value = targetX
      handlerFocus(leftHandle.value, leftTooltip.value)
    } else if (targetX >= right.value) {
      right.value = targetX
      handlerFocus(rightHandle.value, rightTooltip.value)
    } else {
      if (targetX - left.value < right.value - targetX) {
        left.value = targetX
        handlerFocus(leftHandle.value, leftTooltip.value)
      } else {
        right.value = targetX
        handlerFocus(rightHandle.value, rightTooltip.value)
      }
    }
  } else {
    // 单滑块模式
    right.value = targetX
    handlerFocus(rightHandle.value, rightTooltip.value)
  }
}
function onLeftMouseDown() {
  // 在滚动条上拖动左滑块
  const leftX = sliderRef.value.getBoundingClientRect().left // 滑动条左端距离屏幕可视区域左边界的距离
  window.onmousemove = (e: MouseEvent) => {
    if (props.tooltip) {
      leftTooltip.value.classList.add('show-handle-tooltip')
    }
    // e.clientX返回事件被触发时鼠标指针相对于浏览器可视窗口的水平坐标
    const value = Math.round(pixelStepOperation(e.clientX - leftX, '/'))
    const targetX = fixedDigit(pixelStepOperation(value, '*'), 2)
    if (targetX < 0) {
      left.value = 0
    } else if (targetX >= 0 && targetX <= right.value) {
      left.value = targetX
    } else {
      // targetX > right
      left.value = right.value
      rightHandle.value.focus()
      onRightMouseDown()
    }
  }
  window.onmouseup = () => {
    if (props.tooltip) {
      leftTooltip.value.classList.remove('show-handle-tooltip')
    }
    window.onmousemove = null
  }
}
function onRightMouseDown() {
  // 在滚动条上拖动右滑块
  const leftX = sliderRef.value.getBoundingClientRect().left // 滑动条左端距离屏幕可视区域左边界的距离
  window.onmousemove = (e: MouseEvent) => {
    if (props.tooltip) {
      rightTooltip.value.classList.add('show-handle-tooltip')
    }
    // e.clientX返回事件被触发时鼠标指针相对于浏览器可视窗口的水平坐标
    const value = Math.round(pixelStepOperation(e.clientX - leftX, '/'))
    const targetX = fixedDigit(pixelStepOperation(value, '*'), 2)
    if (targetX > sliderWidth.value) {
      right.value = sliderWidth.value
    } else if (left.value <= targetX && targetX <= sliderWidth.value) {
      right.value = targetX
    } else {
      // targetX < left
      right.value = left.value
      if (props.range) {
        leftHandle.value.focus()
        onLeftMouseDown()
      }
    }
  }
  window.onmouseup = () => {
    if (props.tooltip) {
      rightTooltip.value.classList.remove('show-handle-tooltip')
    }
    window.onmousemove = null
  }
}
function onLeftSlide(source: number, place: string) {
  const targetX = pixelStepOperation(source, '-')
  if (place === 'left') {
    // 左滑块左移
    if (targetX < 0) {
      left.value = 0
    } else {
      left.value = targetX
    }
  } else {
    // 右滑块左移
    if (targetX >= left.value) {
      right.value = targetX
    } else {
      right.value = left.value
      left.value = targetX
      leftHandle.value.focus()
    }
  }
}
function onRightSlide(source: number, place: string) {
  const targetX = pixelStepOperation(source, '+')
  if (place === 'right') {
    // 右滑块右移
    if (targetX > sliderWidth.value) {
      right.value = sliderWidth.value
    } else {
      right.value = targetX
    }
  } else {
    // 左滑块右移
    if (targetX <= right.value) {
      left.value = targetX
    } else {
      left.value = right.value
      right.value = targetX
      rightHandle.value.focus()
    }
  }
}
function pixelStepOperation(target: number, operator: '+' | '-' | '*' | '/'): number {
  if (operator === '+') {
    return target + (sliderWidth.value * props.step) / (props.max - props.min)
  }
  if (operator === '-') {
    return target - (sliderWidth.value * props.step) / (props.max - props.min)
  }
  if (operator === '*') {
    return (target * sliderWidth.value * props.step) / (props.max - props.min)
  }
  if (operator === '/') {
    return (target * (props.max - props.min)) / (sliderWidth.value * props.step)
  }
  return target
}
</script>
<template>
  <div ref="sliderRef" class="m-slider" :class="{ 'slider-disabled': disabled }" :style="`width: ${totalWidth};`">
    <div class="slider-rail" @click.self="disabled ? () => false : onClickPoint($event)"></div>
    <div
      class="slider-track"
      :class="{ 'track-transition': transition }"
      :style="`left: ${left}px; right: auto; width: ${right - left}px;`"
    ></div>
    <div
      v-if="range"
      tabindex="0"
      ref="leftHandle"
      class="slider-handle"
      :class="{ 'handle-transition': transition }"
      :style="`left: ${left}px; right: auto; transform: translate(-50%, -50%);`"
      @keydown.left.prevent="disabled ? () => false : onLeftSlide(left, 'left')"
      @keydown.right.prevent="disabled ? () => false : onRightSlide(left, 'left')"
      @keydown.down.prevent="disabled ? () => false : onLeftSlide(left, 'left')"
      @keydown.up.prevent="disabled ? () => false : onRightSlide(left, 'left')"
      @mousedown="disabled ? () => false : onLeftMouseDown()"
      @blur="tooltip && !disabled ? handlerBlur(leftTooltip) : () => false"
    >
      <div v-if="tooltip" ref="leftTooltip" class="handle-tooltip">
        {
  
  { leftValue }}
        <div class="tooltip-arrow"></div>
      </div>
    </div>
    <div
      tabindex="0"
      ref="rightHandle"
      class="slider-handle"
      :class="{ 'handle-transition': transition }"
      :style="`left: ${right}px; right: auto; transform: translate(-50%, -50%);`"
      @keydown.left.prevent="disabled ? () => false : onLeftSlide(right, 'right')"
      @keydown.right.prevent="disabled ? () => false : onRightSlide(right, 'right')"
      @keydown.down.prevent="disabled ? () => false : onLeftSlide(right, 'right')"
      @keydown.up.prevent="disabled ? () => false : onRightSlide(right, 'right')"
      @mousedown="disabled ? () => false : onRightMouseDown()"
      @blur="tooltip && !disabled ? handlerBlur(rightTooltip) : () => false"
    >
      <div v-if="tooltip" ref="rightTooltip" class="handle-tooltip">
        {
  
  { rightValue }}
        <div class="tooltip-arrow"></div>
      </div>
    </div>
  </div>
</template>
<style lang="less" scoped>
.m-slider {
  display: inline-block;
  height: 4px;
  position: relative;
  z-index: 9;
  touch-action: none; // 禁用元素上的所有手势,使用自己的拖动和缩放api
  .slider-rail {
    // 灰色未选择滑动条背景色
    position: absolute;
    z-index: 99;
    height: 4px;
    width: 100%;
    background-color: rgba(0, 0, 0, 0.04);
    border-radius: 2px;
    cursor: pointer;
    transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  }
  .slider-track {
    // 蓝色已选择滑动条背景色
    position: absolute;
    z-index: 99;
    background: lighten(fade(@themeColor, 54%), 10%);
    border-radius: 4px;
    height: 4px;
    cursor: pointer;
    transition: background 0.2s cubic-bezier(0.4, 0, 0.2, 1);
    pointer-events: none;
  }
  .track-transition {
    transition:
      left 0.2s cubic-bezier(0.4, 0, 0.2, 1),
      width 0.2s cubic-bezier(0.4, 0, 0.2, 1),
      background 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  }
  &:hover {
    .slider-rail {
      // 灰色未选择滑动条背景色
      background: rgba(0, 0, 0, 0.1);
    }
    .slider-track {
      // 蓝色已选择滑动条背景色
      background: @themeColor;
    }
  }
  .slider-handle {
    // 滑块
    position: absolute;
    z-index: 999;
    width: 14px;
    height: 14px;
    top: 50%;
    background: #fff;
    border: 2px solid lighten(fade(@themeColor, 54%), 10%);
    border-radius: 50%;
    cursor: pointer;
    outline: none;
    transition:
      width 0.2s cubic-bezier(0.4, 0, 0.2, 1),
      height 0.2s cubic-bezier(0.4, 0, 0.2, 1),
      border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
      border-width 0.2s cubic-bezier(0.4, 0, 0.2, 1),
      transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
    .handle-tooltip {
      position: relative;
      display: inline-block;
      padding: 6px 8px;
      font-size: 14px;
      color: #fff;
      line-height: 20px;
      text-align: center;
      min-width: 32px;
      border-radius: 6px;
      transform: translate(-50%, -50%) scale(0.8);
      top: -32px;
      left: 50%;
      background: rgba(0, 0, 0, 0.85);
      box-shadow:
        0 6px 16px 0 rgba(0, 0, 0, 0.08),
        0 3px 6px -4px rgba(0, 0, 0, 0.12),
        0 9px 28px 8px rgba(0, 0, 0, 0.05);
      pointer-events: none;
      user-select: none;
      outline: none;
      opacity: 0;
      transition:
        transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
        opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
      .tooltip-arrow {
        position: absolute;
        z-index: 9;
        left: 50%;
        bottom: 0;
        transform: translateX(-50%) translateY(100%) rotate(180deg);
        display: block;
        pointer-events: none;
        width: 16px;
        height: 16px;
        overflow: hidden;
        &::before {
          position: absolute;
          bottom: 0;
          inset-inline-start: 0;
          width: 16px;
          height: 8px;
          background-color: rgba(0, 0, 0, 0.85);
          clip-path: path(
            'M 0 8 A 4 4 0 0 0 2.82842712474619 6.82842712474619 L 6.585786437626905 3.0710678118654755 A 2 2 0 0 1 9.414213562373096 3.0710678118654755 L 13.17157287525381 6.82842712474619 A 4 4 0 0 0 16 8 Z'
          );
          content: '';
        }
        &::after {
          position: absolute;
          width: 8.970562748477143px;
          height: 8.970562748477143px;
          bottom: 0;
          inset-inline: 0;
          margin: auto;
          border-radius: 0 0 2px 0;
          transform: translateY(50%) rotate(-135deg);
          box-shadow: 3px 3px 7px rgba(0, 0, 0, 0.1);
          z-index: 0;
          background: transparent;
          content: '';
        }
      }
    }
    .hover-focus-handle {
      width: 20px;
      height: 20px;
      border-width: 4px;
      border-color: @themeColor;
    }
    &:hover,
    &:focus {
      .hover-focus-handle();
    }
    .show-handle-tooltip {
      pointer-events: auto;
      transform: translate(-50%, -50%) scale(1);
      opacity: 1;
    }
    &:hover {
      .handle-tooltip {
        .show-handle-tooltip();
      }
    }
  }
  .handle-transition {
    transition: left 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  }
}
.slider-disabled {
  .slider-rail {
    cursor: not-allowed;
    background: rgba(0, 0, 0, 0.06);
  }
  .slider-track {
    background: rgba(0, 0, 0, 0.25);
  }
  .slider-handle {
    border-color: rgba(0, 0, 0, 0.25);
    cursor: not-allowed;
    &:hover {
      width: 14px;
      height: 14px;
      border-width: 2px;
      border-color: rgba(0, 0, 0, 0.25);
    }
    &:focus {
      width: 14px;
      height: 14px;
      border-width: 2px;
      border-color: rgba(0, 0, 0, 0.25);
    }
  }
  &:hover {
    .slider-rail {
      background: rgba(0, 0, 0, 0.06);
    }
    .slider-track {
      background: rgba(0, 0, 0, 0.25);
    }
  }
}
</style>

②在要使用的页面引入:

<script setup lang="ts">
import Slider from './Slider.vue'
import { ref, watchEffect } from 'vue'

const singleValue = ref(20)
const singleCustomValue = ref(0)
const doubleValue = ref([20, 80])
const doubleCustomValue = ref([-5, 5])
const singleCustomStepValue = ref(30)
const doubleCustomStepValue = ref([30, 60])
watchEffect(() => {
  console.log('singleValue:', singleValue.value)
})
watchEffect(() => {
  console.log('doubleValue:', doubleValue.value)
})
watchEffect(() => {
  console.log('singleCustomValue:', singleCustomValue.value)
})
watchEffect(() => {
  console.log('doubleCustomValue:', doubleCustomValue.value)
})
watchEffect(() => {
  console.log('singleCustomStepValue:', singleCustomStepValue.value)
})
watchEffect(() => {
  console.log('doubleCustomStepValue:', doubleCustomStepValue.value)
})
function onChange(value: number | number[]) {
  console.log('change:', value)
}
function formatter(value: number) {
  return `${value}%`
}
</script>
<template>
  <div>
    <h1>{
  
  { $route.name }} {
  
  { $route.meta.title }}</h1>
    <h2 class="mt30 mb10">基本使用</h2>
    <Slider width="80%" v-model:value="singleValue" @change="onChange" />
    <h2 class="mt30 mb10">禁用</h2>
    <Flex vertical gap="large" width="80%">
      <Slider v-model:value="singleValue" disabled />
      <Slider v-model:value="doubleValue" range disabled />
    </Flex>
    <h2 class="mt30 mb10">双滑块</h2>
    <Slider width="80%" range v-model:value="doubleValue" @change="onChange" />
    <h2 class="mt30 mb10">自定义最大最小值</h2>
    <Flex vertical gap="large" width="80%">
      <Slider :min="-10" :max="10" v-model:value="singleCustomValue" />
      <Slider :min="-10" :max="10" range v-model:value="doubleCustomValue" />
    </Flex>
    <h2 class="mt30 mb10">自定义步长</h2>
    <Flex vertical gap="large" width="80%">
      <Slider :step="5" v-model:value="singleCustomStepValue" />
      <Slider range :step="5" v-model:value="doubleCustomStepValue" />
    </Flex>
    <h2 class="mt30 mb10">隐藏 tooltip</h2>
    <Flex vertical gap="large" width="80%">
      <Slider :tooltip="false" v-model:value="singleValue" />
      <Slider range :tooltip="false" v-model:value="doubleValue" />
    </Flex>
    <h2 class="mt30 mb10">格式化 tooltip</h2>
    <Flex vertical gap="large" width="80%">
      <Slider :format-tooltip="formatter" v-model:value="singleValue" />
      <Slider range :format-tooltip="formatter" v-model:value="doubleValue" />
    </Flex>
  </div>
</template>
相关文章
|
17天前
vue3学习(3)
vue3学习(3)
|
14天前
|
JavaScript API
Vue3中的计算属性能否动态修改
【9月更文挑战第5天】Vue3中的计算属性能否动态修改
49 10
|
7天前
|
JavaScript
Vue3中路由跳转的语法
Vue3中路由跳转的语法
111 58
|
9天前
|
前端开发
vue3+ts项目中使用mockjs
vue3+ts项目中使用mockjs
209 58
|
5天前
|
JavaScript 索引
Vue 2和Vue 3的区别以及实现原理
Vue 2 的响应式系统通过Object.defineProperty来实现,它为对象的每个属性添加 getter 和 setter,以便追踪依赖并响应数据变化。
20 9
|
7天前
|
JavaScript 开发工具
vite如何打包vue3插件为JSSDK
【9月更文挑战第10天】以下是使用 Vite 打包 Vue 3 插件为 JS SDK 的步骤:首先通过 `npm init vite-plugin-sdk --template vue` 创建 Vue 3 项目并进入项目目录 `cd vite-plugin-sdk`。接着,在 `src` 目录下创建插件文件(如 `myPlugin.js`),并在 `main.js` 中引入和使用该插件。然后,修改 `vite.config.js` 文件以配置打包选项。最后,运行 `npm run build` 进行打包,生成的 `my-plugin-sdk.js` 即为 JS SDK,可在其他项目中引入使用。
|
7天前
|
JavaScript 开发者
彻底搞懂 Vue3 中 watch 和 watchEffect是用法
彻底搞懂 Vue3 中 watch 和 watchEffect是用法
|
14天前
|
JavaScript API
如何使用Vue3的可计算属性
【9月更文挑战第5天】如何使用Vue3的可计算属性
44 13
|
5天前
|
JavaScript 调度
Vue3 使用 Event Bus
Vue3 使用 Event Bus
10 1
|
5天前
|
JavaScript
Vue3 : ref 与 reactive
Vue3 : ref 与 reactive
9 1