Browse Source

feat(sticky): 新增粘性布局组件

suzigang 3 years ago
parent
commit
98a04a9e15

+ 11 - 0
src/config.json

@@ -197,6 +197,17 @@
           "taro": false,
           "tarodoc": true,
           "author": "zongyue3"
+        },
+        {
+          "version": "3.0.0",
+          "name": "Sticky",
+          "cType": "布局组件",
+          "cName": "粘性布局",
+          "type": "component",
+          "desc": "当组件在屏幕范围内时,会按照正常的布局排列,当组件滚出屏幕范围时,始终会固定在距离屏幕固定的距离处",
+          "show": true,
+          "tarodoc": false,
+          "author": "szg2008"
         }
       ]
     },

+ 1 - 0
src/packages/__VUE/sticky/__tests__/sticky.spec.ts

@@ -0,0 +1 @@
+import { mount } from '@vue/test-utils';

+ 57 - 0
src/packages/__VUE/sticky/demo.vue

@@ -0,0 +1,57 @@
+<template>
+  <div class="demo">
+    <h2>基础用法</h2>
+    <nut-cell>
+      <nut-sticky top="57">
+        <nut-button type="primary">吸顶按钮</nut-button>
+      </nut-sticky>
+    </nut-cell>
+    <h2>吸顶距离</h2>
+    <nut-cell>
+      <nut-sticky top="120">
+        <nut-button type="primary">吸顶距离120px</nut-button>
+      </nut-sticky>
+    </nut-cell>
+    <h2>指定容器</h2>
+    <div class="sticky-container" ref="container">
+      <nut-sticky top="100" :container="container" z-index="1">
+        <nut-button type="info">指定容器</nut-button>
+      </nut-sticky>
+    </div>
+    <h2>吸底距离</h2>
+    <nut-cell>
+      <nut-sticky bottom="100" position="bottom">
+        <nut-button type="primary">吸底距离100px</nut-button>
+      </nut-sticky>
+    </nut-cell>
+  </div>
+</template>
+<script lang="ts">
+import { ref } from 'vue';
+import { createComponent } from '../../utils/create';
+const { createDemo } = createComponent('sticky');
+export default createDemo({
+  props: {},
+  setup() {
+    const container = ref(null);
+    return {
+      container
+    };
+  }
+});
+</script>
+<style lang="scss" scoped>
+.demo {
+  height: 200vh !important;
+}
+.sticky-container {
+  width: 100%;
+  height: 300px;
+  background-color: #fff;
+}
+</style>
+<style lang="scss">
+#app {
+  height: auto !important;
+}
+</style>

+ 162 - 0
src/packages/__VUE/sticky/doc.md

@@ -0,0 +1,162 @@
+# Sticky 粘性布局
+
+### 介绍
+
+效果同 `css` 中的 `position: sticky`,对低端浏览器可使用其做兼容
+
+### 安装
+
+```javascript
+
+import { createApp } from 'vue';
+// vue
+import { Sticky } from '@nutui/nutui';
+// taro
+import { Sticky } from '@nutui/nutui-taro';
+
+const app = createApp();
+app.use(Sticky);
+
+```
+
+### 基础用法
+
+:::demo
+
+```html
+<template>
+  <nut-cell>
+    <nut-sticky top="57">
+      <nut-button type="primary">吸顶按钮</nut-button>
+    </nut-sticky>
+  </nut-cell>
+</template>
+<script lang="ts">
+  export default {
+    setup() {
+      return {  };
+    }
+  };
+</script>
+<style lang="scss">
+#app{
+  height: auto !important;
+}
+</style>
+```
+
+:::
+
+### 吸顶距离
+
+:::demo
+
+```html
+<template>
+  <nut-cell>
+    <nut-sticky top="120">
+      <nut-button type="primary">吸顶距离120px</nut-button>
+    </nut-sticky>
+  </nut-cell>
+</template>
+<script lang="ts">
+  export default {
+    setup() {
+      return {  };
+    }
+  };
+</script>
+<style lang="scss">
+#app{
+  height: auto !important;
+}
+</style>
+```
+
+:::
+
+### 指定容器
+
+:::demo
+
+```html
+<template>
+  <div class="sticky-container" ref="container">
+    <nut-sticky top="100" :container="container" z-index="1">
+      <nut-button type="info">指定容器</nut-button>
+    </nut-sticky>
+  </div>
+</template>
+<script lang="ts">
+  import { ref } from 'vue';
+  export default {
+    setup() {
+      const container = ref(null);
+      return {
+        container
+      };
+    }
+  };
+</script>
+<style lang="scss" scoped>
+.sticky-container{
+  width: 100%;
+  height: 300px;
+  background-color: #fff;
+}
+</style>
+<style lang="scss">
+#app{
+  height: auto !important;
+}
+</style>
+```
+
+:::
+
+### 吸底距离
+
+:::demo
+
+```html
+<template>
+  <nut-cell>
+    <nut-sticky bottom="100" position="bottom">
+      <nut-button type="primary">吸底距离100px</nut-button>
+    </nut-sticky>
+  </nut-cell>
+</template>
+<script lang="ts">
+  export default {
+    setup() {
+      return {  };
+    }
+  };
+</script>
+<style lang="scss">
+#app{
+  height: auto !important;
+}
+</style>
+```
+
+:::
+
+## API
+
+### Props
+
+| 参数         | 说明                             | 类型   | 默认值           |
+|--------------|----------------------------------|--------|------------------|
+| position         | 吸附位置(`top`、`bottom`)               | String | `top`                |
+| top         | 吸顶距离               | Number | `0`                |
+| bottom         | 吸底距离               | Number | `0`                |
+| container         | 容器的 `HTML` 节点, 在小程序环境下需要同时指定 `id`               | Element | -                |
+| z-index         | 吸附时的层级               | Number | `2000`               |
+
+### Events
+
+| 事件名 | 说明           | 回调参数     |
+|--------|----------------|--------------|
+| change  | 吸附状态改变时触发 | `val: Boolean` |
+| scroll  | 滚动时触发 | `{ top: Number, fixed: Boolean }` |

