content/content.vue

<template>
    <article class="ion-content" :class="[refreshClass,modeClass]">
        <slot name="refresher"></slot>
        <div class="fixed-content fixed-top" :style="{'top':`${headerBarHeight}px`}">
            <slot name="fixed"></slot>
            <slot name="fixed-top"></slot>
        </div>
        <div class="fixed-content fixed-bottom" :style="{'bottom':`${footerBarHeight}px`}">
            <slot name="fixed-bottom"></slot>
        </div>
        <section ref="scrollElement" class="scroll-content" :style="scrollElementStyle" :class="{'disable-scroll':isScrollDisabled}">
            <slot></slot>
        </section>
    </article>
</template>
<style lang="scss" src="./style.scss"></style>
<script type="text/javascript">
  /**
   * @component Content
   * @description
   *
   * ## 基础组件 / Content组件
   *
   * Vimo框架的页面基础布局分为Header/Content/Footer三个部分, 也就是"上中下三明治"结构布局, Content组件则是中间业务内容的位置.
   *
   * Content组件中书写的代码可以是滚动的内容, 也可以是固定在一面不随滚动的内容, 比如说当页的广告/刷新按钮/歌词等. 这个特性的的开启需要特殊命名slot才能激活.
   *
   * 此外需要注意的是, 一个页面(Page组件)中只能有一个Content组件, 这个是Vimo使用的规则!
   *
   * Content组件中也可以加入下拉刷新和上拉加载的功能, 具体请参考示例.
   *
   * ## 不需要引入
   *
   * 是的, 基础组件是安装vimo后自动全局注册的.
   *
   * @demo #/content
   *
   * @slot 空                slot为空则将内容插入到scroll中
   * @slot [fixed-top]       固定到顶部
   * @slot [fixed-bottom]    固定到底部
   * @slot [refresher]      refresher组件的位置
   *
   * @fires component:Content#onScrollStart
   * @fires component:Content#onScroll
   * @fires component:Content#onScrollEnd
   *
   * @usage
   * <template>
   *  <Page>
   *    <Header>
   *      <Navbar>
   *        <Title>Demo</Title>
   *      <Navbar>
   *    </Header>
   *    <Content record-position>
   *      <h1>这里是内容</h1>
   *      <p>滚动位置将会被记录</p>
   *    </Content>
   *  </Page>
   * </template>
   *
   * */
  import { transitionEnd, parsePxUnit } from '../../util/util'
  import { updateImgs } from './img-util'
  import cssFormat from '../../util/css-format'
  import ScrollView from '../../util/scroll-view'
  import addSlotNameToAttr from '../../util/add-slot-name-to-attr.js'
  import registerListener from '../../util/register-listener.js'
  import throttle from 'lodash.throttle'

  export default {
    name: 'Content',
    inject: {
      pageComponent: 'pageComponent'
    },
    provide () {
      let _this = this
      return {
        contentComponent: _this
      }
    },
    props: {
      mode: {
        type: String,
        default () { return this.$config && this.$config.get('mode', 'ios') || 'ios' }
      }
    },
    data () {
      return {
        refreshClass: {
          'has-refresher': false
        },

        fixedElementStyle: {},  // 固定内容的位置样式
        scrollElementStyle: {}, // 滑动内容的位置样式

        scrollView: new ScrollView(),  // 滚动的实例

        headerBarHeight: 0,
        footerBarHeight: 0,

        resizeUnReg: null,

        imgs: [],      // 子组件Img的实例列表
        imgReqBfr: this.$config && this.$config.getNumber('imgRequestBuffer', 1400),  // 1400
        imgRndBfr: this.$config && this.$config.getNumber('imgRenderBuffer', 600),  // 400
        imgVelMax: this.$config && this.$config.getNumber('imgVelocityMax', 3)
      }
    },
    computed: {
      scrollElement () {
        return this.$refs.scrollElement
      },
      isBox () {
        return this.pageComponent.isBox
      },
      modeClass () {
        return `content-${this.mode}`
      },
      headerComponent () {
        return this.pageComponent.$_getHeaderComponent()
      },
      footerComponent () {
        return this.pageComponent.$_getFooterComponent()
      },
      isScrollDisabled () {
        return this.$app && this.$app.isScrollDisabled
      }
    },
    methods: {
      /**
       * @function resize
       * @description
       * 当动态添加Header/Footer/Tabs或者修改了他的属性时, 重新计算Content组件的尺寸.
       * */
      resize () {
        // 等待DOM更新完毕
        this.$nextTick(() => {
          this.$_recalculateBarDimensions()
        })
      },
      /**
       * @function scrollTo
       * @description
       * 滚动到指定位置
       * @param {Number} [x=0]            - 滚动到指定位置的x值
       * @param {Number} [y=0]            - 滚动到指定位置的y值
       * @param {Number} [duration=300]   - 滚动动画的时间
       * @param {Function=} done          - 当滚动结束时触发的回调
       * @return {Promise}                - 当回调done未定义的时候, 才返回Promise, 如果定义则返回undefined
       * */
      scrollTo (x, y, duration = 300, done) {
        return this.scrollView.scrollTo(x, y, duration, done)
      },
      /**
       * @function scrollToTop
       * @description
       * 滚动到顶部
       * @param {Number} [duration=300] - 滚动动画的时间, 默认是300ms
       * @return {Promise} 当滚动动画完毕后返回promise
       */
      scrollToTop (duration = 300) {
        return this.scrollView.scrollToTop(duration)
      },
      /**
       *
       * @function scrollToBottom
       * @description
       * 滚动到顶部
       * @param {Number} [duration=300] - 滚动动画的时间, 默认是300ms
       * @return {Promise} 当滚动动画完毕后返回promise
       */
      scrollToBottom (duration = 300) {
        return this.scrollView.scrollToBottom(duration)
      },

      /**
       * @function scrollBy
       * @description
       * 滚动到指定位置, 这个和scrollTo类似, 只不过是相对当前位置的滚动
       *
       * ```
       * 当前位置为scrollTop为`100px`, 执行`myScroll.scrollBy(0, -10)`, 则滚动到`110px`位置
       * ```
       *
       * @param {Number} x                - 滚动到指定位置的x值
       * @param {Number} y                - 滚动到指定位置的y值
       * @param {Number} [duration=300]   - 滚动动画的时间
       * @param {Function=} done          - 当滚动结束时触发的回调
       * @return {Promise}                - 当回调done未定义的时候, 才返回Promise, 如果定义则返回undefined
       * */
      scrollBy (x, y, duration = 300, done) {
        return this.scrollView.scrollBy(x, y, duration, done)
      },

      /**
       * @function scrollToElement
       * @description
       * 滚动到指定元素
       * @param {Element} el
       * @param {Number} [duration=300]   - 滚动动画的时间
       * @param {Number} [offsetX=0]
       * @param {Number} [offsetY=0]
       * @param {Function=} done          - 当滚动结束时触发的回调
       * @return {Promise}                - 当回调done未定义的时候, 才返回Promise, 如果定义则返回undefined
       * */
      scrollToElement (el, duration = 300, offsetX = 0, offsetY = 0, done) {
        return this.scrollView.scrollToElement(el, duration, offsetX, offsetY, done)
      },

      /**
       * 重新获取Footer/Header尺寸
       * @private
       * */
      $_recalculateBarDimensions () {
        const STYLE_TOP = 'marginTop'
        const STYLE_BOTTOM = 'marginBottom'

        if (this.headerComponent) {
          let ele = this.headerComponent.$el
          this.headerBarHeight = parsePxUnit(window.getComputedStyle(ele).height)
        }
        if (this.footerComponent) {
          let ele = this.footerComponent.$el
          this.footerBarHeight = parsePxUnit(window.getComputedStyle(ele).height)
        }

        this.scrollElementStyle = {
          [STYLE_TOP]: cssFormat(this.headerBarHeight),
          [STYLE_BOTTOM]: cssFormat(this.footerBarHeight)
        }

        this.fixedElementStyle = {
          [STYLE_TOP]: cssFormat(this.headerBarHeight),
          [STYLE_BOTTOM]: cssFormat(this.footerBarHeight)
        }

        // scrollElement 尺寸计算
        if (this.scrollView && this.scrollView.ev) {
          this.scrollView.ev.contentHeight = this.scrollElement.clientHeight - this.headerBarHeight - this.footerBarHeight
          this.scrollView.ev.contentTop = this.headerBarHeight
          this.scrollView.ev.contentWidth = this.scrollElement.clientWidth
        }

        // 盒子布局不需要min-height
        if (!this.isBox && this.scrollElementStyle) {
          this.scrollElementStyle.minHeight = cssFormat(window.innerHeight - this.headerBarHeight - this.footerBarHeight)
        }
      },

      /**
       * 重新计算Content组件的尺寸维度
       * 因为这部分受以下因素影响:Header,Footer
       * @private
       * */
      $_recalculateContentDimensions () {
        // 计算Header/Footer的高度尺寸
        this.$_recalculateBarDimensions()

        // 流式布局, init(获取尺寸的元素, 监听滚动的元素)
        if (this.isBox) {
          this.scrollView.init(this.scrollElement, this.scrollElement)
        } else {
          this.scrollView.init(document.documentElement, window)
        }

        // initial imgs refresh
        this.$_imgUpdate(this.scrollView.ev)
      },

      // -------- For Refresher Component --------
      /**
       * 获取scrollElement元素的Dom
       * @private
       * */
      $_getScrollElement () {
        return this.scrollElement
      },

      /**
       * 滚动结束的事件回调
       * @param {function} callback - 过渡结束的回调, 回调传参TransitionEvent
       * @private
       */
      $_onScrollElementTransitionEnd (callback) {
        transitionEnd(this.scrollElement, callback)
      },

      /**
       * 在scrollElement上设置属性
       * @param {string} prop - 属性名称
       * @param {any} val     - 属性值
       * @private
       */
      $_setScrollElementStyle (prop, val) {
        if (this.scrollElement) {
          this.$nextTick(() => {
            this.scrollElement.style[prop] = val
          })
        }
      },

      // -------- For Img Component --------
      /**
       * @param {object} img - Img组件的实例
       * @private
       */
      $_addImg (img) {
        this.imgs.push(img)
      },

      /**
       * Img组件更新
       * @private
       */
      $_imgUpdate (ev) {
        if (ev && this.scrollView.initialized && this.imgs.length && this.$_isImgUpdatable()) {
          this.$nextTick(() => {
            updateImgs(this.imgs, ev.scrollTop, ev.contentHeight, ev.directionY, this.imgReqBfr, this.imgRndBfr)
          })
        }
      },
      /**
       * @private
       * */
      $_isImgUpdatable () {
        // 当滚动不是太快的时候, Img组件更新才被允许, 这个速度由this.imgVelMax控制
        return Math.abs(this.scrollView.ev.velocityY) < this.imgVelMax
      }
    },
    created () {
      // 置顶
      window.scrollTo(0, 0)

      // 窗口变化重新计算容器
      this.resizeUnReg = registerListener(window, 'resize', throttle(() => {
        // 计算并设置当前Content的位置及尺寸
        this.$root.$emit('window:resize')
        this.$_recalculateBarDimensions()
      }, 200, {
        leading: false,
        trailing: true
      }))

      /**
       * @event component:Content#onScrollStart
       * @description 滚动开始时触发的事件
       * @property {ScrollEvent} ev - 滚动事件对象
       */
      this.scrollView.scrollStart = (ev) => {
        this.$emit('onScrollStart', ev)
        this.$root && this.$root.$emit('onScrollStart', ev)
      }

      /**
       * @event component:Content#onScroll
       * @description 滚动时触发的事件
       * @property {ScrollEvent} ev - 滚动事件对象
       */
      this.scrollView.scroll = (ev) => {
        this.$emit('onScroll', ev)
        this.$root && this.$root.$emit('onScroll', ev)
        this.$_imgUpdate(ev)
      }

      /**
       * @event component:Content#onScrollEnd
       * @description 滚动结束时触发的事件
       * @property {ScrollEvent} ev - 滚动事件对象
       */
      this.scrollView.scrollEnd = (ev) => {
        this.$emit('onScrollEnd', ev)
        this.$root && this.$root.$emit('onScrollEnd', ev)
        this.$_imgUpdate(ev)
      }

      this.$root.$emit('content:created', this)
    },
    mounted () {
      // fix业务将slot的name贴到attr上, 便于class样式处理
      addSlotNameToAttr(this.$slots)

      // 计算并设置当前Content的位置及尺寸
      this.$_recalculateContentDimensions()

      this.$root.$emit('content:mounted', this)
    },
    destroyed () {
      this.resizeUnReg && this.resizeUnReg()
      this.scrollView.destroy()
    }
  }

  /**
   * @typedef {Object} ContentDimension   - Content组件的维度尺寸信息
   * @property {number} contentHeight     - content offsetHeight,           content自身高度
   * @property {number} contentTop        - content offsetTop,               content到窗体顶部的距离
   * @property {number} contentBottom     - content offsetTop+offsetHeight,  content底部到窗体顶部的的距离
   * @property {number} contentWidth      - content offsetWidth
   * @property {number} contentLeft       - content contentLeft
   * @property {number} contentRight      - content offsetLeft + offsetWidth
   * @property {number} scrollHeight      - scroll scrollHeight
   * @property {number} scrollTop         - scroll scrollTop
   * @property {number} scrollBottom      - scroll scrollTop + scrollHeight
   * @property {number} scrollWidth       - scroll scrollWidth
   * @property {number} scrollLeft        - scroll scrollLeft
   * @property {number} scrollRight       - scroll scrollLeft + scrollWidth
   * */

  /**
   * @typedef {Object} ScrollEvent            - 滚动事件返回的滚动对象
   * @property {number} timeStamp             - 滚动事件
   * @property {number} scrollTop             -
   * @property {number} scrollLeft            -
   * @property {number} scrollHeight          -
   * @property {number} scrollWidth           -
   * @property {number} contentHeight         -
   * @property {number} contentWidth          -
   * @property {number} contentTop            -
   * @property {number} contentBottom         -
   * @property {number} startY                -
   * @property {number} startX                -
   * @property {number} deltaY                -
   * @property {number} deltaX                -
   * @property {number} velocityY             -
   * @property {number} velocityX             -
   * @property {number} directionY            -
   * @property {number} directionX            -
   * */
</script>