cl-slider.uvue 14 KB

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