recycle-scroll-reveal.vue 9.26 KB
<template>
  <div class="recycle-scroll-reveal">
    <div class="recycle-scroll-reveal-main" ref="scroll">
      <div ref="eternal" class="eternal-top">
        <slot name="eternalTop"></slot>
      </div>
      <!--推荐阅读-->
      <div class="scroll-reveal-list-block">
        <div ref="scrollList" class="scroll-reveal-list" :style="{height: listHeight + 'px'}">
          <div
            v-for="(items, col) in visibleItems"
            :key="col"
            class="scroll-reveal-col"
            :ref="'col'+ col"
            :style="colStyle">
            <div
              v-for="item in items"
              :key="item.index"
              class="scroll-reveal-item"
              :ref="'items' + item.index"
              :style="getItemStyle(item)">
              <div v-if="!item.placeholder">
                <slot name="item" :data="item"></slot>
              </div>
            </div>
          </div>
        </div>
        <div class="loading">
          <p v-if="noMore" class="load-text">没有更多了</p>
          <Loading v-else :size="20"></Loading>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import {throttle, slice} from 'lodash';
import {Loading} from 'cube-ui';

const EVENT_SCROLL = 'scroll';

export default {
  name: 'RecycleScrollReveal',
  data() {
    let data = {
      colPrefix: 'sr_col',
      listHeight: 0,
      visibleItems: [],
      colsHeight: [],
      items: [],
      itemWidth: 0,
      noMore: false,
      startIndexs: [0, 0]
    };

    for (let i = 0; i < this.cols; i++) {
      data[data.colPrefix + i] = [];
      data.visibleItems[i] = data[data.colPrefix + i];
      data.colsHeight[i] = 0;
    }

    return data;
  },
  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 [];
      }
    },
    cols: {
      type: Number,
      default: 2
    },
    manualInit: {
      type: Boolean,
      default: false
    }
  },
  computed: {
    colStyle() {
      return {
        width: `${100 / this.cols}%`
      };
    }
  },
  watch: {
    items(newList, oldList) {
      let list = newList.slice(oldList.length, newList.length);

      if (list.length) {
        this.revealCalcing = true;
        this.loadItems(list, oldList.length).then(() => {
          this.revealCalcing = false;
          this.updateListHeight();

          if (oldList.length < 2) {
            this._updateList();
            this.$emit('on-inited', this._getCurrentItems());
          }
        });
      }
    }
  },
  mounted() {
    this.updateList = throttle(this._updateList.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.onScroll, supportsPassive ? { passive: true } : false);

    if (!this.manualInit) {
      this.init();
    }
  },
  beforeDestroy() {
    this.$el.removeEventListener(EVENT_SCROLL, this.onScroll);
  },
  methods: {
    init() {
      this.colsHeight = [];
      this.itemWidth = Math.floor(this.$el.offsetWidth / this.cols);

      this.load(true);
    },
    clear() {
      this.clearTimestamp = new Date().getTime();
      this.noMore = false;
      this.listHeight = 0;

      for (let i = 0; i < this.cols; i++) {
        this.visibleItems[i].length = 0;
        this.colsHeight[i] = 0;
        this.startIndexs[i] = 0;
      }

      this.items = [];
    },
    load(reload) {
      if (reload) {
        this.clear();
      }

      if (!reload && (this.loading || this.noMore)) {
        return;
      }

      this.loading = true;

      this.onFetch().then(res => {
        this.loading = false;

        if (!res) {
          this.noMore = true;
        } else {
          this.items = this.items.concat(res);
        }
      });
    },
    async loadItems(list, start = 0) {
      if (!list.length) {
        return;
      }

      let lastIndex = list.length;

      if (this.cols > 1) {
        lastIndex = Math.max.apply(null, [1, list.length - Math.floor(this.cols * 2)]);
      }

      let startCol = this.getMinHeightCol();
      const timestamp = this.clearTimestamp;

      for (let i = 0; i < list.length; i++) {
        await this.loadItem({
          data: list[i],
          index: i + start,
          width: this.itemWidth,
          isThumb: true,
          placeholder: false
        }, i < lastIndex ? (startCol + i) % this.cols : -1, timestamp);
      }

      this.$nextTick(() => {
        for (let i = 0; i < this.cols; i++) {
          let loop = true;

          let col = this[this.colPrefix + i];

          let k = col.length - 1;

          while (loop) {
            let cur = col[k];

            if (!cur || cur.height) {
              loop = false;
              continue;
            }

            let dom = this.$refs[`items${cur.index}`];

            try {
              if (dom && dom[0]) {
                cur.height = dom[0].offsetHeight;
                cur.top = dom[0].offsetTop;
              }
            } catch (error) {
              const message = `cur_${typeof cur}, dom_ ${typeof dom}, column_${i}, index_ ${k}, length_ ${this.items.length}, ${error ? error.message : ''}`;

              throw new Error(message);
            }

            this.$set(this[this.colPrefix + i], k, cur);
            k--;
          }
        }

        return true;
      });
    },
    loadItem(item, index, timestamp) {
      return new Promise(r => {
        if (timestamp !== this.clearTimestamp) {
          return r();
        }

        if (index < 0) {
          index = this.getMinHeightCol();
        }

        this[this.colPrefix + index].push(item);
        this.$nextTick(() => {
          r();
          this.colsHeight[index] = this.$refs['col' + index][0].offsetHeight;
        });
      });
    },
    getMinHeightCol() {
      return this.colsHeight.indexOf(Math.min.apply(null, this.colsHeight));
    },
    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;
    },
    updateCurrentItems(scrollTop) {
      let top = scrollTop - this.$refs.eternal.offsetHeight;

      let arr = [];

      for (let i = 0; i < this.cols; i++) {
        arr.push(this.updateColumnCurrentItems(i, top));
      }
    },
    updateColumnCurrentItems(index, top) {
      let col = this[this.colPrefix + index];

      let startIndex = this.startIndexs[index];

      let hasTopItem = false;

      for (let i = 0; i < col.length; i++) {
        if ((i < startIndex - this.size || i > startIndex + this.size) && col[i].height) {
          this.$set(col[i], 'placeholder', true);
        } else {
          this.$set(col[i], 'placeholder', false);
        }

        if (!hasTopItem && col[i].top > top) {
          startIndex = Math.max(0, i - 1);
          hasTopItem = true;
        }
      }
      this.startIndexs[index] = startIndex;
    },
    updateListHeight() {
      if (this.$refs.scrollList) {
        this.listHeight = this.$refs.scrollList.scrollHeight || 0;
      }
    },
    _updateList() {
      const scrollTop = this.$el.scrollTop;
      const heights = this.$refs.scroll.offsetHeight;

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

      this.updateCurrentItems(scrollTop);
    },
    _getCurrentItems(scrollTop = 0) {
      let currents = [];

      if (scrollTop > (this.$refs.eternal.offsetHeight - document.body.clientHeight / 2)) {
        for (let i = 0; i < this.cols; i++) {
          let col = this[this.colPrefix + i];

          if (col && col.length) {
            let start = this.startIndexs[i] || 0;

            currents = currents.concat(slice(col, start, start + Math.round(this.size / 2)));
          }
        }
      }

      return currents;
    },
    onScroll() {
      const scrollTop = this.$el.scrollTop;

      this.updateList();

      this.$emit('scroll', {scrollTop, items: this._getCurrentItems(scrollTop)});
    },
  },
  components: {
    Loading
  }
};
</script>

<style lang="scss" scoped>
.recycle-scroll-reveal {
  position: relative;
  height: 100%;
  overflow-x: hidden;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}

.recycle-scroll-reveal-main {
  min-height: 100%;
}

.scroll-reveal-list {
  display: flex;
  align-items: flex-start;
  overflow-y: hidden;
  box-sizing: border-box;

  .scroll-reveal-col {
    flex-grow: 1;
    position: relative;
  }
}

.loading {
  padding: 20px 0;
  text-align: center;

  /deep/ .cube-loading-spinners {
    margin: auto;
  }
}

</style>