From a745709932d358d441469a046e9659844efb59c1 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: Sun, 13 Sep 2020 12:16:29 +0800
Subject: [PATCH] feat(slider): add dual thumb mode for slider (#7175)
* feat(slider): add dual thumb mode for slider
* chore: remove comment
* fix: optimize code via CR
---
src/slider/README.md | 61 ++++++++++++++-
src/slider/README.zh-CN.md | 63 ++++++++++++++--
src/slider/demo/index.vue | 46 ++++++++----
src/slider/index.js | 147 ++++++++++++++++++++++++++++---------
src/slider/index.less | 27 ++++++-
5 files changed, 281 insertions(+), 63 deletions(-)
diff --git a/src/slider/README.md b/src/slider/README.md
index 5ad958b29..c95deda09 100644
--- a/src/slider/README.md
+++ b/src/slider/README.md
@@ -35,6 +35,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
@@ -64,9 +90,7 @@ export default {
```html
-
- {{ value }}
-
+ {{ value }}
@@ -91,15 +115,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 421c226eb..4eb86f6da 100644
--- a/src/slider/README.zh-CN.md
+++ b/src/slider/README.zh-CN.md
@@ -35,6 +35,32 @@ export default {
};
```
+### 双滑块
+
+添加`range`属性就可以开启双滑块模式,确保`value`的值是一个数组
+
+```html
+
+```
+
+```js
+import { Toast } from 'vant';
+
+export default {
+ data() {
+ return {
+ // 双滑块模式时,值必须是数组
+ value: [10, 50],
+ };
+ },
+ methods: {
+ onChange(value) {
+ Toast('当前值:' + value);
+ },
+ },
+};
+```
+
### 指定选择范围
```html
@@ -64,9 +90,7 @@ export default {
```html
-
- {{ value }}
-
+ {{ value }}
@@ -88,20 +112,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..eee0e9317 100644
--- a/src/slider/demo/index.vue
+++ b/src/slider/demo/index.vue
@@ -5,20 +5,24 @@
-
+
-
+
-
+
+
+
+
+
-
+
- {{ value6 }}
+ {{ value7 }}
-
+
+
+
+
+
+
+
@@ -46,35 +56,41 @@ 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],
};
},
diff --git a/src/slider/index.js b/src/slider/index.js
index 0c9a27888..3a8221804 100644
--- a/src/slider/index.js
+++ b/src/slider/index.js
@@ -2,6 +2,7 @@ import { ref, computed } from 'vue';
// Utils
import { createNamespace, addUnit, getSizeStyle } from '../utils';
+import { deepClone } from '../utils/deep-clone';
import { preventDefault } from '../utils/dom/event';
// Composition
@@ -15,6 +16,7 @@ export default createComponent({
props: {
disabled: Boolean,
vertical: Boolean,
+ range: Boolean,
barHeight: [Number, String],
buttonSize: [Number, String],
activeColor: String,
@@ -32,7 +34,7 @@ export default createComponent({
default: 1,
},
modelValue: {
- type: Number,
+ type: [Number, Array],
default: 0,
},
},
@@ -42,12 +44,13 @@ export default createComponent({
setup(props, { emit, slots }) {
let startValue;
let currentValue;
+ let index;
const rootRef = ref();
const dragStatus = ref();
const touch = useTouch();
- const range = computed(() => props.max - props.min);
+ const scope = computed(() => props.max - props.min);
const wrapperStyle = computed(() => {
const crossAxis = props.vertical ? 'width' : 'height';
@@ -57,10 +60,30 @@ export default createComponent({
};
});
+ // 计算选中条的长度百分比
+ const calcMainAxis = () => {
+ const { modelValue, min, range } = props;
+ if (range) {
+ return `${((modelValue[1] - modelValue[0]) * 100) / scope.value}%`;
+ }
+ return `${((modelValue - min) * 100) / scope.value}%`;
+ };
+
+ // 计算选中条的开始位置的偏移量
+ const calcOffset = () => {
+ const { modelValue, min, range } = props;
+ if (range) {
+ return `${((modelValue[0] - min) * 100) / scope.value}%`;
+ }
+ return `0%`;
+ };
+
const barStyle = computed(() => {
const mainAxis = props.vertical ? 'height' : 'width';
return {
- [mainAxis]: `${((props.modelValue - props.min) * 100) / range.value}%`,
+ [mainAxis]: calcMainAxis(),
+ left: props.vertical ? null : calcOffset(),
+ top: props.vertical ? calcOffset() : null,
background: props.activeColor,
transition: dragStatus.value ? 'none' : null,
};
@@ -72,14 +95,31 @@ export default createComponent({
return Math.round(value / step) * step;
};
- const updateValue = (value, end) => {
- value = format(value);
+ const isSameValue = (newValue, oldValue) => {
+ return JSON.stringify(newValue) === JSON.stringify(oldValue);
+ };
- if (value !== props.modelValue) {
+ // 处理两个滑块重叠之后的情况
+ const handleOverlap = (value) => {
+ if (value[0] > value[1]) {
+ value = deepClone(value);
+ return value.reverse();
+ }
+ return value;
+ };
+
+ const updateValue = (value, end) => {
+ if (props.range) {
+ value = handleOverlap(value).map(format);
+ } else {
+ value = format(value);
+ }
+
+ if (!isSameValue(value, props.modelValue)) {
emit('update:modelValue', value);
}
- if (end && value !== startValue) {
+ if (end && !isSameValue(value, startValue)) {
emit('change', value);
}
};
@@ -91,15 +131,26 @@ export default createComponent({
return;
}
- const { min, vertical, modelValue } = props;
+ const { min, vertical, modelValue, range } = props;
const rect = useRect(rootRef);
const delta = vertical
? event.clientY - rect.top
: event.clientX - rect.left;
const total = vertical ? rect.height : rect.width;
- const value = +min + (delta / total) * range.value;
+ let value = +min + (delta / total) * scope.value;
+
+ if (range) {
+ let left = modelValue[0];
+ let right = modelValue[1];
+ const middle = (left + right) / 2;
+ if (value <= middle) {
+ left = value;
+ } else {
+ right = value;
+ }
+ value = [left, right];
+ }
- startValue = modelValue;
updateValue(value, true);
};
@@ -109,7 +160,12 @@ export default createComponent({
}
touch.start(event);
- startValue = format(props.modelValue);
+ currentValue = props.modelValue;
+ if (props.range) {
+ startValue = props.modelValue.map(format);
+ } else {
+ startValue = format(props.modelValue);
+ }
dragStatus.value = 'start';
};
@@ -129,9 +185,13 @@ export default createComponent({
const rect = useRect(rootRef);
const delta = props.vertical ? touch.deltaY.value : touch.deltaX.value;
const total = props.vertical ? rect.height : rect.width;
- const diff = (delta / total) * range.value;
+ const diff = (delta / total) * scope.value;
- currentValue = startValue + diff;
+ if (props.range) {
+ currentValue[index] = startValue[index] + diff;
+ } else {
+ currentValue = startValue + diff;
+ }
updateValue(currentValue);
};
@@ -148,27 +208,44 @@ export default createComponent({
dragStatus.value = '';
};
- const renderButton = () => (
-
- );
+ const renderButton = (i) => {
+ const map = ['left', 'right'];
+ const getClassName = () => {
+ if (typeof i === 'number') {
+ return `button-wrapper-${map[i]}`;
+ }
+ return `button-wrapper`;
+ };
+
+ return (
+ {
+ if (typeof i === 'number') {
+ // 保存当前按钮的索引
+ index = i;
+ }
+ onTouchStart(e);
+ }}
+ onTouchmove={onTouchMove}
+ onTouchend={onTouchEnd}
+ onTouchcancel={onTouchEnd}
+ onClick={(e) => e.stopPropagation()}
+ >
+ {slots.button ? (
+ slots.button()
+ ) : (
+
+ )}
+
+ );
+ };
// format initial value
updateValue(props.modelValue);
@@ -185,7 +262,7 @@ export default createComponent({
onClick={onClick}
>
- {renderButton()}
+ {props.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;