uni-tianzai-slider-range.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. <template>
  2. <!-- 区间滑动选择器容器 -->
  3. <view class="slider-range-body" :class="{
  4. disabled: disabled, // 禁用状态
  5. padding: tips || scaleInfo.show // 显示提示或刻度时添加 padding
  6. }">
  7. <!-- 滑动条 -->
  8. <view class="slider-bar" :style="[getBackgroundStyle]">
  9. <!-- 选中区域 -->
  10. <view class="slider-active-bar" :style="[getActiveStyle]"></view>
  11. <!-- 滑块容器 -->
  12. <view class="block-bar">
  13. <!-- 第一个滑块 -->
  14. <view class="block-item" v-if="firstBlock.show" :style="{
  15. left: getFirstBlockPosition,
  16. zIndex: firstBlock.zIndex
  17. }" data-sort="firstBlock" @touchstart.stop.prevent="onTouchStart" @touchmove.stop.prevent="onTouchMove"
  18. @touchend.stop.prevent="onTouchEnd">
  19. <!-- 非微信小程序插槽 -->
  20. <!-- #ifndef MP-WEIXIN -->
  21. <slot name="block">
  22. <view class="slider dis a-c j-c ">{{firstValue}}</view>
  23. </slot>
  24. <!-- #endif -->
  25. <!-- 微信小程序插槽 -->
  26. <!-- #ifdef MP-WEIXIN -->
  27. <slot name="firstBlock">
  28. <view class="slider dis a-c j-c ">{{firstValue}}</view>
  29. </slot>
  30. <!-- #endif -->
  31. </view>
  32. <!-- 第二个滑块 -->
  33. <view class="block-item" v-if="secondBlock.show" :style="{
  34. left: getSecondBlockPosition,
  35. zIndex: secondBlock.zIndex
  36. }" data-sort="secondBlock" @touchstart.stop.prevent="onTouchStart" @touchmove.stop.prevent="onTouchMove"
  37. @touchend.stop.prevent="onTouchEnd" @mousedown.stop="onTouchStart" @mousemove.stop="onTouchMove"
  38. @mouseleave.stop="onTouchEnd" @mouseup.stop="onTouchEnd">
  39. <!-- 非微信小程序插槽 -->
  40. <!-- #ifndef MP-WEIXIN -->
  41. <slot name="block">
  42. <view class="slider dis a-c j-c ">{{secondValue}}</view>
  43. </slot>
  44. <!-- #endif -->
  45. <!-- 微信小程序插槽 -->
  46. <!-- #ifdef MP-WEIXIN -->
  47. <slot name="secondBlock">
  48. <view class="slider dis a-c j-c ">{{secondValue}}</view>
  49. </slot>
  50. <!-- #endif -->
  51. </view>
  52. <!-- 占位元素,用于撑开容器高度 -->
  53. <view class="block-placeholder">
  54. <!-- 非微信小程序插槽 -->
  55. <!-- #ifndef MP-WEIXIN -->
  56. <slot name="block">
  57. <view class="default-block block-font"></view>
  58. </slot>
  59. <!-- #endif -->
  60. <!-- 微信小程序插槽 -->
  61. <!-- #ifdef MP-WEIXIN -->
  62. <slot name="placeholderBlock">
  63. <view class="default-block block-font"></view>
  64. </slot>
  65. <!-- #endif -->
  66. </view>
  67. </view>
  68. </view>
  69. <!-- 刻度条 -->
  70. <view class="scale-bar dis a-c j-s ">
  71. <text>¥{{min}}</text>
  72. <text>¥{{max}}</text>
  73. </view>
  74. </view>
  75. </template>
  76. <script>
  77. /**
  78. * @description 区间滑动选择组件
  79. * @example <slider-range></slider-range>
  80. * @property {number|array<number>} value 初始值,为 number 时表示滑动选择器,为 array<number> 时表示区间滑动选择器
  81. * @property {string} valueType 当 value 为 number 时,可滑动的是哪个值,可选值:min、max,默认 max
  82. * @property {number} min 最小值(最左侧值)默认 0
  83. * @property {number} max 最大值(最右侧值)默认 100
  84. * @property {number} step 步长,为 0 时不设步长,默认为 1
  85. * @property {number|string} height 滑动条高度,可自定义单位,默认单位 rpx
  86. * @property {boolean} disabled 是否禁用,默认 false
  87. * @property {boolean} tips 是否显示滑动提示,默认为 true
  88. * @property {boolean|object} scale 刻度条配置
  89. * @property {object} backgroundStyle 背景条自定义样式
  90. * @property {object} activeStyle 选中条自定义样式
  91. **/
  92. // 补全单位
  93. const completeUnit = (val = 0, unit = 'rpx') => {
  94. if (isNaN(Number(val))) return val
  95. return val + unit
  96. }
  97. // 乘法,防止小数计算失真
  98. const multiplication = (val1, val2) => {
  99. let dotIndex = String(val2).indexOf('.')
  100. if (dotIndex != -1) {
  101. let floatLength = String(val2).length - (dotIndex + 1)
  102. return val1 * (val2 * Math.pow(10, floatLength)) / Math.pow(10, floatLength)
  103. } else {
  104. return val1 * val2
  105. }
  106. }
  107. export default {
  108. name: "slider-range",
  109. props: {
  110. value: {
  111. type: [Number, Array],
  112. default: () => []
  113. },
  114. valueType: {
  115. type: String,
  116. default: 'max'
  117. },
  118. min: {
  119. type: Number,
  120. default: 0
  121. },
  122. max: {
  123. type: Number,
  124. default: 1000
  125. },
  126. step: {
  127. type: Number,
  128. default: 5
  129. },
  130. height: {
  131. type: [Number, String],
  132. default: 12
  133. },
  134. disabled: {
  135. type: Boolean,
  136. default: false
  137. },
  138. tips: {
  139. type: Boolean,
  140. default: true
  141. },
  142. scale: {
  143. type: [Boolean, Object],
  144. default: true
  145. },
  146. backgroundStyle: {
  147. type: Object,
  148. default: () => {
  149. return {
  150. width: '100%'
  151. }
  152. }
  153. },
  154. activeStyle: {
  155. type: Object,
  156. default: () => {
  157. return {}
  158. }
  159. }
  160. },
  161. data() {
  162. return {
  163. // 两个值
  164. firstValue: 200,
  165. secondValue: 0,
  166. // 两个滑点
  167. firstBlock: {
  168. show: true,
  169. zIndex: 1,
  170. },
  171. secondBlock: {
  172. show: true,
  173. zIndex: 2,
  174. },
  175. // 拖动中
  176. dragging: '',
  177. // H5 端拖动锁,防止 mouseup/mouseleave 重复触发 end,以及松手后再次移入时还在滑动
  178. isDragging: false,
  179. // 刻度线设置
  180. scaleInfo: {
  181. show: true,
  182. min: true,
  183. max: true,
  184. interval: 'auto',
  185. color: '#333',
  186. tickColor: '#999',
  187. fontSize: 22,
  188. format: null,
  189. },
  190. scaleLine: [],
  191. }
  192. },
  193. computed: {
  194. getStep() {
  195. if (this.max - this.min < this.step) return this.max - this.min
  196. return this.step
  197. },
  198. // 滑点坐标
  199. getFirstBlockPosition() {
  200. return (this.firstValue - this.min) / (this.max - this.min) * 100 + '%'
  201. },
  202. getSecondBlockPosition() {
  203. return (this.secondValue - this.min) / (this.max - this.min) * 100 + '%'
  204. },
  205. // 背景条样式
  206. getBackgroundStyle() {
  207. return {
  208. ...this.backgroundStyle,
  209. height: completeUnit(this.height)
  210. }
  211. },
  212. // 选中条样式
  213. getActiveStyle() {
  214. let min = Math.min(this.firstValue, this.secondValue)
  215. let max = Math.max(this.firstValue, this.secondValue)
  216. return {
  217. ...this.activeStyle,
  218. left: (min - this.min) / (this.max - this.min) * 100 + '%',
  219. width: (max - min) / (this.max - this.min) * 100 + '%',
  220. }
  221. }
  222. },
  223. watch: {
  224. /**
  225. * 监听value属性变化
  226. * @param {Array|Number} newValue 新值
  227. */
  228. value: {
  229. handler(newValue) {
  230. // 当value变化时重新初始化
  231. this.create()
  232. },
  233. deep: true
  234. }
  235. },
  236. mounted() {
  237. this.create()
  238. },
  239. methods: {
  240. /**
  241. * 初始化组件
  242. * 设置初始值和刻度信息
  243. */
  244. create() {
  245. // 验证参数有效性
  246. if (this.max <= this.min) {
  247. throw '[slider-range] max 属性不应小于或等于 min 属性'
  248. }
  249. // 设置初始值
  250. if (typeof this.value == 'number') {
  251. let value = this.value
  252. // 限制值在范围内
  253. if (this.value > this.max) value = this.max
  254. if (this.value < this.min) value = this.min
  255. // 根据valueType设置单滑块模式
  256. if (this.valueType == 'max') {
  257. this.firstBlock.show = false
  258. this.firstValue = this.min
  259. this.secondValue = value
  260. } else {
  261. this.secondBlock.show = false
  262. this.secondValue = this.max
  263. this.firstValue = value
  264. }
  265. } else {
  266. // 双滑块模式
  267. let firstValue = this.value[0] || this.min
  268. if (firstValue > this.max) firstValue = this.max
  269. if (firstValue < this.min) firstValue = this.min
  270. let secondValue = this.value[1] || this.max
  271. if (secondValue > this.max) secondValue = this.max
  272. if (secondValue < this.min) secondValue = this.min
  273. this.firstValue = firstValue
  274. this.secondValue = secondValue
  275. this.firstBlock.show = true
  276. this.secondBlock.show = true
  277. }
  278. },
  279. /**
  280. * 触摸开始事件
  281. * @param {Event} e 触摸事件对象
  282. */
  283. onTouchStart(e) {
  284. // 如果上一次拖动未结束,不响应新的 mousedown(避免 H5 端 mouseup 之后又 mousedown 混乱)
  285. if (this.isDragging) return
  286. if (this.disabled) return
  287. this.isDragging = true
  288. // 设置当前拖动的滑块
  289. this.dragging = e.currentTarget.dataset.sort
  290. // 调整z-index,确保当前拖动的滑块在上方
  291. e.currentTarget.dataset.sort == 'firstBlock' ?
  292. this.firstBlock.zIndex = this.secondBlock.zIndex + 1 :
  293. this.secondBlock.zIndex = this.firstBlock.zIndex + 1
  294. // 记录开始位置和初始值
  295. this.startDragPostion = (e.changedTouches && e.changedTouches[0]) ? e.changedTouches[0].pageX : (e.pageX || 0)
  296. this.startValue = e.currentTarget.dataset.sort == 'firstBlock' ? this.firstValue : this.secondValue
  297. // 触发开始事件
  298. this.$emit('start', {
  299. block: e.currentTarget.dataset.sort,
  300. value: this.startValue,
  301. values: [Math.min(this.firstValue, this.secondValue), Math.max(this.firstValue, this
  302. .secondValue)]
  303. })
  304. },
  305. /**
  306. * 触摸移动事件
  307. * @param {Event} e 触摸事件对象
  308. */
  309. onTouchMove(e) {
  310. if (this.disabled) return
  311. // 拖动已结束(mouseup/mouseleave 后又移入),直接拦截
  312. if (!this.isDragging) return
  313. this.onDrag(e)
  314. },
  315. /**
  316. * 触摸结束事件
  317. * @param {Event} e 触摸事件对象
  318. */
  319. onTouchEnd(e) {
  320. // 拖动已结束过(mouseup 和 mouseleave 都会触发),避免重复发 end
  321. if (!this.isDragging) return
  322. this.isDragging = false
  323. this.dragging = ''
  324. if (this.disabled) return
  325. this.onDrag(e, true)
  326. },
  327. /**
  328. * 拖动处理
  329. * @param {Event} e 触摸事件对象
  330. * @param {boolean} end 是否结束拖动
  331. */
  332. onDrag(e, end = false) {
  333. // 兼容 MouseEvent、TouchEvent、以及 e 为 null 的情况
  334. let pageX = 0
  335. if (e && e.changedTouches && e.changedTouches[0]) {
  336. pageX = e.changedTouches[0].pageX
  337. } else if (e && typeof e.pageX === 'number') {
  338. pageX = e.pageX
  339. }
  340. // 如果 end 时无法拿到 pageX,使用最后记录的 startDragPostion(也就是不移动,只确认 end)
  341. if (end && pageX === 0 && this.startDragPostion) {
  342. pageX = this.startDragPostion
  343. }
  344. // 兜底 e.currentTarget,取当前正在拖动的滑块
  345. let sort = (e && e.currentTarget && e.currentTarget.dataset && e.currentTarget.dataset.sort) || this.dragging
  346. let view = uni.createSelectorQuery().in(this).select('.block-bar')
  347. view.boundingClientRect(data => {
  348. // DOM 还没渲染好时 data 可能为 null,做兜底
  349. if (!data || !data.width) {
  350. if (end) {
  351. this.$emit('end', {
  352. block: sort,
  353. value: this.startValue,
  354. values: [Math.min(this.firstValue, this.secondValue), Math.max(this.firstValue, this.secondValue)]
  355. })
  356. }
  357. return
  358. }
  359. // 计算拖动距离对应的数值变化
  360. let diff = ((pageX - this.startDragPostion) / data.width) * (this.max - this.min)
  361. // 如果 diff 是 NaN,跳过这次更新
  362. if (isNaN(diff)) {
  363. if (end) {
  364. this.$emit('end', {
  365. block: sort,
  366. value: this.startValue,
  367. values: [Math.min(this.firstValue, this.secondValue), Math.max(this.firstValue, this.secondValue)]
  368. })
  369. }
  370. return
  371. }
  372. // 更新值
  373. this.onUpdateValue(this.startValue + diff, sort, end)
  374. }).exec()
  375. },
  376. /**
  377. * 更新滑块值
  378. * @param {number} value 新值
  379. * @param {string} sort 滑块标识
  380. * @param {boolean} end 是否结束拖动
  381. */
  382. onUpdateValue(value, sort, end = false) {
  383. // 入口 NaN 防护:如果 value 是 NaN,降级为当前滑块已有值
  384. if (isNaN(value)) {
  385. value = sort == 'firstBlock' ? this.firstValue : this.secondValue
  386. if (isNaN(value)) value = this.min
  387. }
  388. // 限制值在范围内
  389. if (value < this.min) value = this.min
  390. if (value > this.max) value = this.max
  391. // 应用步长
  392. if (this.getStep > 0 && value != this.min && value != this.max) {
  393. value = this.min + multiplication(Math.round((value - this.min) / this.getStep), this.getStep)
  394. } else {
  395. value = Number(value.toFixed(2))
  396. }
  397. // 更新对应滑块的值
  398. if (sort == 'firstBlock') {
  399. this.firstValue = value
  400. } else {
  401. this.secondValue = value
  402. }
  403. // 计算最终的范围值(确保从小到大排序)
  404. const minValue = Math.min(this.firstValue, this.secondValue)
  405. const maxValue = Math.max(this.firstValue, this.secondValue)
  406. // 触发change事件,返回排序后的范围值
  407. this.$emit('change', [minValue, maxValue])
  408. // 触发end事件,返回排序后的范围值
  409. if (end) {
  410. this.$emit('end', {
  411. block: sort,
  412. value: value,
  413. values: [minValue, maxValue]
  414. })
  415. }
  416. }
  417. }
  418. }
  419. </script>
  420. <style lang="scss" scoped>
  421. .slider-range-body {
  422. --main-color: #333333;
  423. --primary-color: #FDE935;
  424. width: 100%;
  425. box-sizing: border-box;
  426. position: relative;
  427. &.disabled {
  428. opacity: .7;
  429. }
  430. &.padding {
  431. margin-top: 30rpx;
  432. }
  433. .slider-bar {
  434. width: 100%;
  435. height: 12rpx;
  436. background-color: #f8f8f8;
  437. border-radius: 999999rpx;
  438. position: relative;
  439. .slider-active-bar {
  440. height: 100%;
  441. background-color: var(--primary-color);
  442. border-radius: 999999rpx;
  443. position: absolute;
  444. }
  445. .block-bar {
  446. width: 100%;
  447. .block-item {
  448. position: absolute;
  449. top: 50%;
  450. left: 0;
  451. transform: translate(-50%, -50%);
  452. }
  453. .block-placeholder {
  454. opacity: 0 !important;
  455. user-select: none;
  456. pointer-events: none;
  457. }
  458. .block-item,
  459. .block-placeholder {
  460. .default-block {
  461. color: var(--primary-color);
  462. font-size: 42rpx;
  463. }
  464. .slider {
  465. padding: 0 20rpx;
  466. box-sizing: border-box;
  467. background: #FFFBD7;
  468. border-radius: 20rpx 20rpx 20rpx 20rpx;
  469. border: 1rpx solid #FDE935;
  470. font-size: 28rpx;
  471. color: #333;
  472. }
  473. }
  474. }
  475. }
  476. .scale-bar {
  477. width: 100%;
  478. font-size: 24rpx;
  479. color: #666;
  480. margin-top: 14rpx;
  481. }
  482. }
  483. </style>