feat(2.x/slider): add dual thumb mode for slider (#7176)

* feat(slider): add dual thumb mode for slider

* fix: test error
This commit is contained in:
被雨水过滤的空气 2020-09-14 06:49:30 +08:00 committed by GitHub
parent 2c38469591
commit 9b32a13bef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 315 additions and 58 deletions

View File

@ -34,6 +34,32 @@ export default {
}; };
``` ```
### Dual thumb
Add `range` attribute to open dual thumb mode
```html
<van-slider v-model="value" range @change="onChange" />
```
```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 ### Range
```html ```html
@ -63,9 +89,7 @@ export default {
```html ```html
<van-slider v-model="value" active-color="#ee0a24"> <van-slider v-model="value" active-color="#ee0a24">
<template #button> <template #button>
<div class="custom-button"> <div class="custom-button">{{ value }}</div>
{{ value }}
</div>
</template> </template>
</van-slider> </van-slider>
@ -90,15 +114,44 @@ export default {
</div> </div>
``` ```
### 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
<div :style="{ height: '120px' }">
<van-slider v-model="value" range vertical @change="onChange" />
</div>
```
```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 ## API
### Props ### Props
| Attribute | Description | Type | Default | | Attribute | Description | Type | Default |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| value | Current value | _number_ | `0` | | value | Current value | _number \| array_ | `0` |
| max | Max value | _number \| string_ | `100` | | max | Max value | _number \| string_ | `100` |
| min | Min value | _number \| string_ | `0` | | min | Min value | _number \| string_ | `0` |
| range | Dual thumb mode | _boolean_ | `false` |
| step | Step size | _number \| string_ | `1` | | step | Step size | _number \| string_ | `1` |
| bar-height | Height of bar | _number \| string_ | `2px` | | bar-height | Height of bar | _number \| string_ | `2px` |
| button-size `v2.4.5` | Button size | _number \| string_ | `24px` | | button-size `v2.4.5` | Button size | _number \| string_ | `24px` |

View File

@ -34,6 +34,32 @@ export default {
}; };
``` ```
### 双滑块
添加`range`属性就可以开启双滑块模式,确保`value`的值是一个数组
```html
<van-slider v-model="value" range @change="onChange" />
```
```js
import { Toast } from 'vant';
export default {
data() {
return {
// 双滑块模式时,值必须是数组
value: [10, 50],
};
},
methods: {
onChange(value) {
Toast('当前值:' + value);
},
},
};
```
### 指定选择范围 ### 指定选择范围
```html ```html
@ -63,9 +89,7 @@ export default {
```html ```html
<van-slider v-model="value" active-color="#ee0a24"> <van-slider v-model="value" active-color="#ee0a24">
<template #button> <template #button>
<div class="custom-button"> <div class="custom-button">{{ value }}</div>
{{ value }}
</div>
</template> </template>
</van-slider> </van-slider>
@ -87,20 +111,49 @@ export default {
Slider 垂直展示时,高度为 100% 父元素高度 Slider 垂直展示时,高度为 100% 父元素高度
```html ```html
<div :style="{ height: '100px' }"> <div :style="{ height: '120px' }">
<van-slider v-model="value" vertical /> <van-slider v-model="value" vertical />
</div> </div>
``` ```
### 垂直方向,双滑块
同时添加`range``vertical`属性,并确保`value`的值是一个数组
```html
<div :style="{ height: '120px' }">
<van-slider v-model="value" range vertical @change="onChange" />
</div>
```
```js
import { Toast } from 'vant';
export default {
data() {
return {
// 双滑块模式时,值必须是数组
value: [10, 50],
};
},
methods: {
onChange(value) {
Toast('当前值:' + value);
},
},
};
```
## API ## API
### Props ### Props
| 参数 | 说明 | 类型 | 默认值 | | 参数 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| value | 当前进度百分比 | _number_ | `0` | | value | 当前进度百分比 | _number \| array_ | `0` |
| max | 最大值 | _number \| string_ | `100` | | max | 最大值 | _number \| string_ | `100` |
| min | 最小值 | _number \| string_ | `0` | | min | 最小值 | _number \| string_ | `0` |
| range | 双滑块模式 | _boolean_ | `false` |
| step | 步长 | _number \| string_ | `1` | | step | 步长 | _number \| string_ | `1` |
| bar-height | 进度条高度,默认单位为`px` | _number \| string_ | `2px` | | bar-height | 进度条高度,默认单位为`px` | _number \| string_ | `2px` |
| button-size `v2.4.5` | 滑块按钮大小,默认单位为`px` | _number \| string_ | `24px` | | button-size `v2.4.5` | 滑块按钮大小,默认单位为`px` | _number \| string_ | `24px` |

View File

@ -5,20 +5,24 @@
</demo-block> </demo-block>
<demo-block :title="t('title2')"> <demo-block :title="t('title2')">
<van-slider v-model="value2" :min="-50" :max="50" @change="onChange" /> <van-slider range v-model="value2" @change="onChange" />
</demo-block> </demo-block>
<demo-block :title="t('title3')"> <demo-block :title="t('title3')">
<van-slider v-model="value3" disabled /> <van-slider v-model="value3" :min="-50" :max="50" @change="onChange" />
</demo-block> </demo-block>
<demo-block :title="t('title4')"> <demo-block :title="t('title4')">
<van-slider v-model="value4" :step="10" @change="onChange" /> <van-slider v-model="value4" disabled />
</demo-block>
<demo-block :title="t('title5')">
<van-slider v-model="value5" :step="10" @change="onChange" />
</demo-block> </demo-block>
<demo-block :title="t('customStyle')"> <demo-block :title="t('customStyle')">
<van-slider <van-slider
v-model="value5" v-model="value6"
bar-height="4px" bar-height="4px"
active-color="#ee0a24" active-color="#ee0a24"
@change="onChange" @change="onChange"
@ -26,16 +30,22 @@
</demo-block> </demo-block>
<demo-block :title="t('customButton')"> <demo-block :title="t('customButton')">
<van-slider v-model="value6" active-color="#ee0a24"> <van-slider v-model="value7" active-color="#ee0a24">
<template #button> <template #button>
<div class="custom-button">{{ value6 }}</div> <div class="custom-button">{{ value7 }}</div>
</template> </template>
</van-slider> </van-slider>
</demo-block> </demo-block>
<demo-block v-if="!isWeapp" :title="t('vertical')"> <demo-block v-if="!isWeapp" :title="t('vertical')">
<div :style="{ height: '120px', paddingLeft: '30px' }"> <div :style="{ height: '120px', paddingLeft: '30px' }">
<van-slider v-model="value7" vertical @change="onChange" /> <van-slider v-model="value8" vertical @change="onChange" />
</div>
</demo-block>
<demo-block v-if="!isWeapp" :title="t('vertical2')">
<div :style="{ height: '120px', paddingLeft: '30px' }">
<van-slider v-model="value9" range vertical @change="onChange" />
</div> </div>
</demo-block> </demo-block>
</demo-section> </demo-section>
@ -46,38 +56,42 @@ export default {
i18n: { i18n: {
'zh-CN': { 'zh-CN': {
title1: '基础用法', title1: '基础用法',
title2: '指定选择范围', title2: '双滑块',
title3: '禁用', title3: '指定选择范围',
title4: '指定步长', title4: '禁用',
title5: '指定步长',
customStyle: '自定义样式', customStyle: '自定义样式',
customButton: '自定义按钮', customButton: '自定义按钮',
text: '当前值:', text: '当前值:',
vertical: '垂直方向', vertical: '垂直方向',
vertical2: '垂直方向,双滑块',
}, },
'en-US': { 'en-US': {
title1: 'Basic Usage', title1: 'Basic Usage',
title2: 'Range', title2: 'Dual thumb mode',
title3: 'Disabled', title3: 'Range',
title4: 'Step size', title4: 'Disabled',
title5: 'Step size',
customStyle: 'Custom Style', customStyle: 'Custom Style',
customButton: 'Custom Button', customButton: 'Custom Button',
text: 'Current value: ', text: 'Current value: ',
vertical: 'Vertical', vertical: 'Vertical',
vertical2: 'Vertical, Dual thumb mode',
}, },
}, },
data() { data() {
return { return {
value1: 50, value1: 50,
value2: 0, value2: [20, 60],
value3: 50, value3: 0,
value4: 50, value4: 50,
value5: 50, value5: 50,
value6: 50, value6: 50,
value7: 50, value7: 50,
value8: 50,
value9: [20, 60],
}; };
}, },
methods: { methods: {
onChange(value) { onChange(value) {
this.$toast(this.t('text') + value); this.$toast(this.t('text') + value);

View File

@ -1,16 +1,22 @@
import { createNamespace, addUnit } from '../utils'; import { createNamespace, addUnit } from '../utils';
import { deepClone } from '../utils/deep-clone';
import { preventDefault } from '../utils/dom/event'; import { preventDefault } from '../utils/dom/event';
import { TouchMixin } from '../mixins/touch'; import { TouchMixin } from '../mixins/touch';
import { FieldMixin } from '../mixins/field'; import { FieldMixin } from '../mixins/field';
const [createComponent, bem] = createNamespace('slider'); const [createComponent, bem] = createNamespace('slider');
const isSameValue = (newValue, oldValue) => {
return JSON.stringify(newValue) === JSON.stringify(oldValue);
};
export default createComponent({ export default createComponent({
mixins: [TouchMixin, FieldMixin], mixins: [TouchMixin, FieldMixin],
props: { props: {
disabled: Boolean, disabled: Boolean,
vertical: Boolean, vertical: Boolean,
range: Boolean,
barHeight: [Number, String], barHeight: [Number, String],
buttonSize: [Number, String], buttonSize: [Number, String],
activeColor: String, activeColor: String,
@ -28,7 +34,7 @@ export default createComponent({
default: 1, default: 1,
}, },
value: { value: {
type: Number, type: [Number, Array],
default: 0, default: 0,
}, },
}, },
@ -40,7 +46,7 @@ export default createComponent({
}, },
computed: { computed: {
range() { scope() {
return this.max - this.min; return this.max - this.min;
}, },
@ -61,7 +67,12 @@ export default createComponent({
}, },
mounted() { 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: { methods: {
@ -71,7 +82,12 @@ export default createComponent({
} }
this.touchStart(event); 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'; this.dragStatus = 'start';
}, },
@ -91,10 +107,14 @@ export default createComponent({
const rect = this.$el.getBoundingClientRect(); const rect = this.$el.getBoundingClientRect();
const delta = this.vertical ? this.deltaY : this.deltaX; const delta = this.vertical ? this.deltaY : this.deltaX;
const total = this.vertical ? rect.height : rect.width; 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; if (this.range) {
this.updateValue(this.newValue); this.currentValue[this.index] = this.startValue[this.index] + diff;
} else {
this.currentValue = this.startValue + diff;
}
this.updateValue(this.currentValue);
}, },
onTouchEnd() { onTouchEnd() {
@ -103,7 +123,7 @@ export default createComponent({
} }
if (this.dragStatus === 'draging') { if (this.dragStatus === 'draging') {
this.updateValue(this.newValue, true); this.updateValue(this.currentValue, true);
this.$emit('drag-end'); this.$emit('drag-end');
} }
@ -120,20 +140,44 @@ export default createComponent({
? event.clientY - rect.top ? event.clientY - rect.top
: event.clientX - rect.left; : event.clientX - rect.left;
const total = this.vertical ? rect.height : rect.width; 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.startValue = this.value;
this.updateValue(value, true); 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); this.$emit('input', value);
} }
if (end && value !== this.startValue) { if (end && !isSameValue(value, this.startValue)) {
this.$emit('change', value); this.$emit('change', value);
} }
}, },
@ -156,8 +200,28 @@ export default createComponent({
[crossAxis]: addUnit(this.barHeight), [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 = { 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, background: this.activeColor,
}; };
@ -165,6 +229,47 @@ export default createComponent({
barStyle.transition = 'none'; 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 (
<div
ref={getRefName()}
role="slider"
tabindex={this.disabled ? -1 : 0}
aria-valuemin={this.min}
aria-valuenow={this.value}
aria-valuemax={this.max}
aria-orientation={this.vertical ? 'vertical' : 'horizontal'}
class={bem(getClassName())}
onTouchstart={() => {
if (isNumber) {
// 保存当前按钮的索引
this.index = i;
}
}}
onClick={(e) => e.stopPropagation()}
>
{this.slots('button') || (
<div class={bem('button')} style={this.buttonStyle} />
)}
</div>
);
};
return ( return (
<div <div
style={wrapperStyle} style={wrapperStyle}
@ -172,20 +277,7 @@ export default createComponent({
onClick={this.onClick} onClick={this.onClick}
> >
<div class={bem('bar')} style={barStyle}> <div class={bem('bar')} style={barStyle}>
<div {this.range ? [renderButton(0), renderButton(1)] : renderButton()}
ref="wrapper"
role="slider"
tabindex={this.disabled ? -1 : 0}
aria-valuemin={this.min}
aria-valuenow={this.value}
aria-valuemax={this.max}
aria-orientation={this.vertical ? 'vertical' : 'horizontal'}
class={bem('button-wrapper')}
>
{this.slots('button') || (
<div class={bem('button')} style={this.buttonStyle} />
)}
</div>
</div> </div>
</div> </div>
); );

View File

@ -24,7 +24,7 @@
height: 100%; height: 100%;
background-color: @slider-active-background-color; background-color: @slider-active-background-color;
border-radius: inherit; border-radius: inherit;
transition: width @animation-duration-fast, height @animation-duration-fast; transition: all @animation-duration-fast;
} }
&__button { &__button {
@ -34,20 +34,31 @@
border-radius: @slider-button-border-radius; border-radius: @slider-button-border-radius;
box-shadow: @slider-button-box-shadow; box-shadow: @slider-button-box-shadow;
&-wrapper { &-wrapper,
&-wrapper-right {
position: absolute; position: absolute;
top: 50%; top: 50%;
right: 0; right: 0;
transform: translate3d(50%, -50%, 0); transform: translate3d(50%, -50%, 0);
cursor: grab; cursor: grab;
} }
&-wrapper-left {
position: absolute;
top: 50%;
left: 0;
transform: translate3d(-50%, -50%, 0);
cursor: grab;
}
} }
&--disabled { &--disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: @slider-disabled-opacity; 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; cursor: not-allowed;
} }
} }
@ -57,13 +68,21 @@
width: @slider-bar-height; width: @slider-bar-height;
height: 100%; height: 100%;
.van-slider__button-wrapper { .van-slider__button-wrapper,
.van-slider__button-wrapper-right {
top: auto; top: auto;
right: 50%; right: 50%;
bottom: 0; bottom: 0;
transform: translate3d(50%, 50%, 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 // use pseudo element to expand click area
&::before { &::before {
top: 0; top: 0;

View File

@ -11,6 +11,18 @@ exports[`renders demo correctly 1`] = `
</div> </div>
</div> </div>
</div> </div>
<div>
<div class="van-slider">
<div class="van-slider__bar" style="width: 40%; left: 20%;">
<div role="slider" tabindex="0" aria-valuemin="0" aria-valuenow="20,60" aria-valuemax="100" aria-orientation="horizontal" class="van-slider__button-wrapper-left">
<div class="van-slider__button"></div>
</div>
<div role="slider" tabindex="0" aria-valuemin="0" aria-valuenow="20,60" aria-valuemax="100" aria-orientation="horizontal" class="van-slider__button-wrapper-right">
<div class="van-slider__button"></div>
</div>
</div>
</div>
</div>
<div> <div>
<div class="van-slider"> <div class="van-slider">
<div class="van-slider__bar" style="width: 50%;"> <div class="van-slider__bar" style="width: 50%;">
@ -67,5 +79,19 @@ exports[`renders demo correctly 1`] = `
</div> </div>
</div> </div>
</div> </div>
<div>
<div style="height: 120px; padding-left: 30px;">
<div class="van-slider van-slider--vertical">
<div class="van-slider__bar" style="height: 40%; top: 20%;">
<div role="slider" tabindex="0" aria-valuemin="0" aria-valuenow="20,60" aria-valuemax="100" aria-orientation="vertical" class="van-slider__button-wrapper-left">
<div class="van-slider__button"></div>
</div>
<div role="slider" tabindex="0" aria-valuemin="0" aria-valuenow="20,60" aria-valuemax="100" aria-orientation="vertical" class="van-slider__button-wrapper-right">
<div class="van-slider__button"></div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
`; `;