recycle-list.vue 9.01 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"
          :class="thumbClass"
          :ref="'loads'+index"
          :style="getItemStyle(item, index)"
        >
          <div
            v-if="infinite"
            :class="{'cube-recycle-list-transition': infinite}"
            :style="{opacity: +!item.loaded}"
          >
            <slot name="tombstone"></slot>
          </div>
          <div
            v-if="!item.placeholder"
            :class="{'cube-recycle-list-transition': infinite}"
            :style="{opacity: item.loaded}"
          >
            <slot name="item" :data="{data: item.data, index}"></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: loadings.length ? '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 {throttle} from 'lodash';
import CubeLoading from 'cube-ui/src/components/loading/loading.vue';

const EVENT_SCROLL = 'scroll';

export default {
  name: 'RecycleList',
  data() {
    return {
      items: [],
      list: [],
      heights: this.thumbs.length ? 1000 : 0,
      startIndex: 0,
      currentIndex: 0,
      loadings: [],
      startOffset: 0,
      noMore: false,
      thumbsList: this.thumbs,
    };
  },
  props: {
    infinite: {
      type: Boolean,
      default: false
    },
    size: {
      type: Number,
      default: 20
    },
    offset: {
      type: Number,
      default: 100
    },
    onFetch: {
      type: Function,
      required: true
    },
    thumbs: {
      type: Array,
      default() {
        return [];
      }
    }
  },
  computed: {
    visibleItems() {
      if (this.thumbsList.length) {
        return this.thumbsList.map(item => {
          return {
            data: item
          };
        }).concat(this.items.slice(this.thumbsList.length, this.items.length));
      }
      return this.items;
    },
    tombHeight() {
      return this.infinite ? this.$refs.tomb && this.$refs.tomb.offsetHeight : 0;
    },
    loading() {
      return this.loadings.length && this.isThumb;
    },
    isThumb() {
      return !!this.thumbsList.length;
    },
    thumbClass() {
      return {
        thumb: this.isThumb
      };
    }
  },
  watch: {
    list(newV) {
      if (newV.length) {
        this.loadings.pop();
        if (!this.loadings.length) {
          this.loadItems();
        }
      }
    },
    items(newV) {
      if (newV.length > this.list.length) {
        this.getItems();
      }
    }
  },
  mounted() {
    this.scrollEvent = throttle(this._onScroll.bind(this), 100);
    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.scrollEvent, supportsPassive ? { passive: true } : false);
    this.init();
  },
  beforeDestroy() {
    this.$el.removeEventListener(EVENT_SCROLL, this.scrollEvent);
  },
  methods: {
    init() {
      this.load(true);
    },
    getItemStyle(item, index) {
      const style = {};

      if (!this.isThumb) {
        style.transform = `translate3d(0, ${item.top}px, 0)`;
      }
      if (item.placeholder) {
        style.height = `${item.height}px`;
      } else {
        style['z-index'] = this.visibleItems.length - index;
      }
      if (!item.height && !this.isThumb) {
        style.top = `${-1000}px`;
        style.visibility = 'hidden';
      }
      return style;
    },
    load(reload) {
      if (this.infinite) {
        // increase capacity of items to display tombstone
        this.items.length += this.size;
        this.loadItems();
      } else if ((!this.loadings.length && !this.noMore) || reload) {
        this.getItems(reload);
      }
    },
    getItems(reload) {
      if (reload) {
        this.noMore = false;
        this.list = [];
        this.items = [];
      }
      this.loadings.push('pending');
      this.onFetch().then((res) => {
        /* istanbul ignore if */
        if (!res) {
          this.noMore = true;
          this.loadings.pop();
        } else {
          this.list = this.list.concat(res);
        }
      });
    },
    async loadItems() {
      let end = this.infinite ? this.items.length : this.list.length;
      let item;

      for (let i = this.items.length; i < end; i++) {
        item = this.items[i];
        /* istanbul ignore if */
        if (item && item.loaded) {
          continue;
        }
        await this.loadItem(i);
      }
      if (this.thumbsList.length) {
        this.thumbsList = [];
      }
    },
    loadItem(i) {
      return new Promise(r => {
        this.setItem(i, this.list[i]);
        this.$nextTick(() => {
          this.updateItemHeight(i);
          r();
        });
      });
    },
    setItem(index, data) {
      if (this.thumbsList[index]) {
        this.thumbsList[index] = data;
        this.thumbsList = this.thumbsList.map(item => item);
      }
      this.$set(this.items, index, {
        data: data || {},
        height: 0,
        top: -1000,
        isTombstone: !data,
        loaded: data ? 1 : 0,
        placeholder: false
      });
    },
    updateItemHeight(index, resize) {
      if (index === 0 && !resize) {
        this.heights = 0;
      }

      // update item height
      let increHeight = 0;
      let cur = this.items[index];
      let dom = this.$refs[`loads${index}`];

      try {
        if (dom && dom[0]) {
          if (cur.height) {
            increHeight = dom[0].offsetHeight - cur.height;
          }
          cur.height = dom[0].offsetHeight;
          const preItem = this.items[index - 1];

          cur.top = preItem ? (preItem.top + preItem.height) : 0;
          if (!resize) {
            this.heights += cur.height;
          }
        }
      } catch (error) {
        const message = `cur_${typeof cur}, dom_ ${typeof dom}, index_ ${index}, length_ ${this.items.length}, ${error ? error.message : ''}`;

        throw new Error(message);
      }

      return increHeight;
    },
    updateItemTop(startIndex, increHeight) {
      // loop all items to update item top and list height
      for (let i = startIndex; i < this.items.length; i++) {
        let pre = this.items[i - 1];

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

      for (let i = 0; i < this.items.length; i++) {
        if (i < this.startIndex - 10 || i > this.startIndex + 10) {
          this.items[i].placeholder = true;
        } else {
          this.items[i].placeholder = false;
        }
        if (!hasTopItem && this.items[i].top > top) {
          this.startIndex = Math.max(0, i - 1);
          hasTopItem = true;
        }
      }
      if (hasTopItem) {
        this.currentIndex = this.startIndex;
      } else {
        this.currentIndex = this.items.length - 1;
      }
    },
    _onScroll() {
      const scrollTop = this.$el.scrollTop;

      // trigger load
      if (scrollTop + this.$el.offsetHeight > this.heights - this.offset) {
        this.load();
      }
      this.updateIndex();

      this.$emit('scroll', {scrollTop, startIndex: this.currentIndex, item: this.items[this.currentIndex]});
    },
    resize(index) {
      const increHeight = this.updateItemHeight(index, true);

      this.updateItemTop(index, increHeight);
    }
  },
  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
    &.thumb
      position relative
  .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>