recycle-list.vue 8.27 KB
<template>
  <div class="cube-recycle-list">
    <div class="cube-recycle-list-main">
      <div class="cube-recycle-list-items" :style="{height: heights + 'px'}">
        <div
          v-for="(item, index) in visibleItems"
          :key="index"
          class="cube-recycle-list-item"
          :ref="'loads'+index"
          :style="{transform: 'translate(0,' + item.top + 'px)', 'z-index': visibleItems.length - index}"
        >
          <div
            v-if="infinite"
            :class="{'cube-recycle-list-transition': infinite}"
            :style="{opacity: +!item.loaded}"
          >
            <slot name="tombstone"></slot>
          </div>
          <div
            :class="{'cube-recycle-list-transition': infinite}"
            :style="{opacity: item.loaded}"
          >
            <slot name="item" :data="item.data"></slot>
          </div>
        </div>

        <!-- preloads item for get its height, remove it after caculating height-->
        <div class="cube-recycle-list-pool">
          <div
            class="cube-recycle-list-item cube-recycle-list-invisible"
            :ref="'preloads'+index"
            v-for="(item, index) in items"
            :key="index"
            v-if="!item.isTombstone && !item.height"
          >
            <slot name="item" :data="item.data"></slot>
          </div>
          <div ref="tomb" class="cube-recycle-list-item cube-recycle-list-invisible">
            <slot name="tombstone"></slot>
          </div>
        </div>
      </div>
      <div
        v-if="!infinite"
        class="cube-recycle-list-loading">
        <slot name="spinner">
          <div class="cube-recycle-list-loading-content" v-show="!noMore" :style="{visibility: loading ? 'visible' : 'hidden'}">
            <cube-loading class="spinner"></cube-loading>
          </div>
        </slot>
        <div v-show="noMore" class="cube-recycle-list-noMore">
          <slot name="noMore" />
        </div>
      </div>
    </div>
    <div class="cube-recycle-list-fake"></div>
  </div>
</template>

<script>
import CubeLoading from 'cube-ui/src/components/loading/loading.vue';

const EVENT_SCROLL = 'scroll';
const EVENT_RESIZE = 'resize';

