l-slider.uvue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. <template>
  2. <view class="l-slider" :class="classes" ref="sliderRef" @touchstart="touchstart" @touchmove="touchmove"
  3. @touchend="touchend">
  4. <template v-if="isApp && ($slots['start-thumb'] != null || $slots['end-thumb'] != null) || !isApp">
  5. <view ref="railRef" class="l-slider__rail" :class="railClass" :style="[railStyle]">
  6. <view class="l-slider__track" :class="trackClass" :style="[trackStyle]" ref="progressBarRef"></view>
  7. </view>
  8. <view class="l-slider__thumb-wrapper" :class="thumbClass" ref="startThumbRef" data-thumb="0">
  9. <slot name="start-thumb">
  10. <view class="l-slider__thumb" :style="[thumbStyle]"></view>
  11. </slot>
  12. </view>
  13. <view class="l-slider__thumb-wrapper" :class="thumbClass" ref="endThumbRef" data-thumb="1" v-if="range">
  14. <slot name="end-thumb">
  15. <view class="l-slider__thumb" :style="[thumbStyle]"></view>
  16. </slot>
  17. </view>
  18. </template>
  19. </view>
  20. </template>
  21. <script lang="uts" setup>
  22. import { SliderProps, DragStatus } from './type';
  23. import { isNumber } from '@/uni_modules/lime-shared/isNumber'
  24. import { closest } from '@/uni_modules/lime-shared/closest'
  25. import { unitConvert } from '@/uni_modules/lime-shared/unitConvert'
  26. import { format } from './utils'
  27. import { useTouch } from './touch'
  28. const emit = defineEmits(['change', 'update:modelValue', 'change-position'])
  29. const props = withDefaults(defineProps<SliderProps>(), {
  30. max: 100,
  31. min: 0,
  32. step: 1,
  33. range: false,
  34. vertical: false,
  35. disabled: false
  36. })
  37. let dragStatus = ref<DragStatus>('')
  38. let startValue = 0;
  39. // #ifdef APP
  40. const isApp = true;
  41. // #endif
  42. // #ifndef APP
  43. const isApp = false;
  44. // #endif
  45. const tempValue = ref<number[]>([0, 0]);
  46. const innerValue = computed({
  47. set(value : any) {
  48. emit('change', value);
  49. emit('update:modelValue', value);
  50. },
  51. get() : any {
  52. const value = props.value ?? props.modelValue ?? props.min;
  53. if (isNumber(value) && !props.range) {
  54. return value as number
  55. }
  56. if (Array.isArray(value) && (value as any[]).every((v) : boolean => isNumber(v)) && props.range) {
  57. return value as number[]
  58. }
  59. throw new Error(`l-slider value 必须是 number | number[],当前值为: ${JSON.stringify(value)}`);
  60. return value
  61. }
  62. } as WritableComputedOptions<any>)
  63. const isRange = computed(() : boolean => props.range && Array.isArray(innerValue.value) && (innerValue.value as any[]).length == 2);
  64. const direction = computed(() : Map<string, string> => {
  65. const map = new Map<string, string>();
  66. map.set('left', props.vertical ? 'top' : 'left');
  67. map.set('right', props.vertical ? 'bottom' : 'right');
  68. map.set('width', props.vertical ? 'height' : 'width');
  69. map.set('min-height', props.vertical ? 'min-width' : 'min-height');
  70. map.set('vertical', props.vertical ? 'vertical' : 'horizontal');
  71. // map.set('margin-top', props.vertical ? 'margin-left' : 'margin-top');
  72. // map.set('margin-bottom', props.vertical ? 'margin-right' : 'margin-bottom');
  73. return map
  74. })
  75. const getDirection = (key : string) : string => {
  76. return direction.value.get(key) ?? key
  77. }
  78. const classes = computed(() : Map<string, any> => {
  79. const cls = new Map<string, any>();
  80. cls.set(`l-slider--${getDirection('vertical')}`, true)
  81. cls.set(`l-slider--disabled`, props.disabled)
  82. return cls
  83. })
  84. const railClass = computed(() : Map<string, any> => {
  85. const cls = new Map<string, any>();
  86. cls.set(`l-slider__rail--${getDirection('vertical')}`, true)
  87. return cls
  88. })
  89. const trackClass = computed(() : Map<string, any> => {
  90. const cls = new Map<string, any>();
  91. cls.set(`l-slider__track--${getDirection('vertical')}`, true)
  92. return cls
  93. })
  94. const thumbClass = computed(() : Map<string, any> => {
  95. const cls = new Map<string, any>();
  96. cls.set(`l-slider__thumb-wrapper--${getDirection('vertical')}`, true)
  97. return cls
  98. })
  99. const railStyle = computed(() : Map<string, any> => {
  100. const style = new Map<string, any>();
  101. if (props.railColor != null) {
  102. style.set('background', props.railColor!)
  103. }
  104. if (props.railSize != null) {
  105. style.set(props.vertical ? 'width': 'height' , props.railSize!)
  106. }
  107. return style
  108. })
  109. const thumbStyle = computed(() : Map<string, any> => {
  110. const style = new Map<string, any>();
  111. if (props.thumbColor != null) {
  112. style.set('background', props.thumbColor!)
  113. }
  114. if(props.thumbBorderColor != null) {
  115. style.set('border-color', props.thumbBorderColor!)
  116. }
  117. if (props.thumbSize != null) {
  118. style.set('width', props.thumbSize!)
  119. style.set('height', props.thumbSize!)
  120. }
  121. return style
  122. })
  123. const trackStyle = computed(() : Map<string, any> => {
  124. const style = new Map<string, any>();
  125. if (props.trackColor != null) {
  126. style.set('background', props.trackColor!)
  127. }
  128. return style
  129. })
  130. const scope = computed(() : number => props.max - props.min);
  131. const sliderRef = ref<UniElement | null>(null)
  132. const railRef = ref<UniElement | null>(null)
  133. const progressBarRef = ref<UniElement | null>(null)
  134. const startThumbRef = ref<UniElement | null>(null)
  135. const endThumbRef = ref<UniElement | null>(null)
  136. // 计算选中条的长度占轨道总长度的比例
  137. const calculateSelectedBarRatio = () : number => {
  138. const { min } = props;
  139. if (isRange.value) {
  140. // 对于范围选择,计算两个值之间的差值相对于总范围的比值
  141. // const _innerValue = innerValue.value as number[]
  142. // return Math.abs(_innerValue[1] - _innerValue[0]) / scope.value;
  143. return Math.abs(tempValue.value[1] - tempValue.value[0]) / scope.value;
  144. }
  145. // 对于单个值选择,计算该值相对于总范围的比值
  146. // return (innerValue.value as number - min) / scope.value;
  147. return (tempValue.value[0] - min) / scope.value;
  148. };
  149. // 计算选中条起始位置的偏移量占轨道总长度的比例
  150. const calculateStartOffsetRatio = () : number => {
  151. const { min } = props;
  152. if (isRange.value) {
  153. // 对于范围选择,计算起始值相对于总范围的偏移量比值
  154. // const _innerValue = innerValue.value as number[]
  155. const _startValue = Math.min(tempValue.value[0], tempValue.value[1]);
  156. return (_startValue - min) / scope.value;
  157. }
  158. // 对于单个值选择,偏移量为0
  159. return 0;
  160. };
  161. // 计算轨道的最大尺寸(宽或高)
  162. const calculateMaxTrackDimension = () : number[] => {
  163. if (railRef.value == null || startThumbRef.value == null) return [0, 0];
  164. const railBounds = railRef.value!.getBoundingClientRect();
  165. const thumbBounds = startThumbRef.value!.getBoundingClientRect();
  166. const railDimension = props.vertical ? railBounds.width : railBounds.height;
  167. const thumbDimension = props.vertical ? thumbBounds.width : thumbBounds.height;
  168. // return Math.max(railDimension, thumbDimension);
  169. return [Math.max(railDimension, thumbBounds.width, thumbBounds.height), Math.max(railDimension, thumbDimension)];
  170. };
  171. // 计算轨道的总长度
  172. const calculateTrackLength = () : number => {
  173. return (props.vertical ? railRef.value?.offsetHeight : railRef.value?.offsetWidth) ?? (props.vertical ? sliderRef.value?.offsetHeight : sliderRef.value?.offsetWidth) ?? 0;
  174. };
  175. // 更新进度条和滑块的位置
  176. const updateProgressBarAndThumb = () => {
  177. if(sliderRef.value == null) return;
  178. let trackLength = calculateTrackLength();
  179. const selectedBarRatio = calculateSelectedBarRatio();
  180. const startOffsetRatio = calculateStartOffsetRatio();
  181. const [start, end] = tempValue.value;
  182. // #ifdef APP
  183. if (progressBarRef.value == null || startThumbRef.value == null) {
  184. const ctx = sliderRef.value!.getDrawableContext()!
  185. const thumbSize = unitConvert(props.thumbSize ?? 12)
  186. const thumbColor = props.thumbColor ?? 'white'
  187. const railColor = props.railColor ?? 'rgba(0,0,0,0.06)'
  188. const railSize = unitConvert(props.railSize ?? 20)
  189. const trackColor = props.trackColor ?? '#3283ff'
  190. const thumbDimension = Math.max(railSize, thumbSize)
  191. const thumbHalf = thumbDimension / 2;
  192. let offset = 0
  193. sliderRef.value?.style.setProperty(getDirection('min-height'), `${thumbDimension}px`)
  194. if(thumbSize > railSize) {
  195. sliderRef.value?.style.setProperty(props.vertical ? 'margin-top' : 'margin-left', `-1px`)
  196. sliderRef.value?.style.setProperty(props.vertical ? 'margin-bottom' : 'margin-right', `-1px`)
  197. offset = 1
  198. trackLength -= 2
  199. }
  200. const usableTrackLength = trackLength - thumbDimension
  201. const railHalf = railSize / 2
  202. const x1 = props.vertical ? thumbHalf : (railHalf + offset)
  203. const y1 = props.vertical ? (railHalf + offset) : thumbHalf;
  204. const x2 = props.vertical ? thumbHalf : trackLength - railHalf - offset;
  205. const y2 = props.vertical ? trackLength - railHalf - offset : thumbHalf
  206. const s1 = startOffsetRatio * (trackLength - railSize)
  207. const w1 = selectedBarRatio * (trackLength - railSize)
  208. const x3 = props.vertical ? thumbHalf : (railHalf + s1 + offset);
  209. const y3 = props.vertical ? (railHalf + s1 + offset) : thumbHalf;
  210. const x4 = props.vertical ? thumbHalf : (x3 + w1 - offset)
  211. const y4 = props.vertical ? (y3 + w1 - offset) : thumbHalf;
  212. ctx.reset();
  213. ctx.lineWidth = railSize;
  214. ctx.strokeStyle = railColor;
  215. ctx.lineCap = 'round';
  216. // Draw rail
  217. ctx.beginPath();
  218. ctx.moveTo(x1, y1);
  219. ctx.lineTo(x2, y2);
  220. ctx.stroke();
  221. // Draw track 当不在起点时绘制
  222. if(w1 != 0 || start != props.min) {
  223. ctx.strokeStyle = trackColor;
  224. ctx.beginPath();
  225. ctx.moveTo(x3, y3);
  226. ctx.lineTo(x4, y4);
  227. ctx.stroke();
  228. }
  229. // Draw thumbs
  230. const drawThumb = (position: number) => {
  231. const thumbPosition = props.vertical ? thumbHalf : position;
  232. const oppositeThumbPosition = props.vertical ? position : thumbHalf;
  233. ctx.fillStyle = thumbColor;
  234. ctx.strokeStyle = props.thumbBorderColor ?? 'rgba(0,0,0,0.2)'
  235. ctx.lineWidth = 1;
  236. ctx.beginPath();
  237. ctx.arc(thumbPosition, oppositeThumbPosition, (thumbSize - 2) / 2, 0, Math.PI * 2);
  238. ctx.stroke();
  239. ctx.fill();
  240. };
  241. const startThumbPosition = (start - props.min) / scope.value * usableTrackLength + thumbHalf;
  242. drawThumb(startThumbPosition + offset);
  243. if (isRange.value) {
  244. const endThumbPosition = (end - props.min) / scope.value * usableTrackLength + thumbHalf;
  245. drawThumb(endThumbPosition + offset);
  246. }
  247. ctx.update();
  248. return;
  249. }
  250. // #endif
  251. if (progressBarRef.value == null || startThumbRef.value == null) return;
  252. const [maxDimension, thumbDimension] = calculateMaxTrackDimension();
  253. const thumbHalf = maxDimension / 2;
  254. const usableTrackLength = trackLength - maxDimension;
  255. // 更新进度条位置和宽度
  256. progressBarRef.value?.style.setProperty(getDirection('left'), `${startOffsetRatio * usableTrackLength}px`);
  257. progressBarRef.value?.style.setProperty(getDirection('width'), `${(selectedBarRatio * usableTrackLength + maxDimension)}px`);
  258. progressBarRef.value?.style.setProperty('opacity', selectedBarRatio == 0 && start == props.min ? 0 : 1);
  259. sliderRef.value!.style.setProperty(getDirection('min-height'), `${thumbDimension}px`)
  260. // 更新滑块位置
  261. const startThumbPosition = (start - props.min) / scope.value * usableTrackLength + thumbHalf;
  262. startThumbRef.value?.style.setProperty(getDirection('left'), `${startThumbPosition}px`);
  263. const position : number[] = [startOffsetRatio * usableTrackLength, 0]
  264. // emit('change-position', position)
  265. if (!isRange.value || endThumbRef.value == null) return;
  266. const endThumbPosition = (end - props.min) / scope.value * usableTrackLength + thumbHalf;
  267. endThumbRef.value!.style.setProperty(getDirection('left'), `${endThumbPosition}px`);
  268. };
  269. const touch = useTouch()
  270. const getDelta = (startX : number, startY : number) : number => {
  271. if (sliderRef.value == null) return 0;
  272. const rect = sliderRef.value!.getBoundingClientRect()
  273. return props.vertical ? startY - rect.top : startX - rect.left;
  274. }
  275. const currentKey = ref(0)
  276. const touchstart = (event : UniTouchEvent) => {
  277. if (props.disabled || dragStatus.value != '') return;
  278. event.preventDefault()
  279. dragStatus.value = 'start'
  280. touch.start(event);
  281. const { startX, startY } = touch;
  282. const delta = getDelta(startX.value, startY.value)
  283. const total = calculateTrackLength()
  284. let value = format(delta / total * scope.value + props.min, props.min, props.max, props.step)
  285. if (isRange.value) {
  286. currentKey.value = tempValue.value.findIndex((it) : boolean => it == closest(tempValue.value, value))
  287. tempValue.value[currentKey.value] = value
  288. innerValue.value = tempValue.value
  289. } else {
  290. currentKey.value = 0;
  291. innerValue.value = value
  292. }
  293. startValue = value;
  294. }
  295. const touchmove = (event : UniTouchEvent) => {
  296. if (props.disabled) return;
  297. event.preventDefault()
  298. dragStatus.value = 'dragging'
  299. touch.move(event);
  300. const key = currentKey.value
  301. const delta = props.vertical ? touch.deltaY : touch.deltaX;
  302. const total = calculateTrackLength()
  303. const diff = delta.value / total * scope.value;
  304. const value = format(startValue + diff, props.min, props.max, props.step)
  305. tempValue.value[key] = value
  306. if (isRange.value) {
  307. innerValue.value = tempValue.value
  308. } else {
  309. innerValue.value = value
  310. }
  311. }
  312. const touchend = (event : UniTouchEvent) => {
  313. if (props.disabled) return;
  314. dragStatus.value = ''
  315. if (!isRange.value) {
  316. currentKey.value = 0
  317. } else {
  318. currentKey.value = -1
  319. }
  320. }
  321. const stopWatch = watch(innerValue, (v : any) => {
  322. // if(dragStatus.value == '') {
  323. if (isRange.value) {
  324. tempValue.value = (innerValue.value as number[]).map((value) : number => format(value, props.min, props.max, props.step))
  325. } else {
  326. tempValue.value[0] = format(innerValue.value as number, props.min, props.max, props.step)
  327. }
  328. // }
  329. updateProgressBarAndThumb()
  330. }, { immediate: true, deep: true })
  331. const resizeObserver = new UniResizeObserver((entries : Array<UniResizeObserverEntry>) => {
  332. updateProgressBarAndThumb()
  333. })
  334. const stopWrapWatch = watch(() : UniElement | null => sliderRef.value, (el : UniElement | null) => {
  335. if (el == null) return
  336. resizeObserver.observe(el)
  337. })
  338. onUnmounted(() => {
  339. stopWatch()
  340. stopWrapWatch()
  341. resizeObserver.disconnect()
  342. })
  343. </script>
  344. <style lang="scss">
  345. @import './index-u';
  346. </style>