Vue3滑动输入条(Slider)

67 篇文章 3 订阅
65 篇文章 2 订阅

Vue2滑动输入条(Slider)

可自定义设置以下属性:

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

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

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

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

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

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

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

  • 是否隐藏 Tooltip(hideTip),类型:boolean,默认 false

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

主要具有以下功能:

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

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

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

效果如下图:在线预览

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

使用requestAnimationFrame模拟实现setTimeout和setInterval_theMuseCatcher的博客-CSDN博客使用requestAnimationFrame模拟实现setTimeout和setInterval!https://blog.csdn.net/Dandrose/article/details/130167061

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

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { rafTimeout, cancelRaf } from '../index'
interface Props {
  width?: string|number // 宽度
  min?: number // 最小值
  max?: number // 最大值
  disabled?: boolean // 是否禁用
  range?: boolean // 是否双滑块模式
  step?: number // 步长,取值必须大于0,并且可被 (max - min) 整除
  tipFormatter?: Function // Slider 会把当前值传给 tipFormatter,并在 Tooltip 中显示 tipFormatter 的返回值
  hideTip?: 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,
  tipFormatter: (value: number) => value,
  hideTip: false,
  value: 0
})
const transition = ref(false)
const timer = ref()
const left = ref(0) // 左滑块距离滑动条左端的距离
const right = ref(0) // 右滑动距离滑动条左端的距离
const slider = ref()
const sliderWidth = ref()
const leftHandle = ref() // left模板引用
const rightHandle = ref() // right模板引用
const pixelStep = computed(() => { // 滑块移动时的像素步长
  return fixedDigit(sliderWidth.value / (props.max - props.min) * props.step)
})
const totalWidth = computed(() => {
  if (typeof props.width === 'number') {
    return props.width + 'px'
  } else {
    return props.width
  }
})
const sliderValue = computed(() => {
  const high = Math.round(right.value / pixelStep.value * props.step + props.min)
  if (props.range) {
    const low = Math.round(left.value / pixelStep.value * props.step + props.min)
    return [low, high]
  }
  return high
})
const leftValue = computed(() => {
  if (props.range) {
    return props.tipFormatter((sliderValue.value as number[])[0])
  }
  return null
})
const rightValue = computed(() => {
  if (props.range) {
    return props.tipFormatter((sliderValue.value as number[])[1])
  }
  return props.tipFormatter(sliderValue.value)
})
const emits = defineEmits(['update:value', 'change'])
watch(
  () => props.value,
  () => { getPosition() }
)
watch(sliderValue, (to) => {
  emits('update:value', to)
  emits('change', to)
})
onMounted(() => {
  getSliderWidth()
  getPosition()
})
function fixedDigit (num: number) {
  return parseFloat(num.toFixed(2))
}
function getSliderWidth () {
  sliderWidth.value = slider.value.offsetWidth
}
function getPosition () {
  if (props.range) { // 双滑块模式
    left.value = fixedDigit(((props.value as number[])[0] - props.min) / props.step * pixelStep.value)
    right.value = fixedDigit(((props.value as number[])[1] - props.min) / props.step * pixelStep.value)
  } else {
    right.value = fixedDigit((props.value as number - props.min) / props.step * pixelStep.value)
  }
}
function onClickPoint (e: any) { // 点击滑动条,移动滑块
  if (transition.value) {
    cancelRaf(timer.value)
    timer.value = null
  } else {
    transition.value = true
  }
  timer.value = rafTimeout(() => {
    transition.value = false
  }, 300)
  // 元素是absolute时,e.layerX是相对于自身元素左上角的水平位置
  const targetX = Math.round(e.layerX / pixelStep.value) * pixelStep.value // 鼠标点击位置距离滑动输入条左端的水平距离
  if (props.range) { // 双滑块模式
    if (targetX <= left.value) {
      left.value = targetX
      leftHandle.value.focus()
    } else if (targetX >= right.value) {
      right.value = targetX
      rightHandle.value.focus()
    } else {
      if ((targetX - left.value) < (right.value - targetX)) {
        left.value = targetX
        leftHandle.value.focus()
      } else {
        right.value = targetX
        rightHandle.value.focus()
      }
    }
  } else { // 单滑块模式
    right.value = targetX
    rightHandle.value.focus()
  }
}
function onLeftMouseDown () { // 在滚动条上拖动左滑块
  const leftX = slider.value.getBoundingClientRect().left // 滑动条左端距离屏幕可视区域左边界的距离
  document.onmousemove = (e: MouseEvent) => {
    // e.clientX返回事件被触发时鼠标指针相对于浏览器可视窗口的水平坐标
    const targetX = fixedDigit(Math.round((e.clientX - leftX) / pixelStep.value) * pixelStep.value)
    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()
    }
  }
  document.onmouseup = () => {
    document.onmousemove = null
  }
}
function onRightMouseDown () { // 在滚动条上拖动右滑块
  const leftX = slider.value.getBoundingClientRect().left // 滑动条左端距离屏幕可视区域左边界的距离
  document.onmousemove = (e: MouseEvent) => {
    // e.clientX返回事件被触发时鼠标指针相对于浏览器可视窗口的水平坐标
    const targetX = fixedDigit(Math.round((e.clientX - leftX) / pixelStep.value) * pixelStep.value)
    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
      leftHandle.value.focus()
      onLeftMouseDown()
    }
  }
  document.onmouseup = () => {
    document.onmousemove = null
  }
}
function onLeftSlide (source: number, place: string) {
  const targetX = source - pixelStep.value
  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 = source + pixelStep.value
  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()
    }
  }
}
</script>
<template>
  <div :class="['m-slider', { disabled: disabled }]" ref="slider" :style="`width: ${totalWidth};`">
    <div class="u-slider-rail" @click.self="disabled ? () => false : onClickPoint($event)"></div>
    <div class="u-slider-track" :class="{trackTransition: transition}" :style="`left: ${left}px; right: auto; width: ${right - left}px;`"></div>
    <div
      v-if="range"
      tabindex="0"
      ref="leftHandle"
      class="u-slider-handle"
      :class="{handleTransition: 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()">
      <div v-if="!hideTip" class="m-handle-tooltip">
        {{ leftValue }}
        <div class="m-arrow"></div>
      </div>
    </div>
    <div
      tabindex="0"
      ref="rightHandle"
      class="u-slider-handle"
      :class="{handleTransition: 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()">
      <div v-if="!hideTip" class="m-handle-tooltip">
        {{ rightValue }}
        <div class="m-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
  .u-slider-rail { // 灰色未选择滑动条背景色
    position: absolute;
    z-index: 99;
    height: 4px;
    width: 100%;
    background-color: #f5f5f5;
    border-radius: 2px;
    cursor: pointer;
    transition: background-color .3s;
  }
  .u-slider-track { // 蓝色已选择滑动条背景色
    position: absolute;
    z-index: 99;
    background: lighten(fade(@themeColor, 54%), 10%);
    border-radius: 4px;
    height: 4px;
    cursor: pointer;
    transition: background .3s;
    pointer-events: none;
  }
  .trackTransition {
    transition: left .2s, width .2s, background .3s;
  }
  &:hover {
    .u-slider-rail { // 灰色未选择滑动条背景色
      background: #E3E3E3;
    }
    .u-slider-track { // 蓝色已选择滑动条背景色
      background: @themeColor;
    }
  }
  .u-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;
    transition: width .3s, height .3s, border-color .3s, border-width .3s, transform .3s cubic-bezier(0.18, 0.89, 0.32, 1.28);
    .m-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(.8);
      top: -32px;
      left: 50%;
      background: rgba(0, 0, 0, .85);
      box-shadow: 0 6px 16px 0 rgba(0, 0, 0, .08), 0 3px 6px -4px rgba(0, 0, 0, .12), 0 9px 28px 8px rgba(0, 0, 0, .05);
      pointer-events: none;
      user-select: none;
      opacity: 0;
      transition: transform .25s, opacity .25s;
      .m-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, .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, .1);
          z-index: 0;
          background: transparent;
          content: "";
        }
      }
    }
    .hover-focus-handle {
      width: 20px;
      height: 20px;
      border-width: 4px;
      border-color: @themeColor;
      outline: none; // 消除浏览器focus时的默认样式
    }
    &:hover {
      .hover-focus-handle();
      .m-handle-tooltip {
        pointer-events: auto;
        opacity: 1;
        transform: translate(-50%, -50%) scale(1);
      }
    }
    &:focus {
      .hover-focus-handle();
    }
  }
  .handleTransition {
    transition: left .2s;
  }
}
.disabled {
  .u-slider-rail {
    cursor: not-allowed;
    background: rgba(0, 0, 0, .06);
  }
  .u-slider-track {
    background: rgba(0, 0, 0, .25);
  }
  .u-slider-handle {
    border-color: rgba(0, 0, 0, .25);
    cursor: not-allowed;
    &:hover {
      width: 14px;
      height: 14px;
      border-width: 2px;
      border-color: rgba(0, 0, 0, .25);
    }
    &:focus {
      width: 14px;
      height: 14px;
      border-width: 2px;
      border-color: rgba(0, 0, 0, .25);
    }
  }
  &:hover {
    .u-slider-rail {
      background: rgba(0, 0, 0, .06);
    }
    .u-slider-track {
      background: rgba(0, 0, 0, .25);
    }
  }
}
</style>

