u-slider.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. <template>
  2. <view
  3. class="u-slider"
  4. :class="[{ 'u-slider--vertical': vertical, 'u-slider--disabled': disabled }]"
  5. :id="sliderId"
  6. :style="[sliderStyle]"
  7. @tap="onRailTap"
  8. >
  9. <!-- 轨道 -->
  10. <view class="u-slider__rail" :style="[railStyle]">
  11. <view class="u-slider__track" :style="[trackStyle]"></view>
  12. </view>
  13. <!-- 单个滑块或起始滑块 -->
  14. <view
  15. v-if="!range || (Array.isArray(currentValue))"
  16. class="u-slider__thumb"
  17. :style="[startThumbStyle]"
  18. @touchstart.stop.prevent="onThumbStart($event,'start')"
  19. @touchmove.stop.prevent="onThumbMove"
  20. @touchend.stop.prevent="onThumbEnd"
  21. >
  22. <slot name="startThumb">
  23. <view class="u-slider__thumb-inner" :style="[thumbStyle]"></view>
  24. </slot>
  25. <view v-if="showValue" class="u-slider__value">{{ displayStartValue }}</view>
  26. </view>
  27. <!-- 范围滑块结束滑块 -->
  28. <view
  29. v-if="range && (Array.isArray(currentValue))"
  30. class="u-slider__thumb u-slider__thumb--end"
  31. :style="[endThumbStyle]"
  32. @touchstart.stop.prevent="onThumbStart($event,'end')"
  33. @touchmove.stop.prevent="onThumbMove"
  34. @touchend.stop.prevent="onThumbEnd"
  35. >
  36. <slot name="endThumb">
  37. <view class="u-slider__thumb-inner" :style="[thumbStyle]"></view>
  38. </slot>
  39. <view v-if="showValue" class="u-slider__value">{{ displayEndValue }}</view>
  40. </view>
  41. </view>
  42. </template>
  43. <script>
  44. import props from './props.js';
  45. import mixin from '../../libs/mixin/mixin'
  46. import mpMixin from '../../libs/mixin/mpMixin'
  47. /**
  48. * Slider 滑块
  49. * @description 本组件用于滑动选择数值
  50. * @tutorial https://uviewui.com/components/slider.html
  51. * @property {String | Number} value 当前值 (默认 0 )
  52. * @property {String | Number} min 最小值 (默认 0 )
  53. * @property {String | Number} max 最大值 (默认 100 )
  54. * @property {String | Number} step 步长 (默认 1 )
  55. * @property {Boolean} range 是否开启双滑块模式 (默认 false )
  56. * @property {Boolean} disabled 是否禁用滑块 (默认 false )
  57. * @property {Boolean} reverse 是否反向移动 (默认 false )
  58. * @property {Boolean} readonly 是否为只读状态 (默认 false )
  59. * @property {Boolean} noCross 是否禁止双滑块交叉 (默认 false )
  60. * @property {Boolean} vertical 是否垂直展示 (默认 false )
  61. * @property {String} size 滑块的尺寸 (默认 24 )
  62. * @property {String} thumbSize 滑块大小 (默认 15 )
  63. * @property {String} thumbColor 滑块颜色 (默认 '#ffffff' )
  64. * @property {String} thumbBorder 滑块边框颜色 (默认 '3px solid ' + theme.primary )
  65. * @property {String} thumbRadius 滑块圆角 (默认 50 )
  66. * @property {String} railColor 轨道颜色 (默认 'rgba(0, 0, 0, 0.1)' )
  67. * @property {String} railRadius 轨道圆角 (默认 10 )
  68. * @property {String} railSize 轨道大小 (默认 4 )
  69. * @property {String} trackColor 已选择部分的轨道颜色 (默认 theme.primary )
  70. * @property {Boolean} showValue 是否显示数值 (默认 false )
  71. * @example <u-slider></u-slider>
  72. */
  73. export default {
  74. name: 'u-slider',
  75. mixins: [mpMixin, mixin, props],
  76. data() {
  77. return {
  78. sliderId: 'slider' + uni.$u.guid(),
  79. containerRect: null,
  80. activeThumb: null,
  81. isDragging: false,
  82. }
  83. },
  84. computed: {
  85. currentValue() {
  86. let value = 0;
  87. // #ifdef VUE2
  88. value = this.value;
  89. // #endif
  90. // #ifdef VUE3
  91. value = this.modelValue;
  92. // #endif
  93. if (this.range) {
  94. const val = Array.isArray(value) ? value : [this.min, this.max];
  95. return [this.safeNumber(val[0], this.min), this.safeNumber(val[1], this.max)];
  96. }
  97. return this.safeNumber(value, this.min);
  98. },
  99. displayStartValue() {
  100. return Array.isArray(this.currentValue) ? this.currentValue[0] : this.currentValue;
  101. },
  102. displayEndValue() {
  103. return Array.isArray(this.currentValue) ? this.currentValue[1] : '';
  104. },
  105. // 数值范围计算
  106. numericRange() {
  107. const min = this.safeNumber(this.min, 0);
  108. const max = this.safeNumber(this.max, 100);
  109. return { min, max, range: max - min };
  110. },
  111. thumbStyle() {
  112. let style = {
  113. width: uni.$u.addUnit(this.thumbSize),
  114. height: uni.$u.addUnit(this.thumbSize),
  115. borderRadius: uni.$u.addUnit(this.thumbRadius),
  116. backgroundColor: this.thumbColor,
  117. border: this.thumbBorder,
  118. };
  119. return style;
  120. },
  121. sliderStyle(){
  122. let style = {};
  123. if(this.vertical) {
  124. style.width = uni.$u.addUnit(this.size);
  125. } else {
  126. style.height = uni.$u.addUnit(this.size);
  127. }
  128. return style;
  129. },
  130. railStyle() {
  131. let style = {
  132. backgroundColor: this.railColor,
  133. borderRadius: uni.$u.addUnit(this.railRadius)
  134. };
  135. if(this.vertical) {
  136. style.width = uni.$u.addUnit(this.railSize);
  137. } else {
  138. style.height = uni.$u.addUnit(this.railSize);
  139. }
  140. return style;
  141. },
  142. // 计算轨道样式
  143. trackStyle() {
  144. const { min, max } = this.numericRange;
  145. const rect = this.containerRect;
  146. if (!rect) return {};
  147. let startFraction = 0;
  148. let endFraction = 0;
  149. let style = {
  150. backgroundColor: this.trackColor,
  151. borderRadius: uni.$u.addUnit(this.railRadius)
  152. };
  153. if (this.range && Array.isArray(this.currentValue)) {
  154. const firstThumbFraction = this.positionPercent(this.currentValue[0], min, max, this.vertical, this.reverse);
  155. const secondThumbFraction = this.positionPercent(this.currentValue[1], min, max, this.vertical, this.reverse);
  156. startFraction = firstThumbFraction < secondThumbFraction ? firstThumbFraction : secondThumbFraction;
  157. endFraction = firstThumbFraction < secondThumbFraction ? secondThumbFraction : firstThumbFraction;
  158. } else {
  159. const currentFraction = this.positionPercent(this.currentValue, min, max, this.vertical, this.reverse);
  160. if (this.reverse) {
  161. startFraction = currentFraction;
  162. endFraction = 1;
  163. } else {
  164. startFraction = 0;
  165. endFraction = currentFraction;
  166. }
  167. if (!this.reverse){
  168. style.opacity = currentFraction <= 0.05 ? 0 : 1;
  169. }
  170. }
  171. if(this.vertical) {
  172. const start = parseInt(startFraction * rect.height);
  173. const size = parseInt((endFraction - startFraction) * rect.height);
  174. style.bottom = uni.$u.addUnit(start);
  175. style.height = uni.$u.addUnit(size);
  176. style.width = uni.$u.addUnit(this.railSize);
  177. } else {
  178. const start = parseInt(startFraction * rect.width);
  179. const size = parseInt((endFraction - startFraction) * rect.width);
  180. style.left = uni.$u.addUnit(start);
  181. style.width = uni.$u.addUnit(size);
  182. style.height = uni.$u.addUnit(this.railSize);
  183. }
  184. return style;
  185. },
  186. // 计算起始滑块样式
  187. startThumbStyle() {
  188. const value = this.range && Array.isArray(this.currentValue) ? this.currentValue[0] : this.currentValue;
  189. return this.calculateThumbStyle(value);
  190. },
  191. // 计算结束滑块样式
  192. endThumbStyle() {
  193. return this.calculateThumbStyle(this.currentValue[1]);
  194. }
  195. },
  196. mounted() {
  197. this.$nextTick(() => this.measure());
  198. },
  199. // #ifdef VUE3
  200. emits: ['update:modelValue', 'change', 'dragStart', 'dragEnd'],
  201. // #endif
  202. methods: {
  203. measure() {
  204. const query = uni.createSelectorQuery().in(this);
  205. query.select('#' + this.sliderId).boundingClientRect(rect => {
  206. if (rect && rect.width > 0 && rect.height > 0) {
  207. this.containerRect = rect;
  208. }
  209. }).exec();
  210. },
  211. safeNumber(value, defaultValue = 0) {
  212. const num = Number(value);
  213. return isNaN(num) || !isFinite(num) ? defaultValue : num;
  214. },
  215. // 计算百分比位置
  216. percentFromValue(value, min, max) {
  217. const val = this.safeNumber(value, min);
  218. const minVal = this.safeNumber(min, 0);
  219. const maxVal = this.safeNumber(max, 100);
  220. const range = maxVal - minVal;
  221. if (range <= 0) return 0;
  222. return Math.max(0, Math.min(1, (val - minVal) / range));
  223. },
  224. // 计算位置百分比
  225. positionPercent(value, min, max, vertical, reverse) {
  226. const p = this.percentFromValue(value, min, max);
  227. return reverse ? (1 - p) : p;
  228. },
  229. // 计算滑块样式
  230. calculateThumbStyle(value) {
  231. const { min, max } = this.numericRange;
  232. const rect = this.containerRect;
  233. if (!rect) return {};
  234. const fraction = this.positionPercent(value, min, max, this.vertical, this.reverse);
  235. const clampedFraction = Math.max(0, Math.min(1, fraction));
  236. const thumbSize = uni.$u.getPx(this.thumbSize) / 2;
  237. let style = {}
  238. if(this.vertical){
  239. let raw = clampedFraction * rect.height;
  240. if (!this.reverse){
  241. raw -= thumbSize;
  242. }
  243. const position = Math.max(thumbSize, raw);
  244. style.bottom = uni.$u.addUnit(parseInt(position));
  245. } else {
  246. let raw = clampedFraction * rect.width;
  247. if (!this.reverse){
  248. raw -= thumbSize;
  249. }
  250. const position = Math.max(thumbSize, raw);
  251. style.left = uni.$u.addUnit(parseInt(position));
  252. }
  253. return style;
  254. },
  255. coerceToStep(value) {
  256. const { min, max } = this.numericRange;
  257. const step = this.safeNumber(this.step, 1);
  258. const stepValue = step > 0 ? step : 1;
  259. let v = this.safeNumber(value, min);
  260. v = min + Math.round((v - min) / stepValue) * stepValue;
  261. return Math.max(min, Math.min(max, v));
  262. },
  263. pointToValue(event) {
  264. const point = event.touches[0];
  265. const { min, max, range } = this.numericRange;
  266. const rect = this.containerRect;
  267. let position = 0;
  268. if (this.vertical) {
  269. if (rect.height <= 0) return null;
  270. position = 1 - ((point.clientY - rect.top) / rect.height);
  271. } else {
  272. if (rect.width <= 0) return null;
  273. position = (point.clientX - rect.left) / rect.width;
  274. }
  275. position = Math.max(0, Math.min(1, position));
  276. if (this.reverse) position = 1 - position;
  277. return this.coerceToStep(min + position * range);
  278. },
  279. updateValue(proposedValue, movedThumb) {
  280. if (this.disabled || this.readonly) return;
  281. let nextValue = '';
  282. const { min, max } = this.numericRange;
  283. const steppedValue = this.coerceToStep(proposedValue);
  284. if (this.range && Array.isArray(this.currentValue)) {
  285. let [startValue, endValue] = this.currentValue;
  286. if (movedThumb === 'end') {
  287. endValue = steppedValue;
  288. if (this.noCross && endValue < startValue) endValue = startValue;
  289. } else {
  290. startValue = steppedValue;
  291. if (this.noCross && startValue > endValue) startValue = endValue;
  292. }
  293. // 确保范围有效
  294. startValue = Math.max(min, Math.min(max, startValue));
  295. endValue = Math.max(min, Math.min(max, endValue));
  296. nextValue = [startValue, endValue];
  297. } else {
  298. const clampedValue = Math.max(min, Math.min(max, steppedValue));
  299. nextValue = clampedValue;
  300. }
  301. // #ifdef VUE2
  302. this.$emit('input', nextValue);
  303. // #endif
  304. // #ifdef VUE3
  305. this.$emit('update:modelValue', nextValue);
  306. // #endif
  307. },
  308. onRailTap(e) {
  309. if (this.disabled || this.readonly) return;
  310. uni.$u.throttle(() => {
  311. const v = this.pointToValue(e);
  312. if (v == null) return;
  313. if (this.range && Array.isArray(this.currentValue)) {
  314. // 点击移动更近的滑块
  315. const distStart = Math.abs(v - this.currentValue[0]);
  316. const distEnd = Math.abs(v - this.currentValue[1]);
  317. const thumb = distStart <= distEnd ? 'start' : 'end';
  318. this.updateValue(v, thumb);
  319. } else {
  320. this.updateValue(v, 'start');
  321. }
  322. this.$emit('change', this.currentValue);
  323. }, 50);
  324. },
  325. onThumbStart(e, thumb) {
  326. if (this.disabled || this.readonly) return;
  327. this.isDragging = true;
  328. this.activeThumb = thumb;
  329. this.measure();
  330. this.$emit('dragStart', this.currentValue);
  331. },
  332. onThumbMove(e) {
  333. if (!this.isDragging) return;
  334. const v = this.pointToValue(e);
  335. if (v) {
  336. this.updateValue(v, this.activeThumb);
  337. }
  338. },
  339. onThumbEnd(e) {
  340. if (!this.isDragging) return;
  341. this.isDragging = false;
  342. this.$emit('dragEnd', this.currentValue);
  343. this.$emit('change', this.currentValue);
  344. }
  345. }
  346. }
  347. </script>
  348. <style lang="scss" scoped>
  349. @import "../../libs/css/components.scss";
  350. .u-slider {
  351. position: relative;
  352. width: 100%;
  353. display: flex;
  354. align-items: center;
  355. justify-content: center;
  356. &--disabled {
  357. opacity: 0.5;
  358. }
  359. &--vertical {
  360. height: 100%;
  361. justify-content: center;
  362. }
  363. &--vertical .u-slider__rail {
  364. left: 50%;
  365. top: 0;
  366. transform: translateX(-50%);
  367. height: 100%;
  368. width: 100%;
  369. }
  370. &--vertical .u-slider__thumb {
  371. transform: translate(0%, 50%);
  372. }
  373. &__rail {
  374. width: 100%;
  375. }
  376. &__thumb {
  377. position: absolute;
  378. z-index: 2;
  379. left: 0;
  380. transform: translate(-50%, 0%);
  381. }
  382. &__track {
  383. position: absolute;
  384. transition-duration: .1s;
  385. transition-property: opacity;
  386. }
  387. &__thumb-inner {
  388. box-shadow: 0 1px 4px rgba(0, 0, 0, .3);
  389. }
  390. &__value {
  391. position: absolute;
  392. top: -24px;
  393. left: 50%;
  394. transform: translateX(-50%);
  395. padding: 2px 6px;
  396. background: rgba(0,0,0,0.6);
  397. color: #fff;
  398. font-size: 10px;
  399. border-radius: 4px;
  400. }
  401. }
  402. </style>