diff --git a/src/picker/PickerColumn.js b/src/picker/PickerColumn.js index e9be705c9f2..54d5a18df9d 100644 --- a/src/picker/PickerColumn.js +++ b/src/picker/PickerColumn.js @@ -1,8 +1,15 @@ +import { reactive, ref, watch } from 'vue'; +import { PICKER_KEY } from './shared'; + +// Utils +import { range } from '../utils/format/number'; import { deepClone } from '../utils/deep-clone'; import { createNamespace, isObject } from '../utils'; -import { range } from '../utils/format/number'; import { preventDefault } from '../utils/dom/event'; -import { TouchMixin } from '../mixins/touch'; + +// Composition +import { useTouch } from '../composition/use-touch'; +import { useParent } from '../composition/use-relation'; const DEFAULT_DURATION = 200; @@ -27,8 +34,6 @@ function isOptionDisabled(option) { } export default createComponent({ - mixins: [TouchMixin], - props: { valueKey: String, readonly: Boolean, @@ -46,257 +51,210 @@ export default createComponent({ emits: ['change'], - data() { - return { + setup(props, { emit }) { + let moving; + let startOffset; + let touchStartTime; + let momentumOffset; + let transitionEndTrigger; + + const wrapper = ref(); + + const state = reactive({ + index: props.defaultIndex, offset: 0, duration: 0, - options: deepClone(this.initialOptions), - currentIndex: this.defaultIndex, + options: deepClone(props.initialOptions), + }); + + const touch = useTouch(); + + const count = () => state.options.length; + + const baseOffset = () => + (props.itemHeight * (props.visibleItemCount - 1)) / 2; + + const adjustIndex = (index) => { + index = range(index, 0, count()); + + for (let i = index; i < count(); i++) { + if (!isOptionDisabled(state.options[i])) return i; + } + for (let i = index - 1; i >= 0; i--) { + if (!isOptionDisabled(state.options[i])) return i; + } }; - }, - created() { - if (this.$parent.children) { - this.$parent.children.push(this); - } + const setIndex = (index, emitChange) => { + index = adjustIndex(index) || 0; - this.setIndex(this.currentIndex); - }, + const offset = -index * props.itemHeight; + const trigger = () => { + if (index !== state.index) { + state.index = index; - mounted() { - this.bindTouchEvent(this.$el); - }, + if (emitChange) { + emit('change', index); + } + } + }; - unmounted() { - const { children } = this.$parent; + // trigger the change event after transitionend when moving + if (moving && offset !== state.offset) { + transitionEndTrigger = trigger; + } else { + trigger(); + } - if (children) { - children.splice(children.indexOf(this), 1); - } - }, + state.offset = offset; + }; - watch: { - initialOptions: 'setOptions', + const setOptions = (options) => { + if (JSON.stringify(options) !== JSON.stringify(state.options)) { + state.options = deepClone(options); + setIndex(props.defaultIndex); + } + }; - defaultIndex(val) { - this.setIndex(val); - }, - }, + const onClickItem = (index) => { + if (moving || props.readonly) { + return; + } - computed: { - count() { - return this.options.length; - }, + transitionEndTrigger = null; + state.duration = DEFAULT_DURATION; + setIndex(index, true); + }; - baseOffset() { - return (this.itemHeight * (this.visibleItemCount - 1)) / 2; - }, - }, + const getOptionText = (option) => { + if (isObject(option) && props.valueKey in option) { + return option[props.valueKey]; + } + return option; + }; + + const getIndexByOffset = (offset) => + range(Math.round(-offset / props.itemHeight), 0, count() - 1); + + const momentum = (distance, duration) => { + const speed = Math.abs(distance / duration); + + distance = state.offset + (speed / 0.003) * (distance < 0 ? -1 : 1); + + const index = getIndexByOffset(distance); + + state.duration = +props.swipeDuration; + setIndex(index, true); + }; + + const stopMomentum = () => { + moving = false; + state.duration = 0; - methods: { - setOptions(options) { - if (JSON.stringify(options) !== JSON.stringify(this.options)) { - this.options = deepClone(options); - this.setIndex(this.defaultIndex); + if (transitionEndTrigger) { + transitionEndTrigger(); + transitionEndTrigger = null; } - }, + }; - onTouchStart(event) { - if (this.readonly) { + const onTouchStart = (event) => { + if (props.readonly) { return; } - this.touchStart(event); + touch.start(event); - if (this.moving) { - const translateY = getElementTranslateY(this.$refs.wrapper); - this.offset = Math.min(0, translateY - this.baseOffset); - this.startOffset = this.offset; + if (moving) { + const translateY = getElementTranslateY(wrapper.value); + state.offset = Math.min(0, translateY - baseOffset()); + startOffset = state.offset; } else { - this.startOffset = this.offset; + startOffset = state.offset; } - this.duration = 0; - this.transitionEndTrigger = null; - this.touchStartTime = Date.now(); - this.momentumOffset = this.startOffset; - }, + state.duration = 0; + touchStartTime = Date.now(); + momentumOffset = startOffset; + transitionEndTrigger = null; + }; - onTouchMove(event) { - if (this.readonly) { + const onTouchMove = (event) => { + if (props.readonly) { return; } - this.touchMove(event); + touch.move(event); - if (this.direction === 'vertical') { - this.moving = true; + if (touch.isVertical()) { + moving = true; preventDefault(event, true); } - this.offset = range( - this.startOffset + this.deltaY, - -(this.count * this.itemHeight), - this.itemHeight + state.offset = range( + startOffset + touch.deltaY.value, + -(count() * props.itemHeight), + props.itemHeight ); const now = Date.now(); - if (now - this.touchStartTime > MOMENTUM_LIMIT_TIME) { - this.touchStartTime = now; - this.momentumOffset = this.offset; + if (now - touchStartTime > MOMENTUM_LIMIT_TIME) { + touchStartTime = now; + momentumOffset = state.offset; } - }, + }; - onTouchEnd() { - if (this.readonly) { + const onTouchEnd = () => { + if (props.readonly) { return; } - const distance = this.offset - this.momentumOffset; - const duration = Date.now() - this.touchStartTime; + const distance = state.offset - momentumOffset; + const duration = Date.now() - touchStartTime; const allowMomentum = duration < MOMENTUM_LIMIT_TIME && Math.abs(distance) > MOMENTUM_LIMIT_DISTANCE; if (allowMomentum) { - this.momentum(distance, duration); + momentum(distance, duration); return; } - const index = this.getIndexByOffset(this.offset); - this.duration = DEFAULT_DURATION; - this.setIndex(index, true); + const index = getIndexByOffset(state.offset); + state.duration = DEFAULT_DURATION; + setIndex(index, true); // compatible with desktop scenario // use setTimeout to skip the click event triggered after touchstart setTimeout(() => { - this.moving = false; + moving = false; }, 0); - }, - - onTransitionEnd() { - this.stopMomentum(); - }, - - onClickItem(index) { - if (this.moving || this.readonly) { - return; - } - - this.transitionEndTrigger = null; - this.duration = DEFAULT_DURATION; - this.setIndex(index, true); - }, - - adjustIndex(index) { - index = range(index, 0, this.count); - - for (let i = index; i < this.count; i++) { - if (!isOptionDisabled(this.options[i])) return i; - } - - for (let i = index - 1; i >= 0; i--) { - if (!isOptionDisabled(this.options[i])) return i; - } - }, - - getOptionText(option) { - if (isObject(option) && this.valueKey in option) { - return option[this.valueKey]; - } - return option; - }, - - setIndex(index, emitChange) { - index = this.adjustIndex(index) || 0; - - const offset = -index * this.itemHeight; - - const trigger = () => { - if (index !== this.currentIndex) { - this.currentIndex = index; - - if (emitChange) { - this.$emit('change', index); - } - } - }; - - // trigger the change event after transitionend when moving - if (this.moving && offset !== this.offset) { - this.transitionEndTrigger = trigger; - } else { - trigger(); - } - - this.offset = offset; - }, - - setValue(value) { - const { options } = this; - for (let i = 0; i < options.length; i++) { - if (this.getOptionText(options[i]) === value) { - return this.setIndex(i); - } - } - }, - - getValue() { - return this.options[this.currentIndex]; - }, - - getIndexByOffset(offset) { - return range(Math.round(-offset / this.itemHeight), 0, this.count - 1); - }, - - momentum(distance, duration) { - const speed = Math.abs(distance / duration); - - distance = this.offset + (speed / 0.003) * (distance < 0 ? -1 : 1); - - const index = this.getIndexByOffset(distance); - - this.duration = +this.swipeDuration; - this.setIndex(index, true); - }, - - stopMomentum() { - this.moving = false; - this.duration = 0; - - if (this.transitionEndTrigger) { - this.transitionEndTrigger(); - this.transitionEndTrigger = null; - } - }, + }; - genOptions() { + const renderOptions = () => { const optionStyle = { - height: `${this.itemHeight}px`, + height: `${props.itemHeight}px`, }; - return this.options.map((option, index) => { - const text = this.getOptionText(option); + return state.options.map((option, index) => { + const text = getOptionText(option); const disabled = isOptionDisabled(option); const data = { - style: optionStyle, role: 'button', + style: optionStyle, tabindex: disabled ? -1 : 0, - class: [ - bem('item', { - disabled, - selected: index === this.currentIndex, - }), - ], + class: bem('item', { + disabled, + selected: index === state.index, + }), onClick: () => { - this.onClickItem(index); + onClickItem(index); }, }; const childData = { class: 'van-ellipsis', - [this.allowHtml ? 'innerHTML' : 'textContent']: text, + [props.allowHtml ? 'innerHTML' : 'textContent']: text, }; return ( @@ -305,27 +263,63 @@ export default createComponent({ ); }); - }, - }, + }; - render() { - const wrapperStyle = { - transform: `translate3d(0, ${this.offset + this.baseOffset}px, 0)`, - transitionDuration: `${this.duration}ms`, - transitionProperty: this.duration ? 'all' : 'none', + const setValue = (value) => { + const { options } = state; + for (let i = 0; i < options.length; i++) { + if (getOptionText(options[i]) === value) { + return setIndex(i); + } + } }; - return ( -
- -
+ const getValue = () => state.options[state.index]; + + setIndex(state.index); + + useParent(PICKER_KEY, { + state, + getValue, + setValue, + setOptions, + stopMomentum, + }); + + watch(() => props.initialOptions, setOptions); + + watch( + () => props.defaultIndex, + (value) => { + setIndex(value); + } ); + + return () => { + const wrapperStyle = { + transform: `translate3d(0, ${state.offset + baseOffset()}px, 0)`, + transitionDuration: `${state.duration}ms`, + transitionProperty: state.duration ? 'all' : 'none', + }; + + return ( +
+ +
+ ); + }; }, }); diff --git a/src/picker/index.js b/src/picker/index.js index afce13dce84..721e0ae3266 100644 --- a/src/picker/index.js +++ b/src/picker/index.js @@ -1,10 +1,15 @@ +import { ref, watch, computed, provide, reactive } from 'vue'; +import { pickerProps, PICKER_KEY, DEFAULT_ITEM_HEIGHT } from './shared'; + // Utils import { createNamespace } from '../utils'; import { preventDefault } from '../utils/dom/event'; import { BORDER_UNSET_TOP_BOTTOM } from '../utils/constant'; -import { pickerProps, DEFAULT_ITEM_HEIGHT } from './shared'; import { unitToPx } from '../utils/format/unit'; +// Composition +import { useExpose } from '../composition/use-expose'; + // Components import Loading from '../loading'; import PickerColumn from './PickerColumn'; @@ -34,63 +39,34 @@ export default createComponent({ emits: ['confirm', 'cancel', 'change'], - data() { - return { - children: [], - formattedColumns: [], - }; - }, + setup(props, { emit, slots }) { + const children = reactive([]); + const formattedColumns = ref([]); - computed: { - itemPxHeight() { - return this.itemHeight ? unitToPx(this.itemHeight) : DEFAULT_ITEM_HEIGHT; - }, + const itemHeight = computed(() => + props.itemHeight ? unitToPx(props.itemHeight) : DEFAULT_ITEM_HEIGHT + ); - dataType() { - const { columns } = this; + const dataType = computed(() => { + const { columns } = props; const firstColumn = columns[0] || {}; if (firstColumn.children) { return 'cascade'; } - if (firstColumn.values) { return 'object'; } - return 'text'; - }, - }, - - watch: { - columns: { - handler() { - this.format(); - }, - immediate: true, - }, - }, - - methods: { - format() { - const { columns, dataType } = this; + }); - if (dataType === 'text') { - this.formattedColumns = [{ values: columns }]; - } else if (dataType === 'cascade') { - this.formatCascade(); - } else { - this.formattedColumns = columns; - } - }, - - formatCascade() { + const formatCascade = () => { const formatted = []; - let cursor = { children: this.columns }; + let cursor = { children: props.columns }; while (cursor && cursor.children) { - const defaultIndex = cursor.defaultIndex ?? +this.defaultIndex; + const defaultIndex = cursor.defaultIndex ?? +props.defaultIndex; formatted.push({ values: cursor.children, @@ -101,20 +77,35 @@ export default createComponent({ cursor = cursor.children[defaultIndex]; } - this.formattedColumns = formatted; - }, + formattedColumns.value = formatted; + }; + + const format = () => { + const { columns } = props; - emit(event) { - if (this.dataType === 'text') { - this.$emit(event, this.getColumnValue(0), this.getColumnIndex(0)); + if (dataType.value === 'text') { + formattedColumns.value = [{ values: columns }]; + } else if (dataType.value === 'cascade') { + formatCascade(); } else { - this.$emit(event, this.getValues(), this.getIndexes()); + formattedColumns.value = columns; } - }, + }; + + // get indexes of all columns + const getIndexes = () => children.map((child) => child.state.index); - onCascadeChange(columnIndex) { - let cursor = { children: this.columns }; - const indexes = this.getIndexes(); + // set options of column by index + const setColumnValues = (index, options) => { + const column = children[index]; + if (column) { + column.setOptions(options); + } + }; + + const onCascadeChange = (columnIndex) => { + let cursor = { children: props.columns }; + const indexes = getIndexes(); for (let i = 0; i <= columnIndex; i++) { cursor = cursor.children[indexes[i]]; @@ -122,169 +113,154 @@ export default createComponent({ while (cursor && cursor.children) { columnIndex++; - this.setColumnValues(columnIndex, cursor.children); + setColumnValues(columnIndex, cursor.children); cursor = cursor.children[cursor.defaultIndex || 0]; } - }, - - onChange(columnIndex) { - if (this.dataType === 'cascade') { - this.onCascadeChange(columnIndex); - } - - if (this.dataType === 'text') { - this.$emit('change', this.getColumnValue(0), this.getColumnIndex(0)); - } else { - this.$emit('change', this.getValues(), columnIndex); - } - }, + }; // get column instance by index - getColumn(index) { - return this.children[index]; - }, + const getColumn = (index) => children[index]; - // @exposed-api // get column value by index - getColumnValue(index) { - const column = this.getColumn(index); + const getColumnValue = (index) => { + const column = getColumn(index); return column && column.getValue(); - }, + }; - // @exposed-api // set column value by index - setColumnValue(index, value) { - const column = this.getColumn(index); + const setColumnValue = (index, value) => { + const column = getColumn(index); if (column) { column.setValue(value); - if (this.dataType === 'cascade') { - this.onCascadeChange(index); + if (dataType.value === 'cascade') { + onCascadeChange(index); } } - }, + }; - // @exposed-api // get column option index by column index - getColumnIndex(columnIndex) { - return (this.getColumn(columnIndex) || {}).currentIndex; - }, + const getColumnIndex = (index) => (getColumn(index) || {}).state.index; - // @exposed-api // set column option index by column index - setColumnIndex(columnIndex, optionIndex) { - const column = this.getColumn(columnIndex); + const setColumnIndex = (columnIndex, optionIndex) => { + const column = getColumn(columnIndex); if (column) { column.setIndex(optionIndex); - - if (this.dataType === 'cascade') { - this.onCascadeChange(columnIndex); + if (props.dataType === 'cascade') { + onCascadeChange(columnIndex); } } - }, + }; - // @exposed-api // get options of column by index - getColumnValues(index) { - return (this.children[index] || {}).options; - }, - - // @exposed-api - // set options of column by index - setColumnValues(index, options) { - const column = this.children[index]; + const getColumnValues = (index) => (children[index] || {}).state.options; - if (column) { - column.setOptions(options); - } - }, - - // @exposed-api // get values of all columns - getValues() { - return this.children.map((child) => child.getValue()); - }, + const getValues = () => children.map((child) => child.getValue()); - // @exposed-api // set values of all columns - setValues(values) { + const setValues = (values) => { values.forEach((value, index) => { - this.setColumnValue(index, value); + setColumnValue(index, value); }); - }, - - // @exposed-api - // get indexes of all columns - getIndexes() { - return this.children.map((child) => child.currentIndex); - }, + }; - // @exposed-api // set indexes of all columns - setIndexes(indexes) { + const setIndexes = (indexes) => { indexes.forEach((optionIndex, columnIndex) => { - this.setColumnIndex(columnIndex, optionIndex); + setColumnIndex(columnIndex, optionIndex); }); - }, + }; - // @exposed-api - confirm() { - this.children.forEach((child) => child.stopMomentum()); - this.emit('confirm'); - }, + const emitAction = (event) => { + if (dataType.value === 'text') { + emit(event, getColumnValue(0), getColumnIndex(0)); + } else { + emit(event, getValues(), getIndexes()); + } + }; - cancel() { - this.emit('cancel'); - }, + const onChange = (columnIndex) => { + if (dataType.value === 'cascade') { + onCascadeChange(columnIndex); + } - genTitle() { - if (this.$slots.title) { - return this.$slots.title(); + if (dataType.value === 'text') { + emit('change', getColumnValue(0), getColumnIndex(0)); + } else { + emit('change', getValues(), columnIndex); } + }; + + const confirm = () => { + children.forEach((child) => child.stopMomentum()); + emitAction('confirm'); + }; - if (this.title) { - return
{this.title}
; + const cancel = () => { + emitAction('cancel'); + }; + + const renderTitle = () => { + if (slots.title) { + return slots.title(); } - }, + if (props.title) { + return
{props.title}
; + } + }; - genToolbar() { - if (this.showToolbar) { + const renderToolbar = () => { + if (props.showToolbar) { return (
- {this.$slots.default - ? this.$slots.default() + {slots.default + ? slots.default() : [ - , - this.genTitle(), + renderTitle(), , ]}
); } - }, + }; - genColumns() { - const { itemPxHeight } = this; - const wrapHeight = itemPxHeight * this.visibleItemCount; + const renderColumnItems = () => + formattedColumns.value.map((item, columnIndex) => ( + { + onChange(columnIndex); + }} + /> + )); - const frameStyle = { height: `${itemPxHeight}px` }; + const renderColumns = () => { + const wrapHeight = itemHeight.value * props.visibleItemCount; + const frameStyle = { height: `${itemHeight.value}px` }; const columnsStyle = { height: `${wrapHeight}px` }; const maskStyle = { - backgroundSize: `100% ${(wrapHeight - itemPxHeight) / 2}px`, + backgroundSize: `100% ${(wrapHeight - itemHeight.value) / 2}px`, }; return ( @@ -293,7 +269,7 @@ export default createComponent({ style={columnsStyle} onTouchmove={preventDefault} > - {this.genColumnItems()} + {renderColumnItems()}
); - }, - - genColumnItems() { - return this.formattedColumns.map((item, columnIndex) => ( - { - this.onChange(columnIndex); - }} - /> - )); - }, - }, + }; - render() { - return ( + provide(PICKER_KEY, { children }); + + watch(() => props.columns, format, { immediate: true }); + + useExpose({ + confirm, + getValues, + setValues, + getIndexes, + setIndexes, + getColumnIndex, + setColumnIndex, + getColumnValue, + setColumnValue, + getColumnValues, + setColumnValues, + }); + + return () => (
- {this.toolbarPosition === 'top' ? this.genToolbar() : null} - {this.loading ? : null} - {this.$slots['columns-top']?.()} - {this.genColumns()} - {this.$slots['columns-bottom']?.()} - {this.toolbarPosition === 'bottom' ? this.genToolbar() : null} + {props.toolbarPosition === 'top' ? renderToolbar() : null} + {props.loading ? : null} + {slots['columns-top']?.()} + {renderColumns()} + {slots['columns-bottom']?.()} + {props.toolbarPosition === 'bottom' ? renderToolbar() : null}
); }, diff --git a/src/picker/shared.ts b/src/picker/shared.ts index 0a59b3dfe8d..b9ac9f9f338 100644 --- a/src/picker/shared.ts +++ b/src/picker/shared.ts @@ -8,6 +8,8 @@ export type SharedPickerProps = { confirmButtonText?: string; }; +export const PICKER_KEY = 'vanPicker'; + export const DEFAULT_ITEM_HEIGHT = 44; export const pickerProps = {