②在要使用的页面引入:

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

const singleValue = ref(20)
const doubleValue = ref([20, 80])
watchEffect(() => {
  console.log('singleValue:', singleValue.value)
})
watchEffect(() => {
  console.log('doubleValue:', doubleValue.value)
})
function onChange (value: number|number[]) {
  console.log('change:', value)
}
function formatter (value: number) {
  return `${value}%`
}
</script>
<template>
  <div>
    <h2 class="mb10">Slider 单滑块滑动输入条基本使用 ({{  singleValue }})</h2>
    <Slider
      width="80%"
      v-model:value="singleValue"
      @change="onChange" />
    <h2 class="mt30 mb10">双滑块滑动输入条基本使用 (range & {{ doubleValue }})</h2>
    <Slider
      width="80%"
      range
      v-model:value="doubleValue" />
    <h2 class="mt30 mb10">格式化 Tooltip 的内容</h2>
    <Slider
      width="80%"
      range
      :tip-formatter="formatter"
      v-model:value="doubleValue" />
    <h2 class="mt30 mb10">禁用双滑块滑动输入条 (range & disabled & {{ doubleValue }})</h2>
    <Slider
      width="80%"
      disabled
      range
      v-model:value="doubleValue" />
    <h2 class="mt30 mb10">自定义步长 (range & step: 5)</h2>
    <Slider range :step="5" width="80%" v-model:value="doubleValue" />
  </div>
</template>
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值