Browse Source

feat: add picker

yangkaixuan 4 years ago
parent
commit
14f619d802

+ 11 - 0
src/config.js

@@ -165,6 +165,17 @@ module.exports = {
           sort: 4,
           show: true,
           author: 'wangyue217'
+        },
+        {
+          version: '3.0.0',
+          name: 'Picker',
+          type: 'component',
+          cName: '选择器',
+          desc:
+            '提供多个选型集合供用户选择,支持单列选择和多列级联,通常与弹出层配合使用',
+          sort: 5,
+          show: true,
+          author: 'yangkaixuan'
         }
       ]
     },

+ 279 - 0
src/packages/picker/Column.vue

@@ -0,0 +1,279 @@
+<template>
+  <view
+    class="nut-picker__content"
+    :style="{ height: height + 'px' }"
+    @touchstart="onTouchStart"
+    @touchmove="onTouchMove"
+    @touchend="onTouchEnd"
+    @touchcancel="onTouchEnd"
+    @transitionend="stopMomentum"
+  >
+    <view class="nut-picker__hairline" :style="{ top: ` ${top}px` }"></view>
+    <view
+      class="nut-picker__mask"
+      :style="{ backgroundSize: `100% ${top}px` }"
+    ></view>
+    <view class="nut-picker__wrapper" ref="wrapper" :style="wrapperStyle">
+      <view
+        class="nut-picker__item"
+        :key="index"
+        v-for="(item, index) in columns"
+        >{{ item }}</view
+      >
+    </view>
+  </view>
+</template>
+<script lang="ts">
+import { toRefs, reactive, ref, watch, computed } from 'vue';
+import { createComponent } from '@/utils/create';
+import { useTouch } from './use-touch';
+const MOMENTUM_LIMIT_DISTANCE = 15;
+const MOMENTUM_LIMIT_TIME = 300;
+const DEFAULT_DURATION = 200;
+const { componentName, create } = createComponent('picker');
+function range(num: number, min: number, max: number): number {
+  return Math.min(Math.max(num, min), max);
+}
+function preventDefault(event: Event, isStopPropagation?: boolean) {
+  /* istanbul ignore else */
+  if (typeof event.cancelable !== 'boolean' || event.cancelable) {
+    event.preventDefault();
+  }
+
+  if (isStopPropagation) {
+    stopPropagation(event);
+  }
+}
+
+function stopPropagation(event: Event) {
+  event.stopPropagation();
+}
+function getElementTranslateY(element) {
+  const style = window.getComputedStyle(element);
+  const transform = style.transform || style.webkitTransform;
+  const translateY = transform.slice(7, transform.length - 1).split(', ')[5];
+  return Number(translateY);
+}
+export function isObject(val: unknown): val is Record<any, any> {
+  return val !== null && typeof val === 'object';
+}
+
+function isOptionDisabled(option) {
+  return isObject(option) && option.disabled;
+}
+export default create({
+  props: {
+    show: {
+      type: Boolean,
+      default: false
+    },
+    readonly: {
+      type: Boolean,
+      default: false
+    },
+    txt: {
+      type: String,
+      default: ''
+    },
+    visibleItemCount: {
+      type: [Number],
+      default: 7
+    },
+    defaultIndex: {
+      type: [Number, String],
+      default: 0
+    },
+    itemHeight: {
+      type: [Number],
+      default: 35
+    },
+    initialOptions: {
+      type: Array,
+      default: () => [
+        1,
+        2,
+        3,
+        4,
+        5,
+        6,
+        7,
+        8,
+        9,
+        11,
+        22,
+        33,
+        44,
+        55,
+        66,
+        77,
+        8,
+        9
+      ]
+    }
+  },
+  components: {},
+  emits: ['click', 'close'],
+
+  setup(props, { emit }) {
+    let moving;
+    let startOffset, touchStartTime, momentumOffset, transitionEndTrigger;
+
+    const _show = ref(false);
+
+    const state = reactive({
+      index: props.defaultIndex,
+      offset: 0,
+      duration: 0,
+      options: props.initialOptions
+    });
+    const baseOffset = () =>
+      (props.itemHeight * (props.visibleItemCount - 1)) / 2;
+    const count = () => state.options.length;
+    const momentum = (distance, duration) => {
+      const speed = Math.abs(distance / duration);
+
+      distance = state.offset + (speed / 0.003) * (distance < 0 ? -1 : 1);
+
+      const index = getIndexByOffset(distance);
+
+      setIndex(index, true);
+    };
+    watch(
+      () => props.show,
+      val => {
+        _show.value = val;
+      }
+    );
+    const stopMomentum = () => {
+      moving = false;
+      state.duration = 0;
+    };
+    const wrapper = ref();
+    const touch = useTouch();
+    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;
+      }
+    };
+    const setIndex = (index, emitChange) => {
+      index = adjustIndex(index) || 0;
+
+      const offset = -index * props.itemHeight;
+      const trigger = () => {
+        if (index !== state.index) {
+          state.index = index;
+
+          if (emitChange) {
+            // emit('change', index);
+          }
+        }
+      };
+
+      // trigger the change event after transitionend when moving
+      if (moving && offset !== state.offset) {
+        transitionEndTrigger = trigger;
+      } else {
+        trigger();
+      }
+
+      state.offset = offset;
+    };
+    const getIndexByOffset = offset =>
+      range(Math.round(-offset / props.itemHeight), 0, count() - 1);
+    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;
+      }
+      moving = true;
+      touch.move(event);
+
+      if (touch.isVertical()) {
+        moving = true;
+        // preventDefault(event, true);
+      }
+
+      const moveOffset = startOffset + touch.deltaY.value;
+      if (moveOffset > props.itemHeight) {
+        state.offset = props.itemHeight;
+      } else {
+        state.offset = startOffset + touch.deltaY.value;
+      }
+
+      const now = Date.now();
+
+      if (now - touchStartTime > MOMENTUM_LIMIT_TIME) {
+        touchStartTime = now;
+        momentumOffset = state.offset;
+      }
+    };
+    const onTouchEnd = () => {
+      const index = getIndexByOffset(state.offset);
+      state.duration = DEFAULT_DURATION;
+      setIndex(index, true);
+      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 handleClick = (event: Event) => {
+      emit('click', event);
+    };
+    const wrapperStyle = computed(() => ({
+      transform: `translate3d(0, ${state.offset + baseOffset()}px, 0)`,
+      transitionDuration: `${state.duration}ms`,
+      transitionProperty: state.duration ? 'all' : 'none'
+    }));
+    return {
+      show: _show,
+      wrapper,
+      onTouchStart,
+      onTouchMove,
+      onTouchEnd,
+      wrapperStyle,
+      state,
+      stopMomentum,
+      columns: props.initialOptions,
+      top: (Number(props.visibleItemCount - 1) / 2) * props.itemHeight,
+      height: Number(props.visibleItemCount) * props.itemHeight,
+      close: () => {
+        emit('close');
+      }
+    };
+  }
+});
+</script>
+
+<style lang="scss">
+@import 'index.scss';
+</style>

