<template>
<div content class="ion-content" :class="['content-'+mode, hasRefresher?'has-refresher':'']">
<div ref="fixedElement" class="fixed-content" :style="fixedElementStyle">
<!--Fixed-->
<slot name="fixed"></slot>
<!--Fixed Top-->
<slot name="fixedTop"></slot>
<!--Fixed Bottom-->
<slot name="fixedBottom"></slot>
</div>
<div ref="scrollElement" class="scroll-content" :style="scrollElementStyle">
<!--默认是能滚动的内容-->
<!--原生滚动-->
<slot></slot>
</div>
<slot name="refresher"></slot>
</div>
</template>
<style lang="scss">
@import "content";
@import "content.ios";
@import "content.md";
</style>
<script type="text/javascript">
/**
* @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 -
* @property {HTMLElement} contentElement -
* @property {HTMLElement} fixedElement -
* @property {HTMLElement} scrollElement -
* @property {HTMLElement} scrollElement -
* @property {HTMLElement} headerElement -
* @property {HTMLElement} footerElement -
* */
/**
* @component Content
* @description
*
* ## 基础组件 / Content组件
*
* Vimo框架的页面基础布局分为Header/Content/Footer三个部分, 也就是"上中下三明治"结构布局, Content组件则是中间业务内容的位置.
*
* Content组件中书写的代码可以是滚动的内容, 也可以是固定在一面不随滚动的内容, 比如说当页的广告/刷新按钮/歌词等. 这个特性的的开启需要特殊命名slot才能激活.
*
* 此外需要注意的是, 一个页面(Page组件)中只能有一个Content组件, 这个是Vimo使用的规则!
*
* Content组件中也可以加入下拉刷新和上拉加载的功能, 具体请参考示例.
*
* ## 不需要引入
*
* 是的, 基础组件是安装vimo后自动全局注册的.
*
* @demo #/content
*
* @slot 空 slot为空则将内容插入到scroll中
* @slot [fixed] 默认值, 固定到顶部
* @slot [fixedTop] 固定到顶部
* @slot [fixedBottom] 固定到底部
* @slot [refresher] refresher组件的位置
*
* @props {boolean} [fullscreen=false] - 控制Content是否全屏显示, 如果为true, 则Content的上下将延伸到Header和Footer的下面
* @props {boolean} [recordPosition=false] - 控制Content组件是否记录其浏览位置, 这个可在config中设置, 默认为false, 建议无限滚动页面设置false
*
* @fires component:Content#onScrollStart
* @fires component:Content#onScroll
* @fires component:Content#onScrollEnd
*
*
* @usage
* <template>
* <vm-page>
* <vm-header>
* <vm-navbar>
* <vm-title>Demo</vm-title>
* <vm-navbar>
* </vm-header>
* <vm-content record-position>
* <h1>这里是内容</h1>
* <p>滚动位置将会被记录</p>
* </vm-content>
* </vm-page>
* </template>
*
* */
import { transitionEnd, parsePxUnit, isPresent } from '../../util/util'
import ThemeMixins from '../../themes/theme.mixins'
import { ScrollView } from './scroll-view'
export default {
name: 'vm-content',
mixins: [ThemeMixins],
props: {
fullscreen: Boolean,
recordPosition: {
type: Boolean,
default () { return this.$config && this.$config.getBoolean('recordPosition', false) || false }
}
},
data () {
return {
isFullscreen: this.fullscreen,
hasRefresher: false,
fixedElementStyle: {}, // 固定内容的位置样式
scrollElementStyle: {}, // 滑动内容的位置样式
scrollElement: null, // scrollConent的DOM句柄
fixedElement: null, // fixedElement的DOM句柄
headerElement: null, // Header组件的DOM句柄
footerElement: null, // footer组件的DOM句柄
tabsElement: null, // tabs组件的DOM句柄
headerBarHeight: 0,
footerBarHeight: 0,
_scroll: null, // 滚动的实例
_cTop: 0, // content top
_cBottom: 0, // content bottom
_fTop: 0, // fixex top
_fBottom: 0, // fixex bottom
_pTop: 0, // padding top
_pBottom: 0, // padding bottom
_imgs: [], // 子组件Img的实例列表
_imgReqBfr: 0, // 1400
_imgRndBfr: 0, // 400
_imgVelMax: 0
}
},
watch: {
// 当值改变是,重新设置
fullscreen () {
this.isFullscreen = this.fullscreen
this.recalculateContentDimensions()
}
},
methods: {
// -------- public --------
/**
* @function getContentDimensions
* @description
* 返回滚动元素的维度信息
* @return {ContentDimension}
* */
getContentDimensions () {
console.assert(this._scroll, 'The method getContentDimensions() need _scroll instance, please check!')
if (!this._scroll) return
const scrollEle = this.scrollElement
const parentElement = scrollEle.parentElement
let contentHeight = parentElement.offsetHeight - this._cTop - this._cBottom
let contentTop = this._cTop
let contentBottom = parentElement.offsetHeight - this._cBottom
let contentWidth = parentElement.offsetWidth
let contentLeft = parentElement.offsetLeft
return {
contentHeight,
contentTop,
contentBottom,
contentWidth,
contentLeft,
scrollHeight: this._scroll.getHeight(),
scrollTop: this._scroll.getTop(),
scrollWidth: this._scroll.getWidth(),
scrollLeft: this._scroll.getLeft()
}
},
/**
* @function resize
* @description
* 当动态添加Header/Footer/Tabs或者修改了他的属性时, 重新计算Content组件的尺寸.
* */
resize () {
// 等待DOM更新完毕
this.$nextTick(() => {
this.recalculateContentDimensions()
})
},
/**
* @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._scroll.scrollTo(x, y, duration, done)
},
/**
* @function scrollToTop
* @description
* 滚动到顶部
* @param {Number} [duration=300] - 滚动动画的时间, 默认是300ms
* @return {promise} 当滚动动画完毕后返回promise
*/
scrollToTop (duration = 300) {
// 页面防止点击
this.$app && this.$app.setDisableScroll(true, duration)
return this._scroll.scrollToTop(duration)
},
/**
*
* @function scrollToBottom
* @description
* 滚动到顶部
* @param {Number} [duration=300] - 滚动动画的时间, 默认是300ms
* @return {promise} 当滚动动画完毕后返回promise
*/
scrollToBottom (duration = 300) {
// 页面防止点击
this.$app && this.$app.setDisableScroll(true, duration)
return this._scroll.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) {
this.$app && this.$app.setDisableScroll(true, duration)
return this._scroll.scrollBy(x, y, duration, done)
},
/**
* @function scrollToElement
* @description
* 滚动到指定元素
* @param {Number} 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) {
this.$app && this.$app.setDisableScroll(true, duration)
return this._scroll.scrollToElement(el, duration, offsetX, offsetY, done)
},
// -------- private --------
/**
* DOM完毕后进行初始化
* @private
* */
init () {
if (this.scrollElement) return
const scroll = this._scroll // 滚动的实例
/**
* 找到fixedElement/scrollElement的位置
* */
scroll.ev.fixedElement = this.fixedElement = this.$refs.fixedElement
scroll.ev.scrollElement = this.scrollElement = this.$refs.scrollElement
/**
* @event component:Content#onScrollStart
* @description 滚动开始时触发的事件
* @property {ScrollEvent} ev - 滚动事件对象
*/
scroll.scrollStart = (ev) => {
this.$emit('onScrollStart', ev)
this.$eventBus && this.$eventBus.$emit('onScrollStart', ev)
}
/**
* @event component:Content#onScroll
* @description 滚动时触发的事件
* @property {ScrollEvent} ev - 滚动事件对象
*/
scroll.scroll = (ev) => {
this.$emit('onScroll', ev)
this.$eventBus && this.$eventBus.$emit('onScroll', ev)
// img更新
this.imgsUpdate()
this.recordScrollPosition(ev)
}
/**
* @event component:Content#onScrollEnd
* @description 滚动结束时触发的事件
* @property {ScrollEvent} ev - 滚动事件对象
*/
scroll.scrollEnd = (ev) => {
this.$emit('onScrollEnd', ev)
this.$eventBus && this.$eventBus.$emit('onScrollEnd', ev)
// img更新
this.imgsUpdate()
}
/**
* 计算并设置当前Content的位置及尺寸
* */
this.recalculateContentDimensions()
},
/**
* 重新计算Content组件的尺寸维度
* 因为这部分受以下因素影响:fullscreen、Header,Footer
* @private
* */
recalculateContentDimensions () {
const scrollEle = this.scrollElement
if (!scrollEle) {
console.assert(false, 'this.scrollElement should be valid')
return
}
const fixedEle = this.fixedElement
if (!fixedEle) {
console.assert(false, 'this.fixedElement should be valid')
return
}
// 如果滚动实例_scroll不存在
// 则直接返回
if (!this._scroll) return
let scrollEvent = this._scroll.ev
let ele = this.$el // HTMLElement
// 获取Footer/Header信息
let computedStyle
let tagName
let children = this.$parent.$children
children.forEach((child) => {
ele = child.$el
tagName = child.$options._componentTag.toLowerCase()
if (tagName === 'vm-header') {
this.headerElement = scrollEvent.headerElement = ele
computedStyle = window.getComputedStyle(this.headerElement)
this.headerBarHeight = parsePxUnit(computedStyle.height)
} else if (tagName === 'vm-footer') {
this.footerElement = scrollEvent.footerElement = ele
computedStyle = window.getComputedStyle(this.footerElement)
this.footerBarHeight = parsePxUnit(computedStyle.height)
}
})
// 获取Content信息
scrollEvent.contentElement = this.$el
if (this.isFullscreen) {
computedStyle = window.getComputedStyle(this.$el)
this._pTop = parsePxUnit(computedStyle.paddingTop)
this._pBottom = parsePxUnit(computedStyle.paddingBottom)
}
// Content的位置值
this._cTop = this.headerBarHeight // contentTop
this._cBottom = this.footerBarHeight
// Fixed的位置值和Content相等, 但是属性可能不一样
this._fTop = this._cTop
this._fBottom = this._cBottom
// 如果是fullscreen的话,padding还需要计算之前的值, 一般为16px
if (this.isFullscreen) {
this._cTop += this._pTop
this._cBottom += this._pBottom
}
// 默认为fullscreen未开启状态, 使用margin属性
let topProperty = 'marginTop'
let bottomProperty = 'marginBottom'
let fixedTop = this._fTop
let fixedBottom = this._fBottom
// 如果fullscreen开启, 使用padding属性
if (this.isFullscreen) {
console.assert(this._pTop >= 0, '_paddingTop has to be positive')
console.assert(this._pBottom >= 0, '_paddingBottom has to be positive')
// 调整Content组件的padding属性, 使得content中的内容在Header和Footer下方滚动,
topProperty = 'paddingTop'
bottomProperty = 'paddingBottom'
}
// update top margin if value changed
console.assert(this._cTop >= 0, 'contentTop has to be positive')
console.assert(fixedTop >= 0, 'fixedTop has to be positive');
(scrollEle.style)[topProperty] = cssFormat(this._cTop)
fixedEle.style.marginTop = cssFormat(fixedTop)
// update bottom margin if value changed
console.assert(this._cBottom >= 0, 'contentBottom has to be positive')
console.assert(fixedBottom >= 0, 'fixedBottom has to be positive');
(scrollEle.style)[bottomProperty] = cssFormat(this._cBottom)
fixedEle.style.marginBottom = cssFormat(fixedBottom)
// 初始化_scroll滚动对象
this._scroll.init(this.scrollElement)
// 计算Content组件的维度信息, 写入scrollEvent中, 只是初始化的信息
const contentDimensions = this.getContentDimensions()
scrollEvent.scrollHeight = contentDimensions.scrollHeight
scrollEvent.scrollWidth = contentDimensions.scrollWidth
scrollEvent.contentHeight = contentDimensions.contentHeight
scrollEvent.contentWidth = contentDimensions.contentWidth
scrollEvent.contentTop = contentDimensions.contentTop
scrollEvent.contentBottom = contentDimensions.contentBottom
// initial imgs refresh
this.imgsUpdate()
},
// -------- 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)
},
/**
* @param {object} img - Img组件的实例
* @private
*/
removeImg (img) {
removeArrayItem(this._imgs, img)
},
/**
* Img组件更新
* @private
*/
imgsUpdate () {
if (this._scroll.initialized && this._imgs.length && this.isImgsUpdatable()) {
let contentDimensions = this.getContentDimensions()
this.$nextTick(() => {
updateImgs(this._imgs, contentDimensions.scrollTop, contentDimensions.contentHeight, contentDimensions.directionY, this._imgReqBfr, this._imgRndBfr)
})
}
},
/**
* @private
* */
isImgsUpdatable () {
// 当滚动不是太快的时候, Img组件更新才被允许, 这个速度由this.imgVelMax控制
return Math.abs(this._scroll.ev.velocityY) < this._imgVelMax
},
/**
* 记录滚动位置
* @private
* */
recordScrollPosition (ev) {
if (this.recordPosition && this.$route) {
// vm-scroll-position-${id} -> 位置
let id = this.$route.name || this.$route.path
window.sessionStorage.setItem(`vm-scroll-position-${id}`, ev.scrollTop)
}
},
/**
* 滚动到记录到页面滚动位置
* @private
* */
toRecordScrollPosition () {
if (this.recordPosition && this.$route) {
let id = this.$route.name || this.$route.path
if (this.$history.getDirection() === 'backward') {
let scrollPositionStr = window.sessionStorage.getItem(`vm-scroll-position-${id}`)
let scrollPosition = parseInt(scrollPositionStr)
this.$nextTick(() => {
this.scrollTo(0, scrollPosition, 0)
})
} else {
window.sessionStorage.removeItem(`vm-scroll-position-${id}`)
}
}
}
},
created () {
// 页面进入前完成非DOM操作部分
this._imgReqBfr = this.$config && this.$config.getNumber('imgRequestBuffer', 1400)
this._imgRndBfr = this.$config && this.$config.getNumber('imgRenderBuffer', 600)
this._imgVelMax = this.$config && this.$config.getNumber('imgVelocityMax', 3)
this._scroll = new ScrollView()
this._imgs = []
this.hasRefresher = this.$slots && isPresent(this.$slots['refresher']);
},
mounted () {
this.init()
// 为slot="fixed"/slot="fixedTop"/slot="fixedBottom"的沟槽设定属性
if (this.$slots && this.$slots['fixed']) {
this.$slots['fixed'].forEach(function (item) {
item.elm.setAttribute('fixed', '')
})
}
if (this.$slots && this.$slots['fixedTop']) {
this.$slots['fixedTop'].forEach(function (item) {
item.elm.setAttribute('fixed', '')
item.elm.setAttribute('fixedTop', '')
})
}
if (this.$slots && this.$slots['fixedBottom']) {
this.$slots['fixedBottom'].forEach(function (item) {
item.elm.setAttribute('fixed', '')
item.elm.setAttribute('fixedBottom', '')
})
}
this.toRecordScrollPosition()
},
activated () {
this.toRecordScrollPosition()
},
deactivate () {},
destroyed () {
this._scroll.destroy()
}
}
/**
* 移除array中的某个item
* @param {any} array
* @param {any} item
* @private
*/
function removeArrayItem (array, item) {
const index = array.indexOf(item)
// ~index => index*(-1)-1
// ~-1 => 0
return ~index && array.splice(index, 1)
}
/**
* 添加px后缀
* @param {string} val
* @return {string}
* @private
* */
function cssFormat (val) {
return (val > 0 ? val + 'px' : '')
}
/**
*
* 对两个img组件根据top排序
* @param {object} a - Img组件实例
* @param {object} b - Img组件实例
* @return {number}
* @private
* */
function sortTopToBottom (a, b) {
if (a.top < b.top) {
return -1
}
if (a.top > b.top) {
return 1
}
return 0
}
/**
* 更新img
* @param {Img[]} imgs
* @param {number} viewableTop
* @param {number} contentHeight
* @param {string} scrollDirectionY
* @param {number} requestableBuffer
* @param {number} renderableBuffer
* @private
* */
function updateImgs (imgs, viewableTop, contentHeight, scrollDirectionY, requestableBuffer, renderableBuffer) {
// ok, so it's time to see which images, if any, should be requested and rendered
// ultimately, if we're scrolling fast then don't bother requesting or rendering
// when scrolling is done, then it needs to do a check to see which images are
// important to request and render, and which image requests should be aborted.
// Additionally, images which are not near the viewable area should not be
// rendered at all in order to save browser resources.
const viewableBottom = (viewableTop + contentHeight)
const priority1 = [] // Img[]
const priority2 = [] // Img[]
let img // 每个Img的实例;
// all images should be paused
for (var i = 0, ilen = imgs.length; i < ilen; i++) {
img = imgs[i]
if (scrollDirectionY === 'up') {
// scrolling up
if (img.getTop() < viewableBottom && img.getBottom() > viewableTop - renderableBuffer) {
// 可视区向上移动, 图片在可是区域或者在可视区域的上面一点, 按照滚动方向即将要看到图片
img.canRequest = img.canRender = true
priority1.push(img)
continue
}
if (img.getBottom() <= viewableTop && img.getBottom() > viewableTop - requestableBuffer) {
// 可视区向上移动, 图片在可视区的上面, 还未进入, 但是需要提前发出图片请求
img.canRequest = true
img.canRender = false
priority2.push(img)
continue
}
if (img.getTop() >= viewableBottom && img.getTop() < viewableBottom + renderableBuffer) {
// 可视区向上移动, 图片在可视区的下面, 所以按照这个方向移动, 是不会再看到图片,
// 但是图片还是可能在renderable area, 故不需要reset
img.canRequest = img.canRender = false
continue
}
} else {
// scrolling down
if (img.getBottom() > viewableTop && img.getTop() < viewableBottom + renderableBuffer) {
// 可视区向下移动, 图片在可是区域或者在可视区域的下面一点, 按照滚动方向即将要看到图片
img.canRequest = img.canRender = true
priority1.push(img)
continue
}
if (img.getTop() >= viewableBottom && img.getTop() < viewableBottom + requestableBuffer) {
// 可视区向下移动, 图片在可视区的下面, 还未进入, 但是需要提前发出图片请求
img.canRequest = true
img.canRender = false
priority2.push(img)
continue
}
if (img.getBottom() <= viewableTop && img.getBottom() > viewableTop - renderableBuffer) {
// 可视区向下移动, 图片在可视区的上面, 所以按照这个方向移动, 是不会再看到图片,
// 但是图片还是可能在renderable area, 故不需要reset
img.canRequest = img.canRender = false
continue
}
}
img.canRequest = img.canRender = false
img.reset()
}
// update all imgs which are viewable
priority1.sort(sortTopToBottom).forEach(i => i.update())
if (scrollDirectionY === 'up') {
// scrolling up
priority2.sort(sortTopToBottom).reverse().forEach(i => i.update())
} else {
// scrolling down
priority2.sort(sortTopToBottom).forEach(i => i.update())
}
}
</script>