<template>
<div class="ion-refresher" :class="{'refresher-active':(state !== 'inactive')}" :style="{'top':top}" :state="state">
<slot></slot>
</div>
</template>
<style lang="scss" src="./style.scss"></style>
<script type="text/javascript">
/**
* @component Refresher
* @description
*
* ## 数据加载 / Refresher下拉刷新组件
*
* ### 说明
*
* Refresher组件是在Content组件下使用, 并提供了下拉刷新的功能. 使用前需要将Refresher组件放在Content组件内所有内容的前面, 并加上`slot="refresher"`属性. 如果Refresher组件使用完毕希望禁用, 请使用`enabled`属性, 而不是使用`v-if`指令.
*
* ### 事件
*
* 事件传递组件的this, 可用的两个方法为: complete/cancel. 当然可以使用ref获取组件的实例
*
* ### 注意
*
* 目前这个组件只适合在原生滚动下使用, 当使用js滚动时会异常
*
* ### 关于指示器RefresherContent
*
* 这个组件是可以定制化的
*
* @props {Number} [closeDuration=280] - 回复到 inactive 状态的动画时间
* @props {Boolean} [enabled=true] - 组件是否可用
* @props {Number} [pullMax=200] - 下拉的最大值, 超过则直接进入 refreshing状态
* @props {Number} [pullMin=70] - 下拉进入refreshing状态的最小值
* @props {Number} [snapbackDuration=280] - 回复到 refreshing 状态的动画时间
*
* @fires component:Refresher#onRefresh - 进入 refreshing 状态时触发, 事件传递组件的this
* @fires component:Refresher#onPull - 下拉并且看到了refresher, 事件传递组件的this
* @fires component:Refresher#onStart - 开始下拉的事件, 事件传递组件的this
*
* @slot 空 - 指示器RefresherContent组件的插槽
*
* @demo #/refresher
* @see component:Content
* @usage
* <Page>
* <Header>
* <Navbar>
* <Title>Refresher</Title>
* </Navbar>
* </Header>
* <Content>
* <Refresher slot="refresher" @onRefresh="doRefresh($event)" @onPull="doPulling($event)">
* <RefresherContent pullingText="下拉刷新..." refreshingText="正在刷新..."></RefresherContent>
* </Refresher>
* <List>
* <Item v-for="i in list">{{i}}</Item>
* </List>
* </Content>
* </Page>
*
*
* // ...
*
* methods: {
* doRefresh(ins){
* let _start = this.i;
* setTimeout(() => {
* for (; (10 + _start) > this.i; this.i++) {
* this.list.unshift(`item - ${this.i}`)
* }
* // 当前异步完成
* ins.complete();
* console.debug('onInfinite-complete')
* }, 500)
* },
* },
* */
import { pointerCoord } from '../../util/util'
import registerListener from '../../util/register-listener'
import css from '../../util/get-css'
import urlChange from '../../util/url-change'
const STATE_INACTIVE = 'inactive'
const STATE_PULLING = 'pulling'
const STATE_READY = 'ready'
const STATE_REFRESHING = 'refreshing'
const STATE_CANCELLING = 'cancelling'
const STATE_COMPLETING = 'completing'
const DAMP = 0.5// 滑动阻尼
export default {
name: 'Refresher',
// Content组件的实例注入
inject: ['contentComponent', 'appComponent'],
data () {
return {
// -------- public --------
/**
* @name state
* @type {String}
* @description Refresher 的状态, 可以使下面的一个值:
* - `inactive` - Refresher 当前时隐藏状态, 没有下拉 或者 刷新
* - `pulling` - Refresher 当前正在被下拉, 但是还没达到触发刷新的点
* - `cancelling` - 还没达到触发刷新的点时, 用户松手, 动画恢复完毕后改为`inactive`状态
* - `ready` - 用户已经下拉到达触发点, 如果用户松手, 则进入`refreshing`状态
* - `refreshing` - Refresher 处于刷新状态, 等待异步操作的完成. 一旦执行了refresh的 `complete()` 方法, Refresher 将进入 `completing` 状态.
* - `completing` - Refresher 的`refreshing` 状态结束, Refresher之后会执行关闭的动画, 如果动画结束, 则回退到`inactive` 状态.
* */
state: STATE_INACTIVE,
/**
* @name startY
* @type {Number}
* @description 下拉的起始点
* */
startY: null, // 下拉的起始点
/**
* @name currentY
* @type {Number}
* @description 当前的点
* */
currentY: null, // 当前的点
/**
* @name deltaY
* @type {Number}
* @description 起始点和当前点的距离
* */
deltaY: null, // 起始点和当前点的距离
/**
* @name progress
* @type {Number}
* @description 表示对当前进度.
* - 0: 原始状态,为下拉
* - =1: 到达refreshing状态的最小值
* - \>1: 超过refreshing状态的最小值
* */
progress: null, // 表示对当前进度. 0:原始状态,为下拉; >1: 刷新开始
// -------- private --------
unReg: null,
appliedStyles: false,
didStart: false,
lastCheck: 0,
unregs: [], // 解绑的事件列表
damp: DAMP, // 滑动阻尼
top: null // style的top
}
},
props: {
// 回复到 inactive 状态的时间
closeDuration: {
type: Number,
default: 280
},
enabled: {
type: Boolean,
default: true
},
// 下拉的最大值, 超过则拉不动
pullMax: {
type: Number,
default: 200
},
// 下拉进入refreshing状态的最小值
pullMin: {
type: Number,
default: 70
},
// 回复到 refreshing 状态的时间
snapbackDuration: {
type: Number,
default: 280
}
},
watch: {
enabled (val) {
this.$_setListeners(val)
},
state (val) {
if (val === STATE_INACTIVE) {
this.appComponent && this.appComponent.setDisableScroll(false)
}
if (val === STATE_PULLING) {
this.appComponent && this.appComponent.setDisableScroll(true)
}
}
},
methods: {
/**
* @function complete
* @description
* 异步数据请求成功后, 调用这个方法; refresher将会关闭,
* 状态由`refreshing` -> `completing`.
*/
complete () {
this.$_closeRefresher(STATE_COMPLETING, '120ms')
// 重新计算尺寸, 必须
this.contentComponent && this.contentComponent.resize()
},
/**
* @function cancel
* @description
* 取消 refresher, 其状态由`refreshing` -> `cancelling`
*/
cancel () {
this.$_closeRefresher(STATE_CANCELLING, '')
},
/**
* @param {Boolean} shouldListen -
* */
$_setListeners (shouldListen) {
// 如果解绑函数存在则全部解绑
if (this.unregs && this.unregs.length > 0) {
this.unregs.forEach((_unreg) => {
_unreg && _unreg()
})
}
// 如果为true, 则添加事件监听
// 等待Content完毕
this.$nextTick(() => {
if (shouldListen && this.contentComponent) {
let contentElement = this.contentComponent.$_getScrollElement()
console.assert(contentElement, 'Refresh Component need Content Ready!::<Component>$_setListeners()')
// TODO: 对于点击事件应该同统一封装一层
registerListener(contentElement, 'touchstart', this.$_pointerDownHandler.bind(this), {'passive': false}, this.unregs)
registerListener(contentElement, 'mousedown', this.$_pointerDownHandler.bind(this), {'passive': false}, this.unregs)
registerListener(contentElement, 'touchmove', this.$_pointerMoveHandler.bind(this), {'passive': false}, this.unregs)
registerListener(contentElement, 'mousemove', this.$_pointerMoveHandler.bind(this), {'passive': false}, this.unregs)
registerListener(contentElement, 'touchend', this.$_pointerUpHandler.bind(this), {'passive': false}, this.unregs)
registerListener(contentElement, 'mouseup', this.$_pointerUpHandler.bind(this), {'passive': false}, this.unregs)
}
})
},
/**
* @param {TouchEvent} ev - 点击事件
* */
$_pointerDownHandler (ev) {
// 如果多点触摸, 则直接返回
if (ev.touches && ev.touches.length > 1) {
return false
}
if (this.state !== STATE_INACTIVE) {
return false
}
let scrollHostScrollTop = this.contentComponent.scrollView.getTop()
// 如果当前的scrollTop大于0, 意味着页面在别的位置, 不是停在顶部, 不是在refresher状态, 直接退出
if (scrollHostScrollTop > 0) {
return false
}
let coord = pointerCoord(ev)
// 记录开始位置
this.startY = this.currentY = coord.y
this.progress = 0
this.state = STATE_INACTIVE
return true
},
/**
* @param {TouchEvent} ev - 点击事件
* */
$_pointerMoveHandler (ev) {
// 滑动部分的处理函数在滑动过程中会执行很多次, 因此会有节流的处理,
// 另外DOM的操作除非必要, 否则不进行
// 多点触摸则直接返回
if (ev.touches && ev.touches.length > 1) {
return 1
}
// 如果页面在滚动, 或者scrollTop != 0
if (this.contentComponent && this.contentComponent.isScrolling) {
return 9
}
// 不能存在scrollTop为负的情况
if (this.contentComponent && this.contentComponent.scrollView && this.contentComponent.scrollView.ev.scrollTop < 0) {
// fix: 有些机型存在scrollTop为负的情况
return 10
}
// 以下状态将返回
// 没有开始位置/正在refreshing状态/正在closing(cancelling/completing)
if (this.startY === null || this.state === STATE_REFRESHING || this.state === STATE_CANCELLING || this.state === STATE_COMPLETING) {
return 2
}
// 在16ms之内如果连续触发,则不处理(节流)
let now = Date.now()
const T = 16
if (this.lastCheck + T > now) {
return 3
}
// 节流 -- 记录上次的节流时间
this.lastCheck = now
let coord = pointerCoord(ev)
this.currentY = coord.y
// 记录拉动的距离
this.deltaY = (coord.y - this.startY)
// 当前是向上滑动, 所以应该设置state为inactive
if (this.deltaY <= 0) {
// 这个是向上滚动, 没使用 refresher 的功能, 故现在是inactive状态
this.progress = 0
if (this.state !== STATE_INACTIVE) {
this.state = STATE_INACTIVE
}
if (this.appliedStyles) {
// reset the styles only if they were applied
this.$_setCss(0, '', false, '') // y/duration/overflow/delay
return 5
}
return 6
}
// 正式进入 refresher
if (this.state === STATE_INACTIVE) {
let scrollHostScrollTop = this.contentComponent.scrollView.ev.scrollTop
if (scrollHostScrollTop > 0) {
this.progress = 0
this.startY = null
return 7
}
// 步入正题
this.state = STATE_PULLING
}
if (!this.deltaY) {
this.progress = 0
return 8
}
// prevent native scroll events
ev.preventDefault()
// 进入 pulling 状态, 开始移动scroll element
this.$_setCss((this.deltaY * this.damp), '0ms', true, '')
// 进行滚动吧
this.$_goScroll()
},
$_goScroll () {
// set pull progress
this.progress = (this.deltaY * this.damp / this.pullMin)
// 对外发送 onStart 事件
if (!this.didStart) {
this.didStart = true
/**
* @event component:Refresher#onStart
* @description 开始下拉的事件, 事件传递组件的this
* @property {Component} this - 组件实例
*/
this.$emit('onStart', this)
}
// 对外发送 onPull 事件
/**
* @event component:Refresher#onPull
* @description 下拉并且看到了refresher, 事件传递组件的this
* @property {Component} this - 组件实例
*/
this.$emit('onPull', this)
// do nothing if the delta is less than the pull threshold
if (this.deltaY * this.damp < this.pullMin) {
// ensure it stays in the pulling state, cuz its not ready yet
this.state = STATE_PULLING
return 2
}
if (this.deltaY * this.damp > this.pullMax) {
// they pulled farther than the max, so kick off the refresh
this.$_beginRefresh()
return 3
}
// 正好在最大和最小值之间, 进入 ready 状态吧
this.state = STATE_READY
return 4
},
$_pointerUpHandler () {
if (this.state === STATE_READY) {
this.$_beginRefresh()
} else if (this.state === STATE_PULLING) {
// 返回原点
this.cancel()
}
// reset
this.startY = null
},
$_beginRefresh () {
this.state = STATE_REFRESHING
// 将content放置在开口处
this.$_setCss(this.pullMin, (this.snapbackDuration + 'ms'), true, '')
// 发送事件 onRefresh
/**
* @event component:Refresher#onRefresh
* @description 进入 refreshing 状态时触发, 事件传递组件的this
* @property {Component} this - 组件实例
*/
this.$emit('onRefresh', this)
},
/**
* @param {string} state
* @param {string} delay
* */
$_closeRefresher (state, delay) {
var timer // number
function close (ev) {
// closing is done, return to inactive state
if (ev) {
clearTimeout(timer)
}
this.state = STATE_INACTIVE
this.progress = 0
this.didStart = this.startY = this.currentY = this.deltaY = null
this.$_setCss(0, '0ms', false, '')
}
// create fallback timer incase something goes wrong with transitionEnd event
timer = window.setTimeout(close.bind(this), 600)
// create transition end event on the content's scroll element
this.contentComponent.$_onScrollElementTransitionEnd(close.bind(this))
// reset set the styles on the scroll element
// set that the refresh is actively cancelling/completing
this.state = state
this.$_setCss(0, '', true, delay)
},
/**
* @param {Number} y - 纵坐标
* @param {String} duration - duration
* @param {Boolean} overflowVisible - overflowVisible
* @param {String} delay - delay
**/
$_setCss (y, duration, overflowVisible, delay) {
this.appliedStyles = (y > 0)
if (this.contentComponent) {
this.contentComponent.$_setScrollElementStyle(css.transform, ((y > 0) ? 'translateY(' + y + 'px) translateZ(0px)' : 'translateZ(0px)'))
this.contentComponent.$_setScrollElementStyle(css.transitionDuration, duration)
this.contentComponent.$_setScrollElementStyle(css.transitionDelay, delay)
this.contentComponent.$_setScrollElementStyle('overflow', (overflowVisible ? 'hidden' : ''))
}
}
},
created () {
this.unReg = urlChange(() => {
this.$app && this.$app.$_enableScroll && this.$app.$_enableScroll()
this.unReg && this.unReg()
this.cancel()
})
},
mounted () {
if (this.contentComponent) {
this.contentComponent.refreshClass['has-refresher'] = true
}
this.$_setListeners(this.enabled)
this.$root.$on('content:mounted', () => {
// refresher定位
if (this.contentComponent) {
let contentTop = this.contentComponent.headerBarHeight || 0
this.top = `${contentTop}px`
}
})
}
}
</script>