Browse Source

fix: Tab title bar supports automatic scrolling with content (#1950)

* feat(tabs): title bar supports automatic scrolling

* feat(tabs): add name prop in taro demo

* docs(tabs): update docs

* feat(tabs): change timerFunc
gyt95 3 years ago
parent
commit
9a7027cf05

+ 40 - 1
src/packages/__VUE/tabs/common.ts

@@ -1,7 +1,8 @@
 import { pxCheck } from '@/packages/utils/pxCheck';
 import { TypeOfFun, getScrollTopRoot } from '@/packages/utils/util';
 import { useRect } from '@/packages/utils/useRect';
-import { onMounted, provide, VNode, ref, Ref, computed, onActivated, watch } from 'vue';
+import { onMounted, provide, VNode, ref, Ref, computed, onActivated, watch, nextTick } from 'vue';
+import raf from '@/packages/utils/raf';
 export class Title {
   title: string = '';
   titleSlot?: VNode[];
@@ -115,6 +116,36 @@ export const component = {
         currentIndex.value = index;
       }
     };
+
+    const navRef = ref<HTMLElement>();
+    const titleRef = ref([]) as Ref<HTMLElement[]>;
+    const scrollIntoView = (immediate?: boolean) => {
+      const nav = navRef.value;
+      const _titles = titleRef.value;
+      if (!nav || !_titles || !_titles[currentIndex.value]) {
+        return;
+      }
+      const title = _titles[currentIndex.value];
+      const to = title.offsetLeft - (nav.offsetWidth - title.offsetWidth) / 2;
+      scrollLeftTo(nav, to, immediate ? 0 : 0.3);
+    };
+
+    const scrollLeftTo = (nav: any, to: number, duration: number) => {
+      let count = 0;
+      const from = nav.scrollLeft;
+
+      const frames = duration === 0 ? 1 : Math.round((duration * 1000) / 16);
+
+      function animate() {
+        nav.scrollLeft += (to - from) / frames;
+
+        if (++count < frames) {
+          raf(animate);
+        }
+      }
+
+      animate();
+    };
     const init = (vnodes: VNode[] = slots.default?.()) => {
       titles.value = [];
       vnodes = vnodes?.filter((item) => typeof item.children !== 'string');
@@ -122,6 +153,9 @@ export const component = {
         renderTitles(vnodes);
       }
       findTabsIndex(props.modelValue);
+      nextTick(() => {
+        scrollIntoView();
+      });
     };
     const onStickyScroll = (params: { top: number; fixed: boolean }) => {
       stickyFixed = params.fixed;
@@ -138,6 +172,7 @@ export const component = {
       () => props.modelValue,
       (value: string | number) => {
         findTabsIndex(value);
+        scrollIntoView();
         if (stickyFixed) {
           let top = useRect(container.value!).top + getScrollTopRoot();
           let value = Math.ceil(top - props.top);
@@ -185,9 +220,13 @@ export const component = {
         currentIndex.value = index;
         emit('update:modelValue', item.paneKey);
         emit('change', item);
+      },
+      setTabItemRef: (el: HTMLElement, index: number) => {
+        titleRef.value[index] = el;
       }
     };
     return {
+      navRef,
       titles,
       contentStyle,
       tabsNavStyle,

+ 7 - 1
src/packages/__VUE/tabs/doc.en-US.md

@@ -173,10 +173,12 @@ export default {
 
 ### A large number of scrolling operations
 
+In the `taro` environment, when multiple `tabs` are included in the same page, `name` needs to be set as a unique identifier to enable the automatic scrolling function of the title bar.
+
 :::demo
 ```html
 <template>
-<nut-tabs v-model="state.tab4value" title-scroll title-gutter="10">
+<nut-tabs v-model="state.tab4value" title-scroll title-gutter="10" name="tab4value">
   <nut-tabpane v-for="item in state.list4" :title="'Tab '+ item">
     Tab {{item}}
   </nut-tabpane>
@@ -350,6 +352,10 @@ export default {
 | auto-height`v3.1.21` | Automatic height. When set to `true`, `nut-tabs` and `nut-tabs__content` will change with the height of the current `nut-tabpane`. | boolean       | false      |
 | sticky`v3.2.3` `applet not supported` | Whether to use sticky mode| boolean       | false      |
 | top`v3.2.3` `applet not supported` | Sticky offset top | number       | 0      |
+| name        | In the `taro` environment, when multiple `tabs` are included in the same page, `name` needs to be set as a unique identifier to enable the automatic scrolling function of the title bar.                              | string | ''        |
+
+
+
 
 ### Tabs Slots
 

+ 4 - 1
src/packages/__VUE/tabs/doc.md

@@ -173,10 +173,12 @@ export default {
 
 ### 数量多,滚动操作
 
+在`taro`环境下,当同一页面中包含多个`tabs`时,需要设置`name`作为唯一标识符来开启标题栏自动滚动功能。
+
 :::demo
 ```html
 <template>
-<nut-tabs v-model="state.tab4value" title-scroll title-gutter="10">
+<nut-tabs v-model="state.tab4value" title-scroll title-gutter="10" name="tab4value">
   <nut-tabpane v-for="item in state.list4" :title="'Tab '+ item">
     Tab {{item}}
   </nut-tabpane>
@@ -350,6 +352,7 @@ export default {
 | auto-height`v3.1.21` | 自动高度。设置为 true 时,nut-tabs 和 nut-tabs__content 会随着当前 nut-tabpane 的高度而发生变化。 | boolean       | false      |
 | sticky`v3.2.3` `小程序不支持` | 是否使用粘性布局 | boolean       | false      |
 | top`v3.2.3` `小程序不支持` | 粘性布局下的吸顶距离 | number       | 0      |
+| name        | 在`taro`环境下,当同一页面中包含多个`tabs`时,需要设置`name`作为唯一标识符来开启标题栏自动滚动功能。                              | string | ''        |
 
 ### Tabs Slots
 

+ 310 - 5
src/packages/__VUE/tabs/index.taro.vue

@@ -1,9 +1,15 @@
 <template>
   <view class="nut-tabs" :class="[direction]" ref="container" id="container">
-    <view
-      class="nut-tabs__titles"
+    <Nut-Scroll-View
+      :scroll-x="true"
+      :scroll-with-animation="scrollWithAnimation"
+      :scroll-left="scrollLeft"
+      :enable-flex="true"
+      :id="`nut-tabs__titles_${name}`"
+      class="nut-tabs__titles tabs-scrollview"
       :class="{ [type]: type, scrollable: titleScroll, [size]: size }"
       :style="tabsNavStyle"
+      @scroll="handleScroll"
     >
       <slot v-if="$slots.titles" name="titles"></slot>
       <template v-else>
@@ -22,7 +28,7 @@
           <view class="nut-tabs__titles-item__text" :class="{ ellipsis: ellipsis }">{{ item.title }} </view>
         </view>
       </template>
-    </view>
+    </Nut-Scroll-View>
     <view class="nut-tabs__content" :style="contentStyle">
       <slot name="default"></slot>
     </view>
@@ -30,7 +36,306 @@
 </template>
 <script lang="ts">
 import { createComponent } from '@/packages/utils/create';
-import { component } from './common';
+import { pxCheck } from '@/packages/utils/pxCheck';
+import { TypeOfFun, getScrollTopRoot } from '@/packages/utils/util';
+import { useRect } from '@/packages/utils/useRect';
+import NutScrollView from '../scrollView/index.taro.vue';
+import { onMounted, provide, VNode, ref, Ref, computed, onActivated, watch, nextTick } from 'vue';
+import raf from '@/packages/utils/raf';
+import Taro from '@tarojs/taro';
+export class Title {
+  title: string = '';
+  titleSlot?: VNode[];
+  paneKey: string = '';
+  disabled: boolean = false;
+  constructor() {}
+}
+export type TabsSize = 'large' | 'normal' | 'small';
 const { create } = createComponent('tabs');
-export default create(component);
+export default create({
+  props: {
+    modelValue: {
+      type: [String, Number],
+      default: 0
+    },
+    color: {
+      type: String,
+      default: ''
+    },
+    direction: {
+      type: String,
+      default: 'horizontal' //vertical
+    },
+    size: {
+      type: String as import('vue').PropType<TabsSize>,
+      default: 'normal'
+    },
+    type: {
+      type: String,
+      default: 'line' //card、line、smile
+    },
+    titleScroll: {
+      type: Boolean,
+      default: false
+    },
+    ellipsis: {
+      type: Boolean,
+      default: true
+    },
+    autoHeight: {
+      type: Boolean,
+      default: false
+    },
+    background: {
+      type: String,
+      default: ''
+    },
+    animatedTime: {
+      type: [Number, String],
+      default: 300
+    },
+    titleGutter: {
+      type: [Number, String],
+      default: 0
+    },
+    sticky: {
+      type: Boolean,
+      default: false
+    },
+    top: {
+      type: Number,
+      default: 0
+    },
+    name: {
+      type: String,
+      default: ''
+    }
+  },
+
+  components: {
+    NutScrollView
+  },
+  emits: ['update:modelValue', 'click', 'change'],
+
+  setup(props: any, { emit, slots }: any) {
+    const container = ref(null);
+    let stickyFixed: boolean;
+    provide('activeKey', { activeKey: computed(() => props.modelValue) });
+    provide('autoHeight', { autoHeight: computed(() => props.autoHeight) });
+    const titles: Ref<Title[]> = ref([]);
+    const renderTitles = (vnodes: VNode[]) => {
+      vnodes.forEach((vnode: VNode, index: number) => {
+        let type = vnode.type;
+        type = (type as any).name || type;
+        if (type == 'nut-tabpane') {
+          let title = new Title();
+          if (vnode.props?.title || vnode.props?.['pane-key'] || vnode.props?.['paneKey']) {
+            let paneKeyType = TypeOfFun(vnode.props?.['pane-key']);
+            let paneIndex =
+              paneKeyType == 'number' || paneKeyType == 'string' ? String(vnode.props?.['pane-key']) : null;
+            let camelPaneKeyType = TypeOfFun(vnode.props?.['paneKey']);
+            let camelPaneIndex =
+              camelPaneKeyType == 'number' || camelPaneKeyType == 'string' ? String(vnode.props?.['paneKey']) : null;
+            title.title = vnode.props?.title;
+            title.paneKey = paneIndex || camelPaneIndex || String(index);
+            title.disabled = vnode.props?.disabled;
+          } else {
+            // title.titleSlot = vnode.children?.title() as VNode[];
+          }
+          titles.value.push(title);
+        } else {
+          if (vnode.children == ' ') {
+            return;
+          }
+          renderTitles(vnode.children as VNode[]);
+        }
+      });
+    };
+
+    const currentIndex = ref((props.modelValue as number) || 0);
+    const findTabsIndex = (value: string | number) => {
+      let index = titles.value.findIndex((item) => item.paneKey == value);
+      if (titles.value.length == 0) {
+        console.error('[NutUI] <Tabs> 当前未找到 TabPane 组件元素 , 请检查 .');
+      } else if (index == -1) {
+        console.error('[NutUI] <Tabs> 请检查 v-model 值是否为 paneKey ,如 paneKey 未设置,请采用下标控制 .');
+      } else {
+        currentIndex.value = index;
+      }
+    };
+
+    const titleRef = ref([]) as Ref<HTMLElement[]>;
+    const scrollLeft = ref(0);
+    const scrollWithAnimation = ref(true);
+    const getRect = (selector: string) => {
+      return new Promise((resolve) => {
+        Taro.createSelectorQuery()
+          .select(selector)
+          .boundingClientRect()
+          .exec((rect = []) => {
+            console.log(rect);
+            resolve(rect[0]);
+          });
+      });
+    };
+    const getAllRect = (selector: string) => {
+      return new Promise((resolve) => {
+        Taro.createSelectorQuery()
+          .selectAll(selector)
+          .boundingClientRect()
+          .exec((rect = []) => resolve(rect[0]));
+      });
+    };
+    const inited = ref(false);
+    const navRectRef = ref();
+    const titleRectRef = ref([]);
+    const scrollIntoView = () => {
+      raf(() => {
+        Promise.all([
+          getRect(`#nut-tabs__titles_${props.name}`),
+          getAllRect(`#nut-tabs__titles_${props.name} .nut-tabs__titles-item`)
+        ]).then(([navRect, titleRects]) => {
+          if (!inited.value) {
+            navRectRef.value = navRect;
+            titleRectRef.value = titleRects;
+            inited.value = true;
+          }
+
+          const titleRect = titleRectRef.value[currentIndex.value];
+
+          let to = titleRect.left - (navRectRef.value.width - titleRect.width) / 2;
+
+          nextTick(() => {
+            scrollWithAnimation.value = true;
+          });
+
+          scrollLeftTo(to);
+        });
+      });
+    };
+
+    const scrollLeftTo = (to: number) => {
+      let count = 0;
+      const from = scrollLeft.value;
+
+      const frames = 1;
+
+      function animate() {
+        scrollLeft.value += (to - from) / frames;
+
+        if (++count < frames) {
+          raf(animate);
+        }
+      }
+
+      animate();
+    };
+    const init = (vnodes: VNode[] = slots.default?.()) => {
+      titles.value = [];
+      vnodes = vnodes?.filter((item) => typeof item.children !== 'string');
+      if (vnodes && vnodes.length) {
+        renderTitles(vnodes);
+      }
+      findTabsIndex(props.modelValue);
+      setTimeout(() => {
+        scrollIntoView();
+      }, 500);
+    };
+    const onStickyScroll = (params: { top: number; fixed: boolean }) => {
+      stickyFixed = params.fixed;
+    };
+
+    watch(
+      () => slots.default?.(),
+      (vnodes: VNode[]) => {
+        init(vnodes);
+      }
+    );
+
+    watch(
+      () => props.modelValue,
+      (value: string | number) => {
+        findTabsIndex(value);
+        scrollIntoView();
+        if (stickyFixed) {
+          let top = useRect(container.value!).top + getScrollTopRoot();
+          let value = Math.ceil(top - props.top);
+          window.scrollTo({
+            top: value,
+            behavior: 'smooth'
+          });
+        }
+      }
+    );
+    onMounted(init);
+    onActivated(init);
+    const contentStyle = computed(() => {
+      return {
+        transform:
+          props.direction == 'horizontal'
+            ? `translate3d(-${currentIndex.value * 100}%, 0, 0)`
+            : `translate3d( 0,-${currentIndex.value * 100}%, 0)`,
+        transitionDuration: `${props.animatedTime}ms`
+      };
+    });
+    const tabsNavStyle = computed(() => {
+      return {
+        background: props.background
+      };
+    });
+    const tabsActiveStyle = computed(() => {
+      return {
+        color: props.type == 'smile' ? props.color : '',
+        background: props.type == 'line' ? props.color : ''
+      };
+    });
+    const titleStyle = computed(() => {
+      return {
+        marginLeft: pxCheck(props.titleGutter),
+        marginRight: pxCheck(props.titleGutter)
+      };
+    });
+    const methods = {
+      tabChange: (item: Title, index: number) => {
+        emit('click', item);
+        if (item.disabled) {
+          return;
+        }
+        currentIndex.value = index;
+        emit('update:modelValue', item.paneKey);
+        emit('change', item);
+      },
+      setTabItemRef: (el: HTMLElement, index: number) => {
+        titleRef.value[index] = el;
+      }
+    };
+
+    const handleScroll = (e: any) => {
+      console.log(e);
+    };
+    return {
+      titles,
+      contentStyle,
+      tabsNavStyle,
+      titleStyle,
+      tabsActiveStyle,
+      container,
+      scrollLeft,
+      scrollWithAnimation,
+      onStickyScroll,
+      handleScroll,
+      ...methods
+    };
+  }
+});
 </script>
+<style lang="less">
+.tabs-scrollview {
+  white-space: nowrap;
+}
+.nut-tabs__titles-item {
+  display: inline-block !important;
+  height: 46px;
+  line-height: 46px;
+}
+</style>

+ 3 - 0
src/packages/__VUE/tabs/index.vue

@@ -6,6 +6,7 @@
           class="nut-tabs__titles"
           :class="{ [type]: type, scrollable: titleScroll, [size]: size }"
           :style="tabsNavStyle"
+          ref="navRef"
         >
           <slot v-if="$slots.titles" name="titles"></slot>
           <template v-else>
@@ -32,6 +33,7 @@
         class="nut-tabs__titles"
         :class="{ [type]: type, scrollable: titleScroll, [size]: size }"
         :style="tabsNavStyle"
+        ref="navRef"
       >
         <slot v-if="$slots.titles" name="titles"></slot>
         <template v-else>
@@ -42,6 +44,7 @@
             :class="{ active: item.paneKey == modelValue, disabled: item.disabled }"
             v-for="(item, index) in titles"
             :key="item.paneKey"
+            :ref="(e) => setTabItemRef(e as HTMLElement, index)"
           >
             <view class="nut-tabs__titles-item__line" :style="tabsActiveStyle" v-if="type == 'line'"></view>
             <view class="nut-tabs__titles-item__smile" :style="tabsActiveStyle" v-if="type == 'smile'">

+ 1 - 1
src/sites/mobile-taro/vue/src/nav/pages/tabs/index.vue

@@ -35,7 +35,7 @@
     </nut-tabs>
 
     <h2>数量多,滚动操作</h2>
-    <nut-tabs v-model="state.tab4value" title-scroll title-gutter="10">
+    <nut-tabs v-model="state.tab4value" title-scroll title-gutter="10" name="tab4value">
       <nut-tabpane v-for="item in state.list4" :title="'Tab ' + item"> Tab {{ item }} </nut-tabpane>
     </nut-tabs>
     <h2>左右布局</h2>