recycle-list.vue 9.19 KB
<template>
  <div class="cube-recycle-list">
    <div class="cube-recycle-list-main">
      <div ref="items" class="cube-recycle-list-items">
        <div
          v-for="item in visibleItems"
          :key="item.index"
          class="cube-recycle-list-item"
          :class="thumbClass"
          :ref="'loads'+item.index"
          :style="getItemStyle(item)">
          <div v-if="!item.placeholder || !item.height">
            <slot name="item" :data="item"></slot>
          </div>
        </div>
      </div>
      <div 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: [],
      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.thumbItems.concat(this.items.slice(this.thumbsList.length, this.items.length));
      }
      return this.items;
    },
    thumbItems() {
      return this.thumbsList.map((item, index) => {
        return {
          data: item,
          isThumb: true,
          index
        };
      });
    },
    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
      };
    }
  },
  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) {
      const style = {};

      if (item.height) {
        if (!item.unlockHight) {
          style.height = `${item.height}px`;
        }
      } else if (item.willchange) {
        style.transition = 'height 300ms cubic-bezier(0.165, 0.84, 0.44, 1)';
        style['will-change'] = 'height';
        style.height = `${item.height}px`;
        style.opacity = 0;
      } else if (!item.isThumb) {
        style.position = 'absolute';
        style.top = `${-1000}px`;
        style.visibility = 'hidden';
      }
      return style;
    },
    load(reload) {
      if ((!this.loadings.length && !this.noMore) || reload) {
        this.getItems(reload);
      }
    },
    getItems(reload) {
      if (reload) {
        this.noMore = false;
        this.items = [];
      }
      this.loadings.push('pending');
      this.onFetch().then((res) => {
        if (!res) {
          this.noMore = true;
          this.loadings.pop();
        } else {
          this.loadItems(res);
        }
      });
    },
    async loadItems(list) {
      const lastItem = this.items[this.items.length - 1];
      const start = lastItem ? lastItem.index + 1 : 0;

      for (let i = 0; i < list.length; i++) {
        await this.loadItem(i + start, list[i]);
      }
      if (this.loadings.length) {
        this.loadings.pop();
      }
      if (this.thumbsList.length) {
        this.thumbsList = [];
      }
    },
    loadItem(i, item) {
      return new Promise(r => {
        const insertIndex = this.setItem(i, item);

        this.$nextTick(() => {
          setTimeout(() => {
            this.updateItemHeight(insertIndex);
            r();
          }, 100);
        });
      });
    },
    setItem(index, data) {
      if (this.thumbsList[index]) {
        this.thumbsList[index] = data;
        this.thumbsList = this.thumbsList.map(item => item);
      }
      const insertIndex = this.items.length;

      this.$set(this.items, insertIndex, {
        data: data || {},
        height: 0,
        top: -1000,
        placeholder: false,
        unlockHight: false,
        willchange: false,
        index
      });
      return insertIndex;
    },
    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${cur.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;
    },
    updateIndex(scrollTop) {
      let top = 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.items[this.startIndex].index;
      } else {
        this.currentIndex = this.items[this.items.length - 1].index;
      }
    },
    _onScroll() {
      const scrollTop = this.$el.scrollTop;
      const heights = this.$refs.items.offsetHeight;

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

      this.$emit('scroll', {scrollTop, startIndex: this.currentIndex, item: this.items[this.currentIndex]});
    },
    findItemIndex(itemIndex) {
      return this.items.findIndex(item => item.index === itemIndex);
    },
    resize(itemIndex) {
      const findItemIndex = this.findItemIndex(itemIndex);

      if (findItemIndex >= 0) {
        this.updateItemHeight(findItemIndex, true);
      }
    },
    delete(itemIndex) {
      const findItemIndex = this.findItemIndex(itemIndex);

      if (findItemIndex >= 0) {
        this.items[findItemIndex].willchange = true;
        let dom = this.$refs[`loads${itemIndex}`];

        dom && dom[0].addEventListener('transitionend', () => {
          this.items.splice(findItemIndex, 1);
          this._onScroll();
        }, {
          once: true
        });
        this.$nextTick(() => {
          this.items[findItemIndex].height = 0;
        });
      }
    },
    unlockHight({index, promise}) {
      const findItemIndex = this.findItemIndex(index);

      if (findItemIndex >= 0) {
        const cur = this.items[findItemIndex];

        if (cur) {
          cur.unlockHight = true;
          promise.then(() => {
            this.updateItemHeight(findItemIndex, true);
            cur.unlockHight = false;
          });
        }
      }
    }
  },
  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%
    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>