From 9b32a13bef50e774269a6862e8846573bd7ae7c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=AB=E9=9B=A8=E6=B0=B4=E8=BF=87=E6=BB=A4=E7=9A=84?= =?UTF-8?q?=E7=A9=BA=E6=B0=94?= <958414905@qq.com> Date: Mon, 14 Sep 2020 06:49:30 +0800 Subject: [PATCH] feat(2.x/slider): add dual thumb mode for slider (#7176) * feat(slider): add dual thumb mode for slider * fix: test error --- src/slider/README.md | 61 +++++++- src/slider/README.zh-CN.md | 63 +++++++- src/slider/demo/index.vue | 48 ++++-- src/slider/index.js | 148 ++++++++++++++---- src/slider/index.less | 27 +++- .../test/__snapshots__/demo.spec.js.snap | 26 +++ 6 files changed, 315 insertions(+), 58 deletions(-) diff --git a/src/slider/README.md b/src/slider/README.md index 0a342c50d..594fbac21 100644 --- a/src/slider/README.md +++ b/src/slider/README.md @@ -34,6 +34,32 @@ export default { }; ``` +### Dual thumb + +Add `range` attribute to open dual thumb mode + +```html + +``` + +```js +import { Toast } from 'vant'; + +export default { + data() { + return { + // value must be an Array + value: [10, 50], + }; + }, + methods: { + onChange(value) { + Toast('current value:' + value); + }, + }, +}; +``` + ### Range ```html @@ -63,9 +89,7 @@ export default { ```html @@ -90,15 +114,44 @@ export default { ``` +### Vertical, Dual thumb mode + +Add `range` and `vertical` attributes at the same time, and make sure that the value of `value` is an array + +```html +
+ +
+``` + +```js +import { Toast } from 'vant'; + +export default { + data() { + return { + // value must be an array + value: [10, 50], + }; + }, + methods: { + onChange(value) { + Toast('Current value:' + value); + }, + }, +}; +``` + ## API ### Props | Attribute | Description | Type | Default | | --- | --- | --- | --- | -| value | Current value | _number_ | `0` | +| value | Current value | _number \| array_ | `0` | | max | Max value | _number \| string_ | `100` | | min | Min value | _number \| string_ | `0` | +| range | Dual thumb mode | _boolean_ | `false` | | step | Step size | _number \| string_ | `1` | | bar-height | Height of bar | _number \| string_ | `2px` | | button-size `v2.4.5` | Button size | _number \| string_ | `24px` | diff --git a/src/slider/README.zh-CN.md b/src/slider/README.zh-CN.md index 99c43e4eb..bfbec503d 100644 --- a/src/slider/README.zh-CN.md +++ b/src/slider/README.zh-CN.md @@ -34,6 +34,32 @@ export default { }; ``` +### 双滑块 + +添加`range`属性就可以开启双滑块模式,确保`value`的值是一个数组 + +```html + +``` + +```js +import { Toast } from 'vant'; + +export default { + data() { + return { + // 双滑块模式时,值必须是数组 + value: [10, 50], + }; + }, + methods: { + onChange(value) { + Toast('当前值:' + value); + }, + }, +}; +``` + ### 指定选择范围 ```html @@ -63,9 +89,7 @@ export default { ```html @@ -87,20 +111,49 @@ export default { Slider 垂直展示时,高度为 100% 父元素高度 ```html -
+
``` +### 垂直方向,双滑块 + +同时添加`range`和`vertical`属性,并确保`value`的值是一个数组 + +```html +
+ +
+``` + +```js +import { Toast } from 'vant'; + +export default { + data() { + return { + // 双滑块模式时,值必须是数组 + value: [10, 50], + }; + }, + methods: { + onChange(value) { + Toast('当前值:' + value); + }, + }, +}; +``` + ## API ### Props | 参数 | 说明 | 类型 | 默认值 | | --- | --- | --- | --- | -| value | 当前进度百分比 | _number_ | `0` | +| value | 当前进度百分比 | _number \| array_ | `0` | | max | 最大值 | _number \| string_ | `100` | | min | 最小值 | _number \| string_ | `0` | +| range | 双滑块模式 | _boolean_ | `false` | | step | 步长 | _number \| string_ | `1` | | bar-height | 进度条高度,默认单位为`px` | _number \| string_ | `2px` | | button-size `v2.4.5` | 滑块按钮大小,默认单位为`px` | _number \| string_ | `24px` | diff --git a/src/slider/demo/index.vue b/src/slider/demo/index.vue index a510eefc6..98c460bf6 100644 --- a/src/slider/demo/index.vue +++ b/src/slider/demo/index.vue @@ -5,20 +5,24 @@ - + - + - + + + + + - +
- + +
+
+ + +
+
@@ -46,38 +56,42 @@ export default { i18n: { 'zh-CN': { title1: '基础用法', - title2: '指定选择范围', - title3: '禁用', - title4: '指定步长', + title2: '双滑块', + title3: '指定选择范围', + title4: '禁用', + title5: '指定步长', customStyle: '自定义样式', customButton: '自定义按钮', text: '当前值:', vertical: '垂直方向', + vertical2: '垂直方向,双滑块', }, 'en-US': { title1: 'Basic Usage', - title2: 'Range', - title3: 'Disabled', - title4: 'Step size', + title2: 'Dual thumb mode', + title3: 'Range', + title4: 'Disabled', + title5: 'Step size', customStyle: 'Custom Style', customButton: 'Custom Button', text: 'Current value: ', vertical: 'Vertical', + vertical2: 'Vertical, Dual thumb mode', }, }, - data() { return { value1: 50, - value2: 0, - value3: 50, + value2: [20, 60], + value3: 0, value4: 50, value5: 50, value6: 50, value7: 50, + value8: 50, + value9: [20, 60], }; }, - methods: { onChange(value) { this.$toast(this.t('text') + value); diff --git a/src/slider/index.js b/src/slider/index.js index d04f19605..fb33c087f 100644 --- a/src/slider/index.js +++ b/src/slider/index.js @@ -1,16 +1,22 @@ import { createNamespace, addUnit } from '../utils'; +import { deepClone } from '../utils/deep-clone'; import { preventDefault } from '../utils/dom/event'; import { TouchMixin } from '../mixins/touch'; import { FieldMixin } from '../mixins/field'; const [createComponent, bem] = createNamespace('slider'); +const isSameValue = (newValue, oldValue) => { + return JSON.stringify(newValue) === JSON.stringify(oldValue); +}; + export default createComponent({ mixins: [TouchMixin, FieldMixin], props: { disabled: Boolean, vertical: Boolean, + range: Boolean, barHeight: [Number, String], buttonSize: [Number, String], activeColor: String, @@ -28,7 +34,7 @@ export default createComponent({ default: 1, }, value: { - type: Number, + type: [Number, Array], default: 0, }, }, @@ -40,7 +46,7 @@ export default createComponent({ }, computed: { - range() { + scope() { return this.max - this.min; }, @@ -61,7 +67,12 @@ export default createComponent({ }, mounted() { - this.bindTouchEvent(this.$refs.wrapper); + if (this.range) { + this.bindTouchEvent(this.$refs.wrapper0); + this.bindTouchEvent(this.$refs.wrapper1); + } else { + this.bindTouchEvent(this.$refs.wrapper); + } }, methods: { @@ -71,7 +82,12 @@ export default createComponent({ } this.touchStart(event); - this.startValue = this.format(this.value); + this.currentValue = this.value; + if (this.range) { + this.startValue = this.value.map(this.format); + } else { + this.startValue = this.format(this.value); + } this.dragStatus = 'start'; }, @@ -91,10 +107,14 @@ export default createComponent({ const rect = this.$el.getBoundingClientRect(); const delta = this.vertical ? this.deltaY : this.deltaX; const total = this.vertical ? rect.height : rect.width; - const diff = (delta / total) * this.range; + const diff = (delta / total) * this.scope; - this.newValue = this.startValue + diff; - this.updateValue(this.newValue); + if (this.range) { + this.currentValue[this.index] = this.startValue[this.index] + diff; + } else { + this.currentValue = this.startValue + diff; + } + this.updateValue(this.currentValue); }, onTouchEnd() { @@ -103,7 +123,7 @@ export default createComponent({ } if (this.dragStatus === 'draging') { - this.updateValue(this.newValue, true); + this.updateValue(this.currentValue, true); this.$emit('drag-end'); } @@ -120,20 +140,44 @@ export default createComponent({ ? event.clientY - rect.top : event.clientX - rect.left; const total = this.vertical ? rect.height : rect.width; - const value = +this.min + (delta / total) * this.range; + let value = +this.min + (delta / total) * this.scope; + + if (this.range) { + let [left, right] = this.value; + const middle = (left + right) / 2; + if (value <= middle) { + left = value; + } else { + right = value; + } + value = [left, right]; + } this.startValue = this.value; this.updateValue(value, true); }, - updateValue(value, end) { - value = this.format(value); + // 处理两个滑块重叠之后的情况 + handleOverlap(value) { + if (value[0] > value[1]) { + value = deepClone(value); + return value.reverse(); + } + return value; + }, - if (value !== this.value) { + updateValue(value, end) { + if (this.range) { + value = this.handleOverlap(value).map(this.format); + } else { + value = this.format(value); + } + + if (!isSameValue(value, this.value)) { this.$emit('input', value); } - if (end && value !== this.startValue) { + if (end && !isSameValue(value, this.startValue)) { this.$emit('change', value); } }, @@ -156,8 +200,28 @@ export default createComponent({ [crossAxis]: addUnit(this.barHeight), }; + // 计算选中条的长度百分比 + const calcMainAxis = () => { + const { value, min, range, scope } = this; + if (range) { + return `${((value[1] - value[0]) * 100) / scope}%`; + } + return `${((value - min) * 100) / scope}%`; + }; + + // 计算选中条的开始位置的偏移量 + const calcOffset = () => { + const { value, min, range, scope } = this; + if (range) { + return `${((value[0] - min) * 100) / scope}%`; + } + return null; + }; + const barStyle = { - [mainAxis]: `${((this.value - this.min) * 100) / this.range}%`, + [mainAxis]: calcMainAxis(), + left: this.vertical ? null : calcOffset(), + top: this.vertical ? calcOffset() : null, background: this.activeColor, }; @@ -165,6 +229,47 @@ export default createComponent({ barStyle.transition = 'none'; } + const renderButton = (i) => { + const map = ['left', 'right']; + const isNumber = typeof i === 'number'; + const getClassName = () => { + if (isNumber) { + return `button-wrapper-${map[i]}`; + } + return `button-wrapper`; + }; + const getRefName = () => { + if (isNumber) { + return `wrapper${i}`; + } + return `wrapper`; + }; + + return ( +
{ + if (isNumber) { + // 保存当前按钮的索引 + this.index = i; + } + }} + onClick={(e) => e.stopPropagation()} + > + {this.slots('button') || ( +
+ )} +
+ ); + }; + return (
-
- {this.slots('button') || ( -
- )} -
+ {this.range ? [renderButton(0), renderButton(1)] : renderButton()}
); diff --git a/src/slider/index.less b/src/slider/index.less index 28d51adc8..881b5eb05 100644 --- a/src/slider/index.less +++ b/src/slider/index.less @@ -24,7 +24,7 @@ height: 100%; background-color: @slider-active-background-color; border-radius: inherit; - transition: width @animation-duration-fast, height @animation-duration-fast; + transition: all @animation-duration-fast; } &__button { @@ -34,20 +34,31 @@ border-radius: @slider-button-border-radius; box-shadow: @slider-button-box-shadow; - &-wrapper { + &-wrapper, + &-wrapper-right { position: absolute; top: 50%; right: 0; transform: translate3d(50%, -50%, 0); cursor: grab; } + + &-wrapper-left { + position: absolute; + top: 50%; + left: 0; + transform: translate3d(-50%, -50%, 0); + cursor: grab; + } } &--disabled { cursor: not-allowed; opacity: @slider-disabled-opacity; - .van-slider__button-wrapper { + .van-slider__button-wrapper, + .van-slider__button-wrapper-left, + .van-slider__button-wrapper-right { cursor: not-allowed; } } @@ -57,13 +68,21 @@ width: @slider-bar-height; height: 100%; - .van-slider__button-wrapper { + .van-slider__button-wrapper, + .van-slider__button-wrapper-right { top: auto; right: 50%; bottom: 0; transform: translate3d(50%, 50%, 0); } + .van-slider__button-wrapper-left { + top: 0; + right: 50%; + left: auto; + transform: translate3d(50%, -50%, 0); + } + // use pseudo element to expand click area &::before { top: 0; diff --git a/src/slider/test/__snapshots__/demo.spec.js.snap b/src/slider/test/__snapshots__/demo.spec.js.snap index e1a65f6e5..1e4852582 100644 --- a/src/slider/test/__snapshots__/demo.spec.js.snap +++ b/src/slider/test/__snapshots__/demo.spec.js.snap @@ -11,6 +11,18 @@ exports[`renders demo correctly 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
@@ -67,5 +79,19 @@ exports[`renders demo correctly 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;