+ 5 - 0
src/packages/__VUE/sticky/index.scss

@@ -0,0 +1,5 @@
+.nut-sticky {
+  &--fixed {
+    position: fixed;
+  }
+}

+ 176 - 0
src/packages/__VUE/sticky/index.taro.vue

@@ -0,0 +1,176 @@
+<script lang="ts">
+import { reactive, computed, h, ref, Ref, unref, PropType, watch, CSSProperties } from 'vue';
+import Taro, { usePageScroll, useReady } from '@tarojs/taro';
+import { createComponent } from '../../utils/create';
+import { useTaroRect } from '../../utils/useTaroRect';
+const { componentName, create } = createComponent('sticky');
+export default create({
+  props: {
+    position: {
+      type: String,
+      default: 'top'
+    },
+    top: {
+      type: [Number, String],
+      default: 0
+    },
+    bottom: {
+      type: [Number, String],
+      default: 0
+    },
+    container: {
+      type: Object as PropType<Element>
+    },
+    zIndex: {
+      type: [Number, String],
+      default: 2000
+    }
+  },
+  emits: ['change', 'scroll'],
+
+  setup(props, { emit, slots }) {
+    const root = ref<HTMLElement>();
+    const query = Taro.createSelectorQuery();
+    const refRandomId = Math.random().toString(36).slice(-8);
+    const state = reactive({
+      width: 0,
+      height: 0,
+      fixed: false,
+      transform: 0
+    });
+
+    const rootStyle = computed(() => {
+      const { fixed, width, height } = state;
+
+      if (fixed) {
+        return {
+          width: `${width}px`,
+          height: `${height}px`
+        };
+      }
+    });
+
+    const stickyStyle = computed(() => {
+      if (!state.fixed) return;
+
+      const style: CSSProperties = {
+        width: `${state.width}px`,
+        height: `${state.height}px`,
+        [props.position]: `${offset.value}px`,
+        zIndex: +props.zIndex
+      };
+
+      if (state.transform) style.transform = `translate3d(0, ${state.transform}px, 0)`;
+
+      return style;
+    });
+
+    const offset = computed(() => {
+      return props.position === 'top' ? props.top : props.bottom;
+    });
+
+    const isHidden = (elementRef: HTMLElement | Ref<HTMLElement | undefined>) => {
+      const el = unref(elementRef);
+      if (!el) return false;
+      return new Promise((resolve, reject) => {
+        query
+          .select(`#${el.id}`)
+          .fields(
+            {
+              computedStyle: ['display', 'position']
+            },
+            (res) => {
+              const hidden = res.display === 'none';
+              const parentHidden = el.offsetParent === null && res.position !== 'fixed';
+              resolve(hidden || parentHidden);
+            }
+          )
+          .exec();
+      });
+    };
+
+    const isExistRoot = async () => {
+      const hidden = await isHidden(root);
+      if (!root.value || hidden) return false;
+      return true;
+    };
+
+    const renderFixed = () => {
+      return h(
+        'view',
+        {
+          style: stickyStyle.value,
+          class: state.fixed ? `${componentName} nut-sticky--fixed` : componentName
+        },
+        slots.default?.()
+      );
+    };
+
+    const onScroll = async (scrollTop: number) => {
+      if (!isExistRoot()) return;
+
+      const { container, position } = props;
+
+      const rootRect = await useTaroRect(root, Taro);
+
+      if (rootRect.width || rootRect.height) {
+        state.width = rootRect.width;
+        state.height = rootRect.height;
+      }
+
+      if (position === 'top') {
+        if (container) {
+          const containerRect = await useTaroRect(container, Taro);
+          const diff = containerRect.bottom - +offset.value - state.height;
+          state.fixed = +offset.value > rootRect.top && containerRect.bottom > 0;
+          state.transform = diff < 0 ? diff : 0;
+        } else {
+          state.fixed = offset.value > rootRect.top;
+        }
+      } else if (position === 'bottom') {
+        const clientHeight = Taro.getSystemInfoSync().windowHeight;
+        if (container) {
+          const containerRect = await useTaroRect(container, Taro);
+          const diff = clientHeight - containerRect.top - +offset.value - state.height;
+          state.fixed = clientHeight - +offset.value < rootRect.bottom && clientHeight > containerRect.top;
+          state.transform = diff < 0 ? -diff : 0;
+        } else {
+          state.fixed = clientHeight - +offset.value < rootRect.bottom;
+        }
+      }
+
+      emit('scroll', {
+        top: scrollTop,
+        fixed: state.fixed
+      });
+    };
+
+    watch(
+      () => state.fixed,
+      (val) => {
+        emit('change', val);
+      }
+    );
+
+    Taro.usePageScroll((res) => {
+      onScroll(res.scrollTop);
+    });
+
+    Taro.useReady(() => {
+      onScroll(0);
+    });
+
+    return () => {
+      return h(
+        'view',
+        {
+          style: rootStyle.value,
+          id: `root-${refRandomId}`,
+          ref: root
+        },
+        [renderFixed()]
+      );
+    };
+  }
+});
+</script>

