wd-slider.vue 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. <template>
  2. <view :class="rootClass" :style="customStyle">
  3. <!-- #ifdef MP-DINGTALK -->
  4. <view style="flex: 1" :class="rootClass">
  5. <!-- #endif -->
  6. <view :class="`wd-slider__label-min ${customMinClass}`" v-if="!hideMinMax">{{ minProp }}</view>
  7. <view class="wd-slider__bar-wrapper" :style="wrapperStyle" :id="sliderBarWrapperId">
  8. <view class="wd-slider__bar" :style="barStyle">
  9. <template v-if="isRange">
  10. <!-- 左边滑块 -->
  11. <view
  12. class="wd-slider__button-wrapper wd-slider__button-wrapper--left"
  13. :style="sliderButtonStyle"
  14. @touchstart="(event) => onSliderTouchStart(event, 0)"
  15. @touchmove="onSliderTouchMove"
  16. @touchend="onSliderTouchEnd"
  17. @touchcancel="onSliderTouchEnd"
  18. >
  19. <view class="wd-slider__label" v-if="!hideLabel">{{ firstValue }}</view>
  20. <view class="wd-slider__button" />
  21. </view>
  22. <!-- 右边滑块 -->
  23. <view
  24. class="wd-slider__button-wrapper wd-slider__button-wrapper--right"
  25. :style="sliderButtonStyle"
  26. @touchstart="(event) => onSliderTouchStart(event, 1)"
  27. @touchmove="onSliderTouchMove"
  28. @touchend="onSliderTouchEnd"
  29. @touchcancel="onSliderTouchEnd"
  30. >
  31. <view class="wd-slider__label" v-if="!hideLabel">{{ secondValue }}</view>
  32. <view class="wd-slider__button" />
  33. </view>
  34. </template>
  35. <view
  36. v-else
  37. class="wd-slider__button-wrapper"
  38. :style="sliderButtonStyle"
  39. @touchstart="(event) => onSliderTouchStart(event, 0)"
  40. @touchmove="onSliderTouchMove"
  41. @touchend="onSliderTouchEnd"
  42. @touchcancel="onSliderTouchEnd"
  43. >
  44. <view class="wd-slider__label" v-if="!hideLabel">{{ firstValue }}</view>
  45. <view class="wd-slider__button" />
  46. </view>
  47. </view>
  48. </view>
  49. <view :class="`wd-slider__label-max ${customMaxClass}`" v-if="!hideMinMax">
  50. {{ maxProp }}
  51. </view>
  52. <!-- #ifdef MP-DINGTALK -->
  53. </view>
  54. <!-- #endif -->
  55. </view>
  56. </template>
  57. <script lang="ts">
  58. export default {
  59. name: 'wd-slider',
  60. options: {
  61. addGlobalClass: true,
  62. virtualHost: true,
  63. styleIsolation: 'shared'
  64. }
  65. }
  66. </script>
  67. <script lang="ts" setup>
  68. import { computed, type CSSProperties, getCurrentInstance, onMounted, ref, watch } from 'vue'
  69. import { deepClone, getRect, isArray, isDef, isEqual, objToStyle, uuid } from '../common/util'
  70. import { useTouch } from '../composables/useTouch'
  71. import { sliderProps, type SliderExpose, type SliderEmits, type SliderDragEvent, type SliderValue } from './types'
  72. const props = defineProps(sliderProps)
  73. const emit = defineEmits<SliderEmits>()
  74. // ----------- 基础状态 -----------
  75. const sliderBarWrapperId = ref<string>(`sliderBarWrapperId${uuid()}`)
  76. const touch = useTouch()
  77. const touchIndex = ref<number>(0)
  78. const { proxy } = getCurrentInstance() as any
  79. // ----------- 轨道尺寸状态 -----------
  80. const trackWidth = ref<number>(0)
  81. const trackLeft = ref<number>(0)
  82. // ----------- 值相关状态 -----------
  83. /**
  84. * 最小值 - 直接使用props,减少状态同步
  85. */
  86. const minProp = computed(() => props.min)
  87. /**
  88. * 最大值 - 直接使用props,减少状态同步
  89. */
  90. const maxProp = computed(() => props.max)
  91. /**
  92. * 步长值 - 直接使用props,减少状态同步
  93. */
  94. const stepProp = computed(() => {
  95. if (props.step <= 0) {
  96. console.warn('[wot ui] warning(wd-slider): step must be greater than 0')
  97. return 1
  98. }
  99. return props.step
  100. })
  101. const startValue = ref<SliderValue>(0)
  102. const modelValue = ref<SliderValue>(getInitValue())
  103. // ----------- 计算属性 -----------
  104. /**
  105. * 是否为双滑块模式
  106. */
  107. const isRange = computed(() => isArray(modelValue.value))
  108. /**
  109. * 计算滑块的取值范围大小
  110. */
  111. const scope = computed(() => maxProp.value - minProp.value)
  112. /**
  113. * 获取左侧滑块的值
  114. */
  115. const firstValue = computed(() => (isRange.value ? (modelValue.value as number[])[0] : (modelValue.value as number)))
  116. /**
  117. * 获取右侧滑块的值(仅双滑块模式有效)
  118. */
  119. const secondValue = computed(() => (isRange.value ? (modelValue.value as number[])[1] : 0))
  120. /**
  121. * 根类名计算
  122. */
  123. const rootClass = computed(() => {
  124. return `wd-slider ${!props.hideLabel ? 'wd-slider__has-label' : ''} ${props.disabled ? 'wd-slider--disabled' : ''} ${props.customClass}`
  125. })
  126. /**
  127. * 滑块按钮样式
  128. */
  129. const sliderButtonStyle = computed(() => {
  130. return objToStyle({
  131. visibility: !props.disabled ? 'visible' : 'hidden'
  132. })
  133. })
  134. /**
  135. * 轨道包装器样式
  136. */
  137. const wrapperStyle = computed(() => {
  138. const style: CSSProperties = {}
  139. if (props.inactiveColor) {
  140. style.background = props.inactiveColor
  141. }
  142. return objToStyle(style)
  143. })
  144. /**
  145. * 进度条样式计算
  146. */
  147. const barStyle = computed(() => {
  148. const style: CSSProperties = {}
  149. if (scope.value === 0) return objToStyle(style)
  150. if (isRange.value) {
  151. // 双滑块模式
  152. const [left, right] = normalizeRangeValues(modelValue.value as number[])
  153. style.width = `${((right - left) * 100) / scope.value}%`
  154. style.left = `${((left - minProp.value) * 100) / scope.value}%`
  155. } else {
  156. // 单滑块模式
  157. style.width = `${(((modelValue.value as number) - minProp.value) * 100) / scope.value}%`
  158. style.left = '0'
  159. }
  160. // 添加自定义颜色
  161. if (props.activeColor) {
  162. style.background = props.activeColor
  163. }
  164. return objToStyle(style)
  165. })
  166. // ----------- 监听器 -----------
  167. /**
  168. * 监听 modelValue 属性变化
  169. */
  170. watch(
  171. () => props.modelValue,
  172. (newValue) => {
  173. if (!isEqual(newValue, modelValue.value)) {
  174. modelValue.value = getInitValue()
  175. }
  176. },
  177. { deep: true }
  178. )
  179. /**
  180. * 向上发射模型值变化
  181. */
  182. watch(modelValue, (newVal) => {
  183. emit('update:modelValue', newVal)
  184. })
  185. // ----------- 生命周期钩子 -----------
  186. onMounted(() => {
  187. initSlider()
  188. })
  189. // ----------- 工具方法 -----------
  190. /**
  191. * 检查组件是否处于禁用状态
  192. */
  193. function isDisabled(): boolean {
  194. return props.disabled
  195. }
  196. /**
  197. * 限制值在指定范围内
  198. */
  199. function clamp(value: number, min: number, max: number): number {
  200. return Math.min(Math.max(value, min), max)
  201. }
  202. // ----------- 核心方法 -----------
  203. /**
  204. * 初始化滑块宽度
  205. */
  206. function initSlider() {
  207. getRect(`#${sliderBarWrapperId.value}`, false, proxy).then((data) => {
  208. trackWidth.value = Number(data.width)
  209. trackLeft.value = Number(data.left)
  210. })
  211. }
  212. /**
  213. * 获取初始值
  214. */
  215. function getInitValue(): SliderValue {
  216. if (isArray(props.modelValue)) {
  217. return normalizeRangeValues(props.modelValue as number[])
  218. } else {
  219. return clamp(props.modelValue as number, minProp.value, maxProp.value)
  220. }
  221. }
  222. /**
  223. * 处理双滑块模式下的值,确保值有效
  224. */
  225. function normalizeRangeValues(value: number[]): [number, number] {
  226. // 检查输入是否为有效的双滑块数组
  227. if (!Array.isArray(value) || value.length < 2) {
  228. console.warn('[wot ui] warning(wd-slider): range value should be an array with at least 2 elements')
  229. return [minProp.value, maxProp.value]
  230. }
  231. const left = clamp(value[0], minProp.value, maxProp.value)
  232. const right = clamp(value[1], minProp.value, maxProp.value)
  233. return left > right ? [right, left] : [left, right]
  234. }
  235. /**
  236. * 将值对齐到最近的步长倍数
  237. */
  238. function snapValueToStep(value: number): number {
  239. // 确保值在范围内
  240. value = clamp(value, minProp.value, maxProp.value)
  241. // 计算最接近的步长倍数
  242. const steps = Math.round((value - minProp.value) / stepProp.value)
  243. // 计算最终值并处理精度问题
  244. return parseFloat((minProp.value + steps * stepProp.value).toFixed(10))
  245. }
  246. /**
  247. * 统一更新滑块值的函数
  248. */
  249. function updateValue(value: SliderValue) {
  250. let newValue: SliderValue = deepClone(value)
  251. if (isArray(value)) {
  252. newValue = normalizeRangeValues(value as number[]).map((v) => snapValueToStep(v)) as [number, number]
  253. } else {
  254. newValue = snapValueToStep(value as number)
  255. }
  256. if (!isEqual(newValue, modelValue.value)) {
  257. modelValue.value = newValue
  258. }
  259. }
  260. // ----------- 事件处理 -----------
  261. /**
  262. * 滑块触摸开始事件处理
  263. */
  264. function onSliderTouchStart(event: any, index: number) {
  265. if (isDisabled()) return
  266. touchIndex.value = index
  267. touch.touchStart(event)
  268. // 保存触摸开始时的滑块值
  269. startValue.value = deepClone(modelValue.value)
  270. // 触发拖动开始事件
  271. emit('dragstart', { value: modelValue.value })
  272. }
  273. /**
  274. * 滑块触摸移动事件处理
  275. */
  276. function onSliderTouchMove(event: any) {
  277. if (isDisabled()) return
  278. // 更新触摸状态
  279. touch.touchMove(event)
  280. // 计算滑动距离对应的值变化
  281. const diff = (touch.deltaX.value / trackWidth.value) * scope.value
  282. let newValue = deepClone(startValue.value)
  283. if (isArray(newValue)) {
  284. ;(newValue as number[])[touchIndex.value] += diff
  285. } else {
  286. newValue = (newValue as number) + diff
  287. }
  288. updateValue(newValue)
  289. // 触发拖动事件
  290. emit('dragmove', { value: modelValue.value })
  291. }
  292. /**
  293. * 滑块触摸结束事件处理
  294. */
  295. function onSliderTouchEnd() {
  296. if (isDisabled()) return
  297. emit('dragend', { value: modelValue.value })
  298. }
  299. defineExpose<SliderExpose>({
  300. initSlider
  301. })
  302. </script>
  303. <style lang="scss" scoped>
  304. @import './index.scss';
  305. </style>