refactor(Picker): refactor with composition api

This commit is contained in:
chenjiahan 2020-09-16 16:11:50 +08:00
parent 971c16eb3c
commit 85d0d423eb
3 changed files with 377 additions and 408 deletions

View File

@ -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),
});
created() {
if (this.$parent.children) {
this.$parent.children.push(this);
}
const touch = useTouch();
this.setIndex(this.currentIndex);
},
const count = () => state.options.length;
mounted() {
this.bindTouchEvent(this.$el);
},
const baseOffset = () =>
(props.itemHeight * (props.visibleItemCount - 1)) / 2;
unmounted() {
const { children } = this.$parent;
const adjustIndex = (index) => {
index = range(index, 0, count());
if (children) {
children.splice(children.indexOf(this), 1);
}
},
watch: {
initialOptions: 'setOptions',
defaultIndex(val) {
this.setIndex(val);
},
},
computed: {
count() {
return this.options.length;
},
baseOffset() {
return (this.itemHeight * (this.visibleItemCount - 1)) / 2;
},
},
methods: {
setOptions(options) {
if (JSON.stringify(options) !== JSON.stringify(this.options)) {
this.options = deepClone(options);
this.setIndex(this.defaultIndex);
for (let i = index; i < count(); i++) {
if (!isOptionDisabled(state.options[i])) return i;
}
},
onTouchStart(event) {
if (this.readonly) {
return;
}
this.touchStart(event);
if (this.moving) {
const translateY = getElementTranslateY(this.$refs.wrapper);
this.offset = Math.min(0, translateY - this.baseOffset);
this.startOffset = this.offset;
} else {
this.startOffset = this.offset;
}
this.duration = 0;
this.transitionEndTrigger = null;
this.touchStartTime = Date.now();
this.momentumOffset = this.startOffset;
},
onTouchMove(event) {
if (this.readonly) {
return;
}
this.touchMove(event);
if (this.direction === 'vertical') {
this.moving = true;
preventDefault(event, true);
}
this.offset = range(
this.startOffset + this.deltaY,
-(this.count * this.itemHeight),
this.itemHeight
);
const now = Date.now();
if (now - this.touchStartTime > MOMENTUM_LIMIT_TIME) {
this.touchStartTime = now;
this.momentumOffset = this.offset;
}
},
onTouchEnd() {
if (this.readonly) {
return;
}
const distance = this.offset - this.momentumOffset;
const duration = Date.now() - this.touchStartTime;
const allowMomentum =
duration < MOMENTUM_LIMIT_TIME &&
Math.abs(distance) > MOMENTUM_LIMIT_DISTANCE;
if (allowMomentum) {
this.momentum(distance, duration);
return;
}
const index = this.getIndexByOffset(this.offset);
this.duration = DEFAULT_DURATION;
this.setIndex(index, true);
// compatible with desktop scenario
// use setTimeout to skip the click event triggered after touchstart
setTimeout(() => {
this.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;
if (!isOptionDisabled(state.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 setIndex = (index, emitChange) => {
index = adjustIndex(index) || 0;
const offset = -index * props.itemHeight;
const trigger = () => {
if (index !== this.currentIndex) {
this.currentIndex = index;
if (index !== state.index) {
state.index = index;
if (emitChange) {
this.$emit('change', index);
emit('change', index);
}
}
};
// trigger the change event after transitionend when moving
if (this.moving && offset !== this.offset) {
this.transitionEndTrigger = trigger;
if (moving && offset !== state.offset) {
transitionEndTrigger = trigger;
} else {
trigger();
}
this.offset = offset;
},
state.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);
}
const setOptions = (options) => {
if (JSON.stringify(options) !== JSON.stringify(state.options)) {
state.options = deepClone(options);
setIndex(props.defaultIndex);
}
},
};
getValue() {
return this.options[this.currentIndex];
},
const onClickItem = (index) => {
if (moving || props.readonly) {
return;
}
getIndexByOffset(offset) {
return range(Math.round(-offset / this.itemHeight), 0, this.count - 1);
},
transitionEndTrigger = null;
state.duration = DEFAULT_DURATION;
setIndex(index, true);
};
momentum(distance, duration) {
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 = this.offset + (speed / 0.003) * (distance < 0 ? -1 : 1);
distance = state.offset + (speed / 0.003) * (distance < 0 ? -1 : 1);
const index = this.getIndexByOffset(distance);
const index = getIndexByOffset(distance);
this.duration = +this.swipeDuration;
this.setIndex(index, true);
},
state.duration = +props.swipeDuration;
setIndex(index, true);
};
stopMomentum() {
this.moving = false;
this.duration = 0;
const stopMomentum = () => {
moving = false;
state.duration = 0;
if (this.transitionEndTrigger) {
this.transitionEndTrigger();
this.transitionEndTrigger = null;
if (transitionEndTrigger) {
transitionEndTrigger();
transitionEndTrigger = null;
}
},
};
genOptions() {
const onTouchStart = (event) => {
if (props.readonly) {
return;
}
touch.start(event);
if (moving) {
const translateY = getElementTranslateY(wrapper.value);
state.offset = Math.min(0, translateY - baseOffset());
startOffset = state.offset;
} else {
startOffset = state.offset;
}
state.duration = 0;
touchStartTime = Date.now();
momentumOffset = startOffset;
transitionEndTrigger = null;
};
const onTouchMove = (event) => {
if (props.readonly) {
return;
}
touch.move(event);
if (touch.isVertical()) {
moving = true;
preventDefault(event, true);
}
state.offset = range(
startOffset + touch.deltaY.value,
-(count() * props.itemHeight),
props.itemHeight
);
const now = Date.now();
if (now - touchStartTime > MOMENTUM_LIMIT_TIME) {
touchStartTime = now;
momentumOffset = state.offset;
}
};
const onTouchEnd = () => {
if (props.readonly) {
return;
}
const distance = state.offset - momentumOffset;
const duration = Date.now() - touchStartTime;
const allowMomentum =
duration < MOMENTUM_LIMIT_TIME &&
Math.abs(distance) > MOMENTUM_LIMIT_DISTANCE;
if (allowMomentum) {
momentum(distance, duration);
return;
}
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(() => {
moving = false;
}, 0);
};
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({
</li>
);
});
},
},
render() {
const wrapperStyle = {
transform: `translate3d(0, ${this.offset + this.baseOffset}px, 0)`,
transitionDuration: `${this.duration}ms`,
transitionProperty: this.duration ? 'all' : 'none',
};
return (
<div class={[bem(), this.className]}>
<ul
ref="wrapper"
style={wrapperStyle}
class={bem('wrapper')}
onTransitionend={this.onTransitionEnd}
>
{this.genOptions()}
</ul>
</div>
const setValue = (value) => {
const { options } = state;
for (let i = 0; i < options.length; i++) {
if (getOptionText(options[i]) === value) {
return setIndex(i);
}
}
};
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 (
<div
class={[bem(), props.className]}
onTouchstart={onTouchStart}
onTouchmove={onTouchMove}
onTouchend={onTouchEnd}
onTouchcancel={onTouchEnd}
>
<ul
ref={wrapper}
style={wrapperStyle}
class={bem('wrapper')}
onTransitionend={stopMomentum}
>
{renderOptions()}
</ul>
</div>
);
};
},
});

View File

@ -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;
};
emit(event) {
if (this.dataType === 'text') {
this.$emit(event, this.getColumnValue(0), this.getColumnIndex(0));
const format = () => {
const { columns } = props;
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;
}
},
};
onCascadeChange(columnIndex) {
let cursor = { children: this.columns };
const indexes = this.getIndexes();
// get indexes of all columns
const getIndexes = () => children.map((child) => child.state.index);
// 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;
},
const getColumnValues = (index) => (children[index] || {}).state.options;
// @exposed-api
// set options of column by index
setColumnValues(index, options) {
const column = this.children[index];
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');
},
genTitle() {
if (this.$slots.title) {
return this.$slots.title();
const onChange = (columnIndex) => {
if (dataType.value === 'cascade') {
onCascadeChange(columnIndex);
}
if (this.title) {
return <div class={['van-ellipsis', bem('title')]}>{this.title}</div>;
if (dataType.value === 'text') {
emit('change', getColumnValue(0), getColumnIndex(0));
} else {
emit('change', getValues(), columnIndex);
}
},
};
genToolbar() {
if (this.showToolbar) {
const confirm = () => {
children.forEach((child) => child.stopMomentum());
emitAction('confirm');
};
const cancel = () => {
emitAction('cancel');
};
const renderTitle = () => {
if (slots.title) {
return slots.title();
}
if (props.title) {
return <div class={[bem('title'), 'van-ellipsis']}>{props.title}</div>;
}
};
const renderToolbar = () => {
if (props.showToolbar) {
return (
<div class={bem('toolbar')}>
{this.$slots.default
? this.$slots.default()
{slots.default
? slots.default()
: [
<button
type="button"
class={bem('cancel')}
onClick={this.cancel}
>
{this.cancelButtonText || t('cancel')}
<button type="button" class={bem('cancel')} onClick={cancel}>
{props.cancelButtonText || t('cancel')}
</button>,
this.genTitle(),
renderTitle(),
<button
type="button"
class={bem('confirm')}
onClick={this.confirm}
onClick={confirm}
>
{this.confirmButtonText || t('confirm')}
{props.confirmButtonText || t('confirm')}
</button>,
]}
</div>
);
}
},
};
genColumns() {
const { itemPxHeight } = this;
const wrapHeight = itemPxHeight * this.visibleItemCount;
const renderColumnItems = () =>
formattedColumns.value.map((item, columnIndex) => (
<PickerColumn
readonly={props.readonly}
valueKey={props.valueKey}
allowHtml={props.allowHtml}
className={item.className}
itemHeight={itemHeight.value}
defaultIndex={item.defaultIndex ?? +props.defaultIndex}
swipeDuration={props.swipeDuration}
visibleItemCount={props.visibleItemCount}
initialOptions={item.values}
onChange={() => {
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()}
<div class={bem('mask')} style={maskStyle} />
<div
class={[BORDER_UNSET_TOP_BOTTOM, bem('frame')]}
@ -301,37 +277,34 @@ export default createComponent({
/>
</div>
);
},
};
genColumnItems() {
return this.formattedColumns.map((item, columnIndex) => (
<PickerColumn
readonly={this.readonly}
valueKey={this.valueKey}
allowHtml={this.allowHtml}
className={item.className}
itemHeight={this.itemPxHeight}
defaultIndex={item.defaultIndex ?? +this.defaultIndex}
swipeDuration={this.swipeDuration}
visibleItemCount={this.visibleItemCount}
initialOptions={item.values}
onChange={() => {
this.onChange(columnIndex);
}}
/>
));
},
},
provide(PICKER_KEY, { children });
render() {
return (
watch(() => props.columns, format, { immediate: true });
useExpose({
confirm,
getValues,
setValues,
getIndexes,
setIndexes,
getColumnIndex,
setColumnIndex,
getColumnValue,
setColumnValue,
getColumnValues,
setColumnValues,
});
return () => (
<div class={bem()}>
{this.toolbarPosition === 'top' ? this.genToolbar() : null}
{this.loading ? <Loading class={bem('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 ? <Loading class={bem('loading')} /> : null}
{slots['columns-top']?.()}
{renderColumns()}
{slots['columns-bottom']?.()}
{props.toolbarPosition === 'bottom' ? renderToolbar() : null}
</div>
);
},

View File

@ -8,6 +8,8 @@ export type SharedPickerProps = {
confirmButtonText?: string;
};
export const PICKER_KEY = 'vanPicker';
export const DEFAULT_ITEM_HEIGHT = 44;
export const pickerProps = {