+ 169 - 0
src/packages/__VUE/sticky/index.vue

@@ -0,0 +1,169 @@
+<script lang="ts">
+import { reactive, computed, h, onMounted, onUnmounted, ref, Ref, unref, PropType, watch, CSSProperties } from 'vue';
+import { createComponent } from '../../utils/create';
+import { useRect } from '../../utils/useRect';
+const { componentName, create } = createComponent('sticky');
+export default create({
+  props: {
+    position: {
+      type: String,
+      default: 'top'
+    },
+    top: {
+      type: [Number, String],
+      default: 0
+    },
+    bottom: {
+      type: [Number, String],
+      default: 0
+    },
+    container: {
+      type: Object as PropType<Element>
+    },
+    zIndex: {
+      type: [Number, String],
+      default: 2000
+    }
+  },
+  emits: ['change', 'scroll'],
+
+  setup(props, { emit, slots }) {
+    const root = ref<HTMLElement>();
+    const state = reactive({
+      width: 0,
+      height: 0,
+      fixed: false,
+      transform: 0
+    });
+
+    const rootStyle = computed(() => {
+      const { fixed, width, height } = state;
+
+      if (fixed) {
+        return {
+          width: `${width}px`,
+          height: `${height}px`
+        };
+      }
+    });
+
+    const stickyStyle = computed(() => {
+      if (!state.fixed) return;
+
+      const style: CSSProperties = {
+        width: `${state.width}px`,
+        height: `${state.height}px`,
+        [props.position]: `${offset.value}px`,
+        zIndex: +props.zIndex
+      };
+
+      if (state.transform) style.transform = `translate3d(0, ${state.transform}px, 0)`;
+
+      return style;
+    });
+
+    const offset = computed(() => {
+      return props.position === 'top' ? props.top : props.bottom;
+    });
+
+    const isHidden = (elementRef: HTMLElement | Ref<HTMLElement | undefined>) => {
+      const el = unref(elementRef);
+      if (!el) return false;
+
+      const style = window.getComputedStyle(el);
+      const hidden = style.display === 'none';
+
+      const parentHidden = el.offsetParent === null && style.position !== 'fixed';
+
+      return hidden || parentHidden;
+    };
+
+    const isExistRoot = () => {
+      if (!root.value || isHidden(root)) return false;
+      return true;
+    };
+
+    const getScrollTop = (el: Element | Window) => {
+      return Math.max(0, 'scrollTop' in el ? el.scrollTop : el.pageYOffset);
+    };
+
+    const renderFixed = () => {
+      return h(
+        'view',
+        {
+          style: stickyStyle.value,
+          class: state.fixed ? `${componentName} nut-sticky--fixed` : componentName
+        },
+        slots.default?.()
+      );
+    };
+
+    const onScroll = () => {
+      if (!isExistRoot()) return;
+
+      const { container, position } = props;
+
+      const scrollTop = getScrollTop(window);
+
+      const rootRect = useRect(root);
+      if (rootRect.width || rootRect.height) {
+        state.width = rootRect.width;
+        state.height = rootRect.height;
+      }
+
+      if (position === 'top') {
+        if (container) {
+          const containerRect = useRect(container);
+          const diff = containerRect.bottom - +offset.value - state.height;
+          state.fixed = +offset.value > rootRect.top && containerRect.bottom > 0;
+          state.transform = diff < 0 ? diff : 0;
+        } else {
+          state.fixed = offset.value > rootRect.top;
+        }
+      } else if (position === 'bottom') {
+        const clientHeight = document.documentElement.clientHeight;
+        if (container) {
+          const containerRect = useRect(container);
+          const diff = clientHeight - containerRect.top - +offset.value - state.height;
+          state.fixed = clientHeight - +offset.value < rootRect.bottom && clientHeight > containerRect.top;
+          state.transform = diff < 0 ? -diff : 0;
+        } else {
+          state.fixed = clientHeight - +offset.value < rootRect.bottom;
+        }
+      }
+
+      emit('scroll', {
+        top: scrollTop,
+        fixed: state.fixed
+      });
+    };
+
+    watch(
+      () => state.fixed,
+      (val) => {
+        emit('change', val);
+      }
+    );
+
+    onMounted(() => {
+      window.addEventListener('scroll', onScroll);
+      onScroll();
+    });
+
+    onUnmounted(() => {
+      window.removeEventListener('scroll', onScroll);
+    });
+
+    return () => {
+      return h(
+        'view',
+        {
+          style: rootStyle.value,
+          ref: root
+        },
+        [renderFixed()]
+      );
+    };
+  }
+});
+</script>