+ 38 - 0
src/packages/picker/demo.vue

@@ -0,0 +1,38 @@
+<template>
+  <div class="demo">
+    <h2>基础用法</h2>
+    <nut-cell title="请选择城市" desc="北京" @click="sss"></nut-cell>
+    <h2>多列样式</h2>
+    <nut-cell title="请选择时间" desc="周三 下午" @click="sss"></nut-cell>
+    <h2>多级联动</h2>
+    <nut-cell title="请选择时间" desc="北京 朝阳" @click="sss"></nut-cell>
+    <h2>带标题的样式</h2>
+    <nut-cell title="请选择时间" desc="请选择城市" @click="sss"></nut-cell>
+    <nut-picker :show="show" @close="close"></nut-picker>
+  </div>
+</template>
+<script lang="ts">
+import { toRefs, ref } from 'vue';
+import { createComponent } from '@/utils/create';
+const { createDemo } = createComponent('picker');
+export default createDemo({
+  props: {},
+  setup() {
+    const show = ref(false);
+    return {
+      show,
+      close: () => {
+        show.value = false;
+      },
+      sss: () => {
+        show.value = true;
+      }
+    };
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.demo {
+}
+</style>

+ 32 - 0
src/packages/picker/doc.md

@@ -0,0 +1,32 @@
+#  picker组件
+
+### 介绍
+    
+基于 xxxxxxx
+    
+## 安装
+    
+    
+    
+## 代码演示
+    
+### 基础用法1
+    
+
+    
+## API
+    
+### Props
+    
+| 参数         | 说明                             | 类型   | 默认值           |
+|--------------|----------------------------------|--------|------------------|
+| name         | 图标名称或图片链接               | String | -                |
+| color        | 图标颜色                         | String | -                |
+| size         | 图标大小,如 '20px' '2em' '2rem' | String | -                |
+   
+### Events
+    
+| 事件名 | 说明           | 回调参数     |
+|--------|----------------|--------------|
+| click  | 点击图标时触发 | event: Event |
+    

+ 58 - 0
src/packages/picker/index.scss

@@ -0,0 +1,58 @@
+.nut-picker {
+  &__content {
+    position: relative;
+    text-align: center;
+    overflow-y: hidden;
+    flex-grow: 1;
+    &:hover {
+      cursor: grab;
+    }
+  }
+  &__mask {
+    position: absolute;
+    top: 0;
+    left: 0;
+    z-index: 1;
+    width: 100%;
+    height: 100%;
+    background-image: linear-gradient(
+        180deg,
+        hsla(0, 0%, 100%, 0.9),
+        hsla(0, 0%, 100%, 0.4)
+      ),
+      linear-gradient(0deg, hsla(0, 0%, 100%, 0.9), hsla(0, 0%, 100%, 0.4));
+    background-repeat: no-repeat;
+    background-position: top, bottom;
+    -webkit-transform: translateZ(0);
+    transform: translateZ(0);
+    pointer-events: none;
+  }
+  &__bar {
+    display: flex;
+    height: 56px;
+    align-items: center;
+    justify-content: space-between;
+    padding: 15px;
+  }
+  &__column {
+    display: flex;
+  }
+  &__left {
+    color: #fa2c19;
+    font-size: 16px;
+  }
+  &__item {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    height: 35px;
+  }
+  &__hairline {
+    position: absolute;
+    height: 35px;
+    width: 100%;
+    border: 1px solid #d8d8d8;
+    border-left: 0;
+    border-right: 0;
+  }
+}

+ 276 - 0
src/packages/picker/index.vue

@@ -0,0 +1,276 @@
+<template>
+  <view>
+    <nut-popup
+      position="bottom"
+      :style="{ height: height + 56 + 'px' }"
+      v-model:show="show"
+      @close="close"
+    >
+      <view class="nut-picker__bar">
+        <view class="nut-picker__left"> 取消</view>
+        <view> 城市选择</view>
+        <view> 确定</view>
+      </view>
+      <view class="nut-picker__column">
+        <column></column>
+        <column></column>
+      </view>
+    </nut-popup>
+  </view>
+</template>
+<script lang="ts">
+import { toRefs, reactive, ref, watch, computed } from 'vue';
+import { createComponent } from '@/utils/create';
+import { useTouch } from './use-touch';
+import column from './Column.vue';
+const MOMENTUM_LIMIT_DISTANCE = 15;
+const MOMENTUM_LIMIT_TIME = 300;
+const DEFAULT_DURATION = 200;
+const { componentName, create } = createComponent('picker');
+function range(num: number, min: number, max: number): number {
+  return Math.min(Math.max(num, min), max);
+}
+function preventDefault(event: Event, isStopPropagation?: boolean) {
+  /* istanbul ignore else */
+  if (typeof event.cancelable !== 'boolean' || event.cancelable) {
+    event.preventDefault();
+  }
+
+  if (isStopPropagation) {
+    stopPropagation(event);
+  }
+}
+
+function stopPropagation(event: Event) {
+  event.stopPropagation();
+}
+function getElementTranslateY(element) {
+  const style = window.getComputedStyle(element);
+  const transform = style.transform || style.webkitTransform;
+  const translateY = transform.slice(7, transform.length - 1).split(', ')[5];
+  return Number(translateY);
+}
+export function isObject(val: unknown): val is Record<any, any> {
+  return val !== null && typeof val === 'object';
+}
+
+function isOptionDisabled(option) {
+  return isObject(option) && option.disabled;
+}
+export default create({
+  props: {
+    show: {
+      type: Boolean,
+      default: false
+    },
+    readonly: {
+      type: Boolean,
+      default: false
+    },
+    txt: {
+      type: String,
+      default: ''
+    },
+    visibleItemCount: {
+      type: [Number],
+      default: 7
+    },
+    defaultIndex: {
+      type: [Number, String],
+      default: 0
+    },
+    itemHeight: {
+      type: [Number],
+      default: 35
+    },
+    initialOptions: {
+      type: Array,
+      default: () => [
+        1,
+        2,
+        3,
+        4,
+        5,
+        6,
+        7,
+        8,
+        9,
+        11,
+        22,
+        33,
+        44,
+        55,
+        66,
+        77,
+        8,
+        9
+      ]
+    }
+  },
+  components: { column },
+  emits: ['click', 'close'],
+
+  setup(props, { emit }) {
+    let moving;
+    let startOffset, touchStartTime, momentumOffset, transitionEndTrigger;
+
+    const _show = ref(false);
+
+    const state = reactive({
+      index: props.defaultIndex,
+      offset: 0,
+      duration: 0,
+      options: props.initialOptions
+    });
+    const baseOffset = () =>
+      (props.itemHeight * (props.visibleItemCount - 1)) / 2;
+    const count = () => state.options.length;
+    const momentum = (distance, duration) => {
+      const speed = Math.abs(distance / duration);
+
+      distance = state.offset + (speed / 0.003) * (distance < 0 ? -1 : 1);
+
+      const index = getIndexByOffset(distance);
+
+      setIndex(index, true);
+    };
+    watch(
+      () => props.show,
+      val => {
+        _show.value = val;
+      }
+    );
+    const stopMomentum = () => {
+      moving = false;
+      state.duration = 0;
+    };
+    const wrapper = ref();
+    const touch = useTouch();
+    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;
+      }
+    };
+    const setIndex = (index, emitChange) => {
+      index = adjustIndex(index) || 0;
+
+      const offset = -index * props.itemHeight;
+      const trigger = () => {
+        if (index !== state.index) {
+          state.index = index;
+
+          if (emitChange) {
+            // emit('change', index);
+          }
+        }
+      };
+
+      // trigger the change event after transitionend when moving
+      if (moving && offset !== state.offset) {
+        transitionEndTrigger = trigger;
+      } else {
+        trigger();
+      }
+
+      state.offset = offset;
+    };
+    const getIndexByOffset = offset =>
+      range(Math.round(-offset / props.itemHeight), 0, count() - 1);
+    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;
+      }
+      moving = true;
+      touch.move(event);
+
+      if (touch.isVertical()) {
+        moving = true;
+        // preventDefault(event, true);
+      }
+
+      const moveOffset = startOffset + touch.deltaY.value;
+      if (moveOffset > props.itemHeight) {
+        state.offset = props.itemHeight;
+      } else {
+        state.offset = startOffset + touch.deltaY.value;
+      }
+
+      const now = Date.now();
+
+      if (now - touchStartTime > MOMENTUM_LIMIT_TIME) {
+        touchStartTime = now;
+        momentumOffset = state.offset;
+      }
+    };
+    const onTouchEnd = () => {
+      const index = getIndexByOffset(state.offset);
+      state.duration = DEFAULT_DURATION;
+      setIndex(index, true);
+      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 handleClick = (event: Event) => {
+      emit('click', event);
+    };
+    const wrapperStyle = computed(() => ({
+      transform: `translate3d(0, ${state.offset + baseOffset()}px, 0)`,
+      transitionDuration: `${state.duration}ms`,
+      transitionProperty: state.duration ? 'all' : 'none'
+    }));
+    return {
+      show: _show,
+      wrapper,
+      onTouchStart,
+      onTouchMove,
+      onTouchEnd,
+      wrapperStyle,
+      state,
+      column,
+      stopMomentum,
+      columns: props.initialOptions,
+      top: (Number(props.visibleItemCount - 1) / 2) * props.itemHeight,
+      height: Number(props.visibleItemCount) * props.itemHeight,
+      close: () => {
+        emit('close');
+      }
+    };
+  }
+});
+</script>
+
+<style lang="scss">
+@import 'index.scss';
+</style>