export default {
  name: 'RecycleList',
  data() {
    return {
      items: [],
      list: [],
      heights: 0,
      startIndex: 0,
      currentIndex: 0,
      loadings: [],
      startOffset: 0,
      noMore: false
    };
  },
  props: {
    infinite: {
      type: Boolean,
      default: false
    },
    size: {
      type: Number,
      default: 20
    },
    offset: {
      type: Number,
      default: 100
    },
    onFetch: {
      type: Function,
      required: true
    }
  },
  computed: {
    visibleItems() {
      return this.items.slice(Math.max(0, this.startIndex - this.size), Math.min(this.items.length, this.startIndex + this.size));
    },
    tombHeight() {
      return this.infinite ? this.$refs.tomb && this.$refs.tomb.offsetHeight : 0;
    },
    loading() {
      return this.loadings.length;
    }
  },
  watch: {
    list(newV) {
      if (newV.length) {
        this.loadings.pop();
        if (!this.loading) {
          this.loadItems();
        }
      }
    },
    items(newV) {
      if (newV.length > this.list.length) {
        this.getItems();
      }
    }
  },
  mounted() {
    let supportsPassive = false;

    try {
      const opts = Object.defineProperty({}, 'passive', {
        get() {
          supportsPassive = true;
          return true;
        }
      });

      window.addEventListener('test', null, opts);
    } catch (e) {} //eslint-disable-line
    this.$el.addEventListener(EVENT_SCROLL, this._onScroll, supportsPassive ? { passive: true } : false);
    window.addEventListener(EVENT_RESIZE, this._onResize);
    this.init();
  },
  beforeDestroy() {
    this.$el.removeEventListener(EVENT_SCROLL, this._onScroll);
    window.removeEventListener(EVENT_RESIZE, this._onResize);
  },
  methods: {
    init() {
      this.load(true);
    },
    load(reload) {
      if (this.infinite) {
        // increase capacity of items to display tombstone
        this.items.length += this.size;
        this.loadItems();
      } else if ((!this.loading && !this.noMore) || reload) {
        this.getItems(reload);
      }
    },
    getItems(reload) {
      this.loadings.push('pending');
      this.onFetch().then((res) => {
        /* istanbul ignore if */
        if (!res) {
          this.noMore = true;
          this.loadings.pop();
        } else if (reload) {
          this.list = res;
          this.items = [];
        } else {
          this.list = this.list.concat(res);
        }
      });
    },
    loadItems(isResize) {
      let promiseTasks = [];
      let start = 0;
      let end = this.infinite ? this.items.length : this.list.length;
      let item;

      for (let i = start; i < end; i++) {
        item = this.items[i];
        /* istanbul ignore if */
        if (item && item.loaded) {
          continue;
        }
        this.setItem(i, this.list[i]);
        promiseTasks.push(this.$nextTick().then(() => {
          this.updateItemHeight(i, isResize);
        }));
      }
      window.Promise.all(promiseTasks).then(() => {
        this.updateItemTop();
      });
    },
    setItem(index, data) {
      this.$set(this.items, index, {
        data: data || {},
        height: 0,
        top: -1000,
        isTombstone: !data,
        loaded: data ? 1 : 0
      });
    },
    updateItemHeight(index, isResize) {
      // update item height
      let cur = this.items[index];
      let dom = this.$refs[(isResize ? 'loads' : 'preloads') + index];

      if (dom && dom[0]) {
        cur.height = dom[0].offsetHeight;
      } else {
        // tombstone
        cur.height = this.tombHeight;
      }
    },
    updateItemTop() {
      // loop all items to update item top and list height
      this.heights = 0;
      for (let i = 0; i < this.items.length; i++) {
        let pre = this.items[i - 1];

        this.items[i].top = pre ? pre.top + pre.height : 0;
        this.heights += this.items[i].height;
      }
      if (this.startOffset) {
        this.setScrollTop();
      }
      this.updateIndex();
    },
    updateIndex() {
      // update visible items start index
      let top = this.$el.scrollTop;
      let hasTopItem = false;

      for (let i = 0; i < this.items.length; i++) {
        if (this.items[i].top > top) {
          this.startIndex = Math.max(0, i - 1);
          hasTopItem = true;
          break;
        }
      }
      if (hasTopItem) {
        this.currentIndex = this.startIndex;
      } else {
        this.currentIndex = this.items.length - 1;
      }
    },
    getStartItemOffset() {
      if (this.items[this.startIndex]) {
        this.startOffset = this.items[this.startIndex].top - this.$el.scrollTop;
      }
    },
    setScrollTop() {
      if (this.items[this.startIndex]) {
        this.$el.scrollTop = this.items[this.startIndex].top - this.startOffset;
        this.startOffset = 0;
      }
    },
    _onScroll() {
      // trigger load
      if (this.$el.scrollTop + this.$el.offsetHeight > this.heights - this.offset) {
        this.load();
      }
      this.updateIndex();

      this.$emit('scroll', {scrollTop: this.$el.scrollTop, startIndex: this.currentIndex, item: this.items[this.currentIndex]});
    },
    _onResize() {
      this.getStartItemOffset();
      this.items.forEach((item) => {
        item.loaded = false;
      });
      this.loadItems(true);
    }
  },
  components: {
    CubeLoading
  }
};
</script>

<style lang="stylus" rel="stylesheet/stylus">
  .cube-recycle-list
    position: relative
    height: 100%
    overflow-x: hidden
    overflow-y: auto
    -webkit-overflow-scrolling: touch

  .cube-recycle-list-main
    min-height: 100%

  .cube-recycle-list-fake
    height: 1px

  .cube-recycle-list-invisible
    top: -1000px
    visibility: hidden

  .cube-recycle-list-item
    width: 100%
    position: absolute
    box-sizing: border-box

  .cube-recycle-list-transition
    position: absolute
    opacity: 0
    transition-property: opacity
    transition-duration: 500ms

  .cube-recycle-list-loading
    overflow: hidden

  .cube-recycle-list-loading-content
    text-align: center
    .spinner
      margin: 10px auto
      display: flex
      justify-content: center

  .cube-recycle-list-noMore
    overflow: hidden
    margin: 10px auto
    height: 20px
    text-align: center
</style>