+ 1 - 1
src/sites/mobile-taro/vue/src/app.config.ts

@@ -1,7 +1,7 @@
 let subpackages = [
   {
     root: 'layout',
-    pages: ['pages/layout/index', 'pages/imagepreview/index']
+    pages: ['pages/layout/index', 'pages/imagepreview/index', 'pages/sticky/index']
   },
   {
     root: 'feedback',

+ 1 - 0
src/sites/mobile-taro/vue/src/layout/pages/sticky/index.config.ts

@@ -0,0 +1 @@
+export default { navigationBarTitleText: 'Sticky' };

+ 53 - 0
src/sites/mobile-taro/vue/src/layout/pages/sticky/index.vue

@@ -0,0 +1,53 @@
+<template>
+  <div class="demo sticky-demo">
+    <h2>基础用法</h2>
+    <nut-cell>
+      <nut-sticky top="0">
+        <nut-button type="primary">吸顶按钮</nut-button>
+      </nut-sticky>
+    </nut-cell>
+    <h2>吸顶距离</h2>
+    <nut-cell>
+      <nut-sticky top="50">
+        <nut-button type="primary">吸顶距离50px</nut-button>
+      </nut-sticky>
+    </nut-cell>
+    <h2>指定容器</h2>
+    <div class="sticky-container" ref="container" id="container">
+      <nut-sticky top="0" :container="container" z-index="1">
+        <nut-button type="info">指定容器</nut-button>
+      </nut-sticky>
+    </div>
+    <h2>吸底距离</h2>
+    <nut-cell>
+      <nut-sticky bottom="100" position="bottom">
+        <nut-button type="primary">吸底距离100px</nut-button>
+      </nut-sticky>
+    </nut-cell>
+  </div>
+</template>
+<script lang="ts">
+import { defineComponent, ref } from 'vue';
+export default defineComponent({
+  props: {},
+  setup() {
+    const container = ref(null);
+    return {
+      container
+    };
+  }
+});
+</script>
+<style lang="scss">
+.sticky-demo {
+  height: 200vh !important;
+}
+.sticky-container {
+  width: 100%;
+  height: 300px;
+  background-color: #fff;
+}
+#app {
+  height: auto !important;
+}
+</style>