+ 69 - 0
src/packages/picker/use-touch.ts

@@ -0,0 +1,69 @@
+import { ref } from 'vue';
+
+const MIN_DISTANCE = 10;
+
+type Direction = '' | 'vertical' | 'horizontal';
+
+function getDirection(x: number, y: number) {
+  if (x > y && x > MIN_DISTANCE) {
+    return 'horizontal';
+  }
+  if (y > x && y > MIN_DISTANCE) {
+    return 'vertical';
+  }
+  return '';
+}
+
+export function useTouch() {
+  const startX = ref(0);
+  const startY = ref(0);
+  const deltaX = ref(0);
+  const deltaY = ref(0);
+  const offsetX = ref(0);
+  const offsetY = ref(0);
+  const direction = ref<Direction>('');
+
+  const isVertical = () => direction.value === 'vertical';
+  const isHorizontal = () => direction.value === 'horizontal';
+
+  const reset = () => {
+    deltaX.value = 0;
+    deltaY.value = 0;
+    offsetX.value = 0;
+    offsetY.value = 0;
+    direction.value = '';
+  };
+
+  const start = ((event: TouchEvent) => {
+    reset();
+    startX.value = event.touches[0].clientX;
+    startY.value = event.touches[0].clientY;
+  }) as EventListener;
+
+  const move = ((event: TouchEvent) => {
+    const touch = event.touches[0];
+    deltaX.value = touch.clientX - startX.value;
+    deltaY.value = touch.clientY - startY.value;
+    offsetX.value = Math.abs(deltaX.value);
+    offsetY.value = Math.abs(deltaY.value);
+
+    if (!direction.value) {
+      direction.value = getDirection(offsetX.value, offsetY.value);
+    }
+  }) as EventListener;
+
+  return {
+    move,
+    start,
+    reset,
+    startX,
+    startY,
+    deltaX,
+    deltaY,
+    offsetX,
+    offsetY,
+    direction,
+    isVertical,
+    isHorizontal
+  };
+}