mand-mobile/components/scroll-view/index.vue

490 lines
12 KiB
Vue

<template>
<div
class="md-scroll-view"
@touchstart="$_onScrollerTouchStart"
@touchmove="$_onScrollerTouchMove"
@touchend="$_onScrollerTouchEnd"
@touchcancel="$_onScrollerTouchEnd"
@mousedown="$_onScrollerMouseDown"
@mousemove="$_onScrollerMouseMove"
@mouseup="$_onScrollerMouseUp"
@mouseleave="$_onScrollerMouseUp"
>
<div class="scroll-view-header" v-if="$slots.header">
<slot name="header"></slot>
</div>
<div
class="scroll-view-container"
:class="{
'horizon': scrollingX && !scrollingY
}"
scroll-wrapper
>
<div
v-if="hasRefresher"
class="scroll-view-refresh"
:class="{
'refreshing': isRefreshing,
'refresh-active': isRefreshActive,
}"
>
<slot
name="refresh"
:scroll-top="scrollY"
:is-refreshing="isRefreshing"
:is-refresh-active="isRefreshActive"
></slot>
</div>
<slot></slot>
<div
v-if="hasMore"
:class="{active: isEndReachingStart || isEndReaching}"
class="scroll-view-more"
>
<slot name="more" :is-end-reaching="isEndReachingStart || isEndReaching"></slot>
</div>
</div>
<div class="scroll-view-footer" v-if="$slots.footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<script>
import {debounce} from '../_util'
import Scroller from '../_util/scroller'
import {render} from '../_util/render'
export default {
name: 'md-scroll-view',
props: {
scrollingX: {
type: Boolean,
default: true,
},
scrollingY: {
type: Boolean,
default: true,
},
bouncing: {
type: Boolean,
default: true,
},
autoReflow: {
type: Boolean,
default: false,
},
manualInit: {
type: Boolean,
default: false,
},
endReachedThreshold: {
type: Number,
default: 0,
},
immediateCheckEndReaching: {
type: Boolean,
default: false,
},
touchAngle: {
type: Number,
default: 45,
},
isPrevent: {
type: Boolean,
default: true,
},
},
data() {
return {
container: null,
content: null,
refresher: null,
more: null,
scroller: null,
refreshOffsetY: 0,
isInitialed: false,
isMouseDown: false,
isRefreshing: false,
isRefreshActive: false,
isEndReachingStart: false,
isEndReaching: false,
scrollX: null,
scrollY: null,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
containerW: 0,
containerH: 0,
contentW: 0,
contentH: 0,
reflowTimer: null,
endReachedHandler: null,
}
},
computed: {
hasRefresher() {
return !!(this.$slots.refresh || this.$scopedSlots.refresh)
},
hasMore() {
return !!(this.$slots.more || this.$scopedSlots.more)
},
},
watch: {
autoReflow(val) {
if (val) {
this.$_initAutoReflow()
} else {
this.$_destroyAutoReflow()
}
},
},
mounted() {
if (!this.manualInit) {
this.$_initScroller()
}
},
destroyed() {
this.$_destroyAutoReflow()
},
methods: {
$_initScroller() {
/* istanbul ignore if */
if (this.isInitialed) {
return
}
this.container = this.$el
this.refresher = this.$el.querySelector('.scroll-view-refresh')
this.more = this.$el.querySelector('.scroll-view-more')
this.content = this.$el.querySelector('.scroll-view-container')
this.refreshOffsetY = this.refresher ? this.refresher.clientHeight : 0
this.moreOffsetY = this.more ? this.more.clientHeight : 0
const container = this.container
const content = this.content
const rect = container.getBoundingClientRect()
const scroller = new Scroller(
(left, top) => {
render(content, left, top)
if (this.isInitialed) {
this.$_onScroll(left, top)
}
},
{
scrollingX: this.scrollingX,
scrollingY: this.scrollingY,
bouncing: this.bouncing,
zooming: false,
animationDuration: 200,
speedMultiplier: 1.2,
inRequestAnimationFrame: true,
},
)
scroller.setPosition(rect.left + container.clientLeft, rect.top + container.clientTop)
if (this.hasRefresher) {
scroller.activatePullToRefresh(
this.refreshOffsetY,
() => {
this.isRefreshActive = true
this.isRefreshing = false
this.$emit('refreshActive')
},
() => {
this.isRefreshActive = false
this.isRefreshing = false
},
() => {
this.isRefreshActive = false
this.isRefreshing = true
this.$emit('refreshing')
},
)
}
this.scroller = scroller
this.reflowScroller(true)
this.autoReflow && this.$_initAutoReflow()
this.endReachedHandler = debounce(() => {
this.isEndReaching = true
this.$emit('endReached')
this.$emit('end-reached')
}, 50)
setTimeout(() => {
this.isInitialed = true
}, 50)
if (this.immediateCheckEndReaching) {
this.$nextTick(this.$_checkScrollerEnd)
}
},
$_initAutoReflow() {
this.$_destroyAutoReflow()
this.reflowTimer = setInterval(() => {
this.reflowScroller()
}, 100)
},
$_destroyAutoReflow() {
this.reflowTimer && clearInterval(this.reflowTimer)
},
$_checkScrollerEnd() {
if (!this.scroller) {
return
}
const containerHeight = this.scroller._clientHeight
const content = this.scroller._contentHeight
const top = this.scroller._scrollTop
const moreOffsetY = this.moreOffsetY
const moreThreshold = this.endReachedThreshold
const endOffset = content - containerHeight - (top + moreOffsetY + moreThreshold)
if (top >= 0 && !this.isEndReaching && endOffset <= 0 && this.endReachedHandler) {
// First prepare for "load more" state
this.isEndReachingStart = true
// Second enter "load more" state
// & trigger endReached event only once after the rebounding animation
this.endReachedHandler()
}
},
$_getScrollerAngle() {
const diffX = this.currentX - this.startX
const diffY = this.currentY - this.startY
const angle = Math.atan2(Math.abs(diffY), Math.abs(diffX)) * 180 / Math.PI
return this.scrollingX ? 90 - angle : angle
},
// MARK: events handler
$_onScrollerTouchStart(event) {
// event.target.tagName && event.target.tagName.match(/input|textarea|select/i)
/* istanbul ignore if */
if (!this.scroller) {
return
}
this.startX = event.targetTouches[0].pageX
this.startY = event.targetTouches[0].pageY
this.scroller.doTouchStart(event.touches, event.timeStamp)
},
$_onScrollerTouchMove(event) {
/* istanbul ignore if */
if (!this.scroller) {
return
}
let hadPrevent = false
if (this.isPrevent) {
event.preventDefault()
hadPrevent = true
}
this.currentX = event.targetTouches[0].pageX
this.currentY = event.targetTouches[0].pageY
if (!this.scrollingX || !this.scrollingY) {
const currentTouchAngle = this.$_getScrollerAngle()
if (currentTouchAngle < this.touchAngle) {
return
}
}
if (!hadPrevent && event.cancelable) {
event.preventDefault()
}
this.scroller.doTouchMove(event.touches, event.timeStamp, event.scale)
const boundaryDistance = 15
const scrollLeft = document.documentElement.scrollLeft || window.pageXOffset || document.body.scrollLeft
const scrollTop = document.documentElement.scrollTop || window.pageYOffset || document.body.scrollTop
const pX = this.currentX - scrollLeft
const pY = this.currentY - scrollTop
if (
pX > document.documentElement.clientWidth - boundaryDistance ||
pY > document.documentElement.clientHeight - boundaryDistance ||
pX < boundaryDistance ||
pY < boundaryDistance
) {
this.scroller.doTouchEnd(event.timeStamp)
}
},
$_onScrollerTouchEnd(event) {
/* istanbul ignore if */
if (!this.scroller) {
return
}
this.scroller.doTouchEnd(event.timeStamp)
},
$_onScrollerMouseDown(event) {
/* istanbul ignore if */
if (!this.scroller) {
return
}
this.startX = event.pageX
this.startY = event.pageY
this.scroller.doTouchStart(
[
{
pageX: event.pageX,
pageY: event.pageY,
},
],
event.timeStamp,
)
this.isMouseDown = true
},
$_onScrollerMouseMove(event) {
/* istanbul ignore if */
if (!this.scroller || !this.isMouseDown) {
return
}
this.currentX = event.pageX
this.currentY = event.pageY
if (!this.scrollingX || !this.scrollingY) {
const currentTouchAngle = this.$_getScrollerAngle()
if (currentTouchAngle < this.touchAngle) {
return
}
}
this.scroller.doTouchMove(
[
{
pageX: event.pageX,
pageY: event.pageY,
},
],
event.timeStamp,
)
this.isMouseDown = true
},
$_onScrollerMouseUp(event) {
/* istanbul ignore if */
if (!this.scroller || !this.isMouseDown) {
return
}
this.scroller.doTouchEnd(event.timeStamp)
this.isMouseDown = false
},
$_onScroll(left, top) {
left = +left.toFixed(2)
top = +top.toFixed(2)
if (this.scrollX === left && this.scrollY === top) {
return
}
this.scrollX = left
this.scrollY = top
this.$_checkScrollerEnd()
this.$emit('scroll', {scrollLeft: left, scrollTop: top})
},
init() {
this.$nextTick(() => {
this.$_initScroller()
})
},
scrollTo(left, top, animate = false) {
/* istanbul ignore if */
if (!this.scroller) {
return
}
this.scroller.scrollTo(left, top, animate)
},
getOffsets() {
/* istanbul ignore if */
if (!this.scroller) {
return {left: 0, top: 0}
}
return this.scroller.getValues()
},
reflowScroller(force = false) {
const container = this.container
const content = this.content
/* istanbul ignore if */
if (!this.scroller || !container || !content) {
return
}
this.$nextTick(() => {
const containerW = container.clientWidth
const containerH = container.clientHeight
const contentW = content.offsetWidth
const contentH = content.offsetHeight
if (
force ||
this.containerW !== containerW ||
this.containerH !== containerH ||
this.contentW !== contentW ||
this.contentH !== contentH
) {
this.scroller.setDimensions(
container.clientWidth,
container.clientHeight,
content.offsetWidth,
content.offsetHeight,
)
this.containerW = containerW
this.containerH = containerH
this.contentW = contentW
this.contentH = contentH
}
})
},
triggerRefresh() {
/* istanbul ignore if */
if (!this.scroller) {
return
}
this.scroller.triggerPullToRefresh()
},
finishRefresh() {
/* istanbul ignore if */
if (!this.scroller) {
return
}
this.scroller.finishPullToRefresh()
this.reflowScroller()
},
finishLoadMore() {
/* istanbul ignore if */
if (!this.scroller) {
return
}
this.isEndReachingStart = false
this.isEndReaching = false
this.reflowScroller()
},
},
}
</script>
<style lang="stylus">
.md-scroll-view
position relative
display block
height 100%
overflow hidden
user-select none
.scroll-view-header, .scroll-view-footer
position absolute
left 0
right 0
z-index 2
.scroll-view-header
top 0
.scroll-view-footer
bottom 0
.scroll-view-container
clearfix()
position relative
z-index 1
// display inline-block
.scroll-view-refresh
clearfix()
position absolute
left 0
right 0
transform translate3d(0, -100%, 0)
.scroll-view-more
visibility hidden
&.active
visibility visible
&.horizon
display inline-block
</style>