cl-slider.uvue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  1. <template>
  2. <view
  3. class="cl-slider"
  4. :class="[
  5. {
  6. 'cl-slider--disabled': isDisabled
  7. },
  8. pt.className
  9. ]"
  10. >
  11. <view
  12. class="cl-slider__inner"
  13. :style="{
  14. height: blockSize + 'rpx'
  15. }"
  16. >
  17. <view
  18. class="cl-slider__track"
  19. :class="[pt.track?.className]"
  20. :style="{
  21. height: trackHeight + 'rpx'
  22. }"
  23. >
  24. <view
  25. class="cl-slider__progress"
  26. :class="[pt.progress?.className]"
  27. :style="progressStyle"
  28. ></view>
  29. <!-- 单滑块模式 -->
  30. <view
  31. v-if="!range"
  32. class="cl-slider__thumb"
  33. :class="[pt.thumb?.className]"
  34. :style="singleThumbStyle"
  35. ></view>
  36. <!-- 双滑块模式 -->
  37. <template v-if="range">
  38. <view
  39. class="cl-slider__thumb cl-slider__thumb--min"
  40. :class="[pt.thumb?.className]"
  41. :style="minThumbStyle"
  42. ></view>
  43. <view
  44. class="cl-slider__thumb cl-slider__thumb--max"
  45. :class="[pt.thumb?.className]"
  46. :style="maxThumbStyle"
  47. ></view>
  48. </template>
  49. </view>
  50. <view
  51. class="cl-slider__picker"
  52. :style="{
  53. height: blockSize * 1.5 + 'rpx'
  54. }"
  55. @touchstart.prevent="onTouchStart"
  56. @touchmove.prevent="onTouchMove"
  57. @touchend="onTouchEnd"
  58. @touchcancel="onTouchEnd"
  59. ></view>
  60. </view>
  61. <slot name="value" :value="displayValue">
  62. <cl-text
  63. v-if="showValue"
  64. :pt="{
  65. className: parseClass(['text-center w-[100rpx]', pt.value?.className])
  66. }"
  67. >
  68. {{ displayValue }}
  69. </cl-text>
  70. </slot>
  71. </view>
  72. </template>
  73. <script setup lang="ts">
  74. import { computed, getCurrentInstance, nextTick, onMounted, ref, watch, type PropType } from "vue";
  75. import { parseClass, parsePt, rpx2px } from "@/cool";
  76. import type { PassThroughProps } from "../../types";
  77. import { useForm } from "../../hooks";
  78. defineOptions({
  79. name: "cl-slider"
  80. });
  81. // 组件属性定义
  82. const props = defineProps({
  83. // 样式穿透对象
  84. pt: {
  85. type: Object,
  86. default: () => ({})
  87. },
  88. // v-model 绑定的值,单值模式使用
  89. modelValue: {
  90. type: Number,
  91. default: 0
  92. },
  93. // v-model:values 绑定的值,范围模式使用
  94. values: {
  95. type: Array as PropType<number[]>,
  96. default: () => [0, 0]
  97. },
  98. // 最小值
  99. min: {
  100. type: Number,
  101. default: 0
  102. },
  103. // 最大值
  104. max: {
  105. type: Number,
  106. default: 100
  107. },
  108. // 步长
  109. step: {
  110. type: Number,
  111. default: 1
  112. },
  113. // 是否禁用
  114. disabled: {
  115. type: Boolean,
  116. default: false
  117. },
  118. // 滑块的大小
  119. blockSize: {
  120. type: Number,
  121. default: 40
  122. },
  123. // 线的高度
  124. trackHeight: {
  125. type: Number,
  126. default: 8
  127. },
  128. // 是否显示当前值
  129. showValue: {
  130. type: Boolean,
  131. default: false
  132. },
  133. // 是否启用范围选择
  134. range: {
  135. type: Boolean,
  136. default: false
  137. }
  138. });
  139. const emit = defineEmits(["update:modelValue", "update:values", "change", "changing"]);
  140. const { proxy } = getCurrentInstance()!;
  141. // 样式穿透类型定义
  142. type PassThrough = {
  143. className?: string;
  144. track?: PassThroughProps;
  145. progress?: PassThroughProps;
  146. thumb?: PassThroughProps;
  147. value?: PassThroughProps;
  148. };
  149. // 计算样式穿透对象
  150. const pt = computed(() => parsePt<PassThrough>(props.pt));
  151. // cl-form 上下文
  152. const { disabled } = useForm();
  153. // 是否禁用
  154. const isDisabled = computed(() => props.disabled || disabled.value);
  155. // 当前滑块的值,单值模式
  156. const value = ref<number>(props.modelValue);
  157. // 当前范围值,范围模式
  158. const rangeValue = ref<number[]>([...props.values]);
  159. // 轨道宽度(像素)
  160. const trackWidth = ref<number>(0);
  161. // 轨道左侧距离屏幕的距离(像素)
  162. const trackLeft = ref<number>(0);
  163. // 当前活动的滑块索引(0: min, 1: max),仅在范围模式下使用
  164. const activeThumbIndex = ref<number>(0);
  165. // 计算当前值在滑块轨道上的百分比位置(单值模式专用)
  166. const percentage = computed(() => {
  167. if (props.range) return 0;
  168. return ((value.value - props.min) / (props.max - props.min)) * 100;
  169. });
  170. // 计算范围模式下两个滑块的百分比位置
  171. type RangePercentage = {
  172. min: number; // 最小值滑块的位置百分比
  173. max: number; // 最大值滑块的位置百分比
  174. };
  175. const rangePercentage = computed<RangePercentage>(() => {
  176. if (!props.range) return { min: 0, max: 0 };
  177. const currentValues = rangeValue.value;
  178. const valueRange = props.max - props.min;
  179. // 分别计算两个滑块在轨道上的位置百分比
  180. const minPercent = ((currentValues[0] - props.min) / valueRange) * 100;
  181. const maxPercent = ((currentValues[1] - props.min) / valueRange) * 100;
  182. return { min: minPercent, max: maxPercent };
  183. });
  184. // 计算进度条的样式属性
  185. const progressStyle = computed(() => {
  186. const style = {};
  187. if (props.range) {
  188. // 范围模式:进度条从最小值滑块延伸到最大值滑块
  189. const { min, max } = rangePercentage.value;
  190. style["left"] = `${min}%`;
  191. style["width"] = `${max - min}%`;
  192. } else {
  193. // 单值模式:进度条从轨道起点延伸到当前滑块位置
  194. style["width"] = `${percentage.value}%`;
  195. }
  196. return style;
  197. });
  198. // 创建滑块的定位样式(通用函数)
  199. function createThumbStyle(percentPosition: number) {
  200. const style = {};
  201. // 计算滑块的有效移动范围(扣除滑块自身宽度,防止超出轨道)
  202. const effectiveTrackWidth = trackWidth.value - rpx2px(props.blockSize) + 1;
  203. const leftPosition = (percentPosition / 100) * effectiveTrackWidth;
  204. // 确保滑块位置在有效范围内
  205. const finalLeftPosition = Math.max(0, Math.min(effectiveTrackWidth, leftPosition));
  206. // 设置滑块的位置和尺寸
  207. style["left"] = `${finalLeftPosition}px`;
  208. style["width"] = `${rpx2px(props.blockSize)}px`;
  209. style["height"] = `${rpx2px(props.blockSize)}px`;
  210. return style;
  211. }
  212. // 单值模式滑块的样式
  213. const singleThumbStyle = computed(() => {
  214. return createThumbStyle(percentage.value);
  215. });
  216. // 范围模式最小值滑块的样式
  217. const minThumbStyle = computed(() => {
  218. return createThumbStyle(rangePercentage.value.min);
  219. });
  220. // 范围模式最大值滑块的样式
  221. const maxThumbStyle = computed(() => {
  222. return createThumbStyle(rangePercentage.value.max);
  223. });
  224. // 计算要显示的数值文本
  225. const displayValue = computed<string>(() => {
  226. if (props.range) {
  227. // 范围模式:显示"最小值 - 最大值"格式
  228. const currentValues = rangeValue.value;
  229. return `${currentValues[0]} - ${currentValues[1]}`;
  230. }
  231. // 单值模式:显示当前值
  232. return `${value.value}`;
  233. });
  234. // 获取滑块轨道的位置和尺寸信息,这是触摸计算的基础数据
  235. function getTrackInfo(): Promise<void> {
  236. return new Promise((resolve) => {
  237. uni.createSelectorQuery()
  238. .in(proxy)
  239. .select(".cl-slider__track")
  240. .boundingClientRect((node) => {
  241. // 保存轨道的宽度和左侧偏移量,用于后续的触摸位置计算
  242. trackWidth.value = (node as NodeInfo).width ?? 0;
  243. trackLeft.value = (node as NodeInfo).left ?? 0;
  244. resolve();
  245. })
  246. .exec();
  247. });
  248. }
  249. // 根据触摸点的横坐标计算对应的滑块数值
  250. function calculateValue(clientX: number): number {
  251. // 如果轨道宽度为0(还未初始化),直接返回最小值
  252. if (trackWidth.value == 0) return props.min;
  253. // 计算触摸点相对于轨道左侧的偏移距离
  254. const touchOffset = clientX - trackLeft.value;
  255. // 将偏移距离转换为0~1的百分比,并限制在有效范围内
  256. const progressPercentage = Math.max(0, Math.min(1, touchOffset / trackWidth.value));
  257. // 根据百分比计算在min~max范围内的实际数值
  258. const valueRange = props.max - props.min;
  259. let calculatedValue = props.min + progressPercentage * valueRange;
  260. // 如果设置了步长,按步长进行取整对齐
  261. if (props.step > 0) {
  262. calculatedValue =
  263. Math.round((calculatedValue - props.min) / props.step) * props.step + props.min;
  264. }
  265. // 确保最终值在[min, max]范围内
  266. return Math.max(props.min, Math.min(props.max, calculatedValue));
  267. }
  268. // 在范围模式下,根据触摸点离哪个滑块更近来确定应该移动哪个滑块
  269. function determineActiveThumb(clientX: number): number {
  270. if (!props.range) return 0;
  271. const currentValues = rangeValue.value;
  272. const touchValue = calculateValue(clientX);
  273. // 计算触摸位置到两个滑块的距离,选择距离更近的滑块进行操作
  274. const distanceToMinThumb = Math.abs(touchValue - currentValues[0]);
  275. const distanceToMaxThumb = Math.abs(touchValue - currentValues[1]);
  276. // 返回距离更近的滑块索引(0: 最小值滑块,1: 最大值滑块)
  277. return distanceToMinThumb <= distanceToMaxThumb ? 0 : 1;
  278. }
  279. // 更新滑块的值,并触发相应的事件
  280. function updateValue(newValue: number | number[]) {
  281. if (props.range) {
  282. // 范围模式:处理双滑块
  283. const newRangeValues = newValue as number[];
  284. const currentRangeValues = rangeValue.value;
  285. // 当左滑块超过右滑块时,自动交换活动滑块索引,实现滑块角色互换
  286. if (newRangeValues[0] > newRangeValues[1]) {
  287. activeThumbIndex.value = 1 - activeThumbIndex.value; // 0变1,1变0
  288. }
  289. // 确保最小值始终不大于最大值,自动排序
  290. const sortedValues = [
  291. Math.min(newRangeValues[0], newRangeValues[1]),
  292. Math.max(newRangeValues[0], newRangeValues[1])
  293. ];
  294. // 只有值真正改变时才更新和触发事件,避免不必要的渲染
  295. if (JSON.stringify(currentRangeValues) !== JSON.stringify(sortedValues)) {
  296. rangeValue.value = sortedValues;
  297. emit("update:values", sortedValues);
  298. emit("changing", sortedValues);
  299. }
  300. } else {
  301. // 单值模式:处理单个滑块
  302. const newSingleValue = newValue as number;
  303. const currentSingleValue = value.value;
  304. if (currentSingleValue !== newSingleValue) {
  305. value.value = newSingleValue;
  306. emit("update:modelValue", newSingleValue);
  307. emit("changing", newSingleValue);
  308. }
  309. }
  310. }
  311. // 触摸开始事件:获取轨道信息并初始化滑块位置
  312. async function onTouchStart(e: TouchEvent) {
  313. if (isDisabled.value) return;
  314. // 先获取轨道的位置和尺寸信息,这是后续计算的基础
  315. await getTrackInfo();
  316. // 等待DOM更新后再处理触摸逻辑
  317. nextTick(() => {
  318. const clientX = e.touches[0].clientX;
  319. const calculatedValue = calculateValue(clientX);
  320. if (props.range) {
  321. // 范围模式:确定要操作的滑块,然后更新对应滑块的值
  322. activeThumbIndex.value = determineActiveThumb(clientX);
  323. const updatedValues = [...rangeValue.value];
  324. updatedValues[activeThumbIndex.value] = calculatedValue;
  325. updateValue(updatedValues);
  326. } else {
  327. // 单值模式:直接更新滑块值
  328. updateValue(calculatedValue);
  329. }
  330. });
  331. }
  332. // 触摸移动事件:实时更新滑块位置
  333. function onTouchMove(e: TouchEvent) {
  334. if (isDisabled.value) return;
  335. const clientX = e.touches[0].clientX;
  336. const calculatedValue = calculateValue(clientX);
  337. if (props.range) {
  338. // 范围模式:更新当前活动滑块的值
  339. const updatedValues = [...rangeValue.value];
  340. updatedValues[activeThumbIndex.value] = calculatedValue;
  341. updateValue(updatedValues);
  342. } else {
  343. // 单值模式:直接更新滑块值
  344. updateValue(calculatedValue);
  345. }
  346. }
  347. // 触摸结束事件:完成拖动,触发最终的change事件
  348. function onTouchEnd() {
  349. if (isDisabled.value) return;
  350. // 触发change事件,表示用户完成了一次完整的拖动操作
  351. if (props.range) {
  352. emit("change", rangeValue.value);
  353. } else {
  354. emit("change", value.value);
  355. }
  356. }
  357. // 监听外部传入的modelValue变化,保持单值模式内部状态同步
  358. watch(
  359. computed(() => props.modelValue),
  360. (newModelValue: number) => {
  361. // 当外部值与内部值不同时,更新内部值并限制在有效范围内
  362. if (newModelValue !== value.value) {
  363. value.value = Math.max(props.min, Math.min(props.max, newModelValue));
  364. }
  365. },
  366. { immediate: true }
  367. );
  368. // 监听外部传入的values变化,保持范围模式内部状态同步
  369. watch(
  370. computed(() => props.values),
  371. (newValues: number[]) => {
  372. // 将外部传入的数组值映射并限制在有效范围内
  373. rangeValue.value = newValues.map((singleValue) => {
  374. return Math.max(props.min, Math.min(props.max, singleValue));
  375. });
  376. },
  377. { immediate: true }
  378. );
  379. // 监听最大值变化,确保当前值不会超过新的最大值
  380. watch(
  381. computed(() => props.max),
  382. (newMaxValue: number) => {
  383. if (props.range) {
  384. // 范围模式:检查并调整两个滑块的值
  385. const currentRangeValues = rangeValue.value;
  386. if (currentRangeValues[0] > newMaxValue || currentRangeValues[1] > newMaxValue) {
  387. updateValue([
  388. Math.min(currentRangeValues[0], newMaxValue),
  389. Math.min(currentRangeValues[1], newMaxValue)
  390. ]);
  391. }
  392. } else {
  393. // 单值模式:检查并调整单个滑块的值
  394. const currentSingleValue = value.value;
  395. if (currentSingleValue > newMaxValue) {
  396. updateValue(newMaxValue);
  397. }
  398. }
  399. },
  400. { immediate: true }
  401. );
  402. // 监听最小值变化,确保当前值不会小于新的最小值
  403. watch(
  404. computed(() => props.min),
  405. (newMinValue: number) => {
  406. if (props.range) {
  407. // 范围模式:检查并调整两个滑块的值
  408. const currentRangeValues = rangeValue.value;
  409. if (currentRangeValues[0] < newMinValue || currentRangeValues[1] < newMinValue) {
  410. updateValue([
  411. Math.max(currentRangeValues[0], newMinValue),
  412. Math.max(currentRangeValues[1], newMinValue)
  413. ]);
  414. }
  415. } else {
  416. // 单值模式:检查并调整单个滑块的值
  417. const currentSingleValue = value.value;
  418. if (currentSingleValue < newMinValue) {
  419. updateValue(newMinValue);
  420. }
  421. }
  422. },
  423. { immediate: true }
  424. );
  425. onMounted(() => {
  426. watch(
  427. computed(() => [props.showValue]),
  428. () => {
  429. nextTick(() => {
  430. getTrackInfo();
  431. });
  432. }
  433. );
  434. getTrackInfo();
  435. });
  436. </script>
  437. <style lang="scss" scoped>
  438. .cl-slider {
  439. @apply flex flex-row items-center w-full overflow-visible;
  440. &--disabled {
  441. @apply opacity-50;
  442. }
  443. &__inner {
  444. @apply flex-1 relative h-full flex flex-row items-center overflow-visible;
  445. }
  446. &__picker {
  447. @apply absolute left-0 w-full;
  448. }
  449. &__track {
  450. @apply relative w-full rounded-full overflow-visible;
  451. @apply bg-surface-200;
  452. }
  453. &__progress {
  454. @apply absolute top-0 h-full rounded-full;
  455. @apply bg-primary-400;
  456. }
  457. &__thumb {
  458. @apply absolute top-1/2 rounded-full border border-solid border-white;
  459. @apply bg-primary-500;
  460. transform: translateY(-50%);
  461. pointer-events: none;
  462. z-index: 1;
  463. border-width: 4rpx;
  464. box-shadow: 0 0 2rpx 2rpx rgba(100, 100, 100, 0.1);
  465. &--min {
  466. z-index: 2;
  467. }
  468. &--max {
  469. z-index: 2;
  470. }
  471. }
  472. }
  473. </style>