@tarojs/components
Version:
Taro 组件库。
590 lines (531 loc) • 17.5 kB
JavaScript
import { memoizeOne } from '../memoize'
import { cancelTimeout, requestTimeout } from '../timer'
import { getRTLOffsetType } from '../domHelpers'
const IS_SCROLLING_DEBOUNCE_INTERVAL = 150
const defaultItemKey = (index) => index
function createListComponent ({
getItemOffset,
getEstimatedTotalSize,
getItemSize,
getOffsetForIndexAndAlignment,
getStartIndexForOffset,
getStopIndexForStartIndex,
initInstanceProps,
shouldResetStyleCacheOnItemSizeChange,
Vue
}) {
return {
props: {
direction: {
type: String,
default: 'ltr'
},
itemData: Array,
layout: {
type: String,
default: 'vertical'
},
useIsScrolling: {
type: Boolean,
default: false
},
overscanCount: {
type: Number,
default: 1
},
wclass: String,
height: {},
innerRef: String,
innerElementType: {
type: String,
default: 'view'
},
itemCount: Number,
wstyle: String,
width: String,
itemSize: {
required: true
},
item: {
required: true
},
initialScrollOffset: {
type: String,
defalt: 0
},
scrollNative: Function
},
data () {
return {
instance: this,
isScrolling: false,
scrollDirection: 'forward',
scrollOffset:
typeof this.$props.initialScrollOffset === 'number'
? this.$props.initialScrollOffset
: 0,
scrollUpdateWasRequested: false,
resetIsScrollingTimeoutId: null
}
},
methods: {
_instanceProps () {
initInstanceProps(this.$props, this)
},
scrollTo (scrollOffset) {
scrollOffset = Math.max(0, scrollOffset)
if (this.scrollOffset === scrollOffset) {
return
}
this.scrollDirection = this.scrollOffset < scrollOffset ? 'forward' : 'backward'
this.scrollOffset = scrollOffset
this.scrollUpdateWasRequested = true
Vue.nextTick(this._resetIsScrollingDebounced)
},
scrollToItem (index, align = 'auto') {
const { itemCount } = this.$props
const { scrollOffset } = this.$data
index = Math.max(0, Math.min(index, itemCount - 1))
this.scrollTo(
getOffsetForIndexAndAlignment(
this.$props,
index,
align,
scrollOffset,
this._instanceProps()
)
)
},
_callOnItemsRendered: memoizeOne(
function (
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex
) {
return this.$props.onItemsRendered({
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex
})
}
),
_callOnScroll: memoizeOne(
function (
scrollDirection,
scrollOffset,
scrollUpdateWasRequested
) {
this.$emit('scroll', {
scrollDirection,
scrollOffset,
scrollUpdateWasRequested
})
}
),
_callPropsCallbacks () {
if (typeof this.$props.onItemsRendered === 'function') {
const { itemCount } = this.$props
if (itemCount > 0) {
const [
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex
] = this._getRangeToRender()
this._callOnItemsRendered(
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex
)
}
}
this._callOnScroll(
this.scrollDirection,
this.scrollOffset,
this.scrollUpdateWasRequested
)
},
_getStyleValue (value) {
return typeof value === 'number'
? value + 'px'
: value == null
? ''
: value
},
_getItemStyle (index) {
const { direction, itemSize, layout } = this.$props
const itemStyleCache = this._getItemStyleCache(
shouldResetStyleCacheOnItemSizeChange && itemSize,
shouldResetStyleCacheOnItemSizeChange && layout,
shouldResetStyleCacheOnItemSizeChange && direction
)
let style
if (itemStyleCache.hasOwnProperty(index)) {
style = itemStyleCache[index]
} else {
const offset = getItemOffset(this.$props, index, this._instanceProps())
const size = getItemSize(this.$props, index, this._instanceProps())
// TODO Deprecate direction "horizontal"
const isHorizontal =
direction === 'horizontal' || layout === 'horizontal'
const isRtl = direction === 'rtl'
const offsetHorizontal = isHorizontal ? offset : 0
itemStyleCache[index] = style = {
position: 'absolute',
left: isRtl ? undefined : offsetHorizontal,
right: isRtl ? offsetHorizontal : undefined,
top: !isHorizontal ? offset : 0,
height: !isHorizontal ? size : '100%',
width: isHorizontal ? size : '100%'
}
}
for (const k in style) {
if (style.hasOwnProperty(k)) {
style[k] = this._getStyleValue(style[k])
}
}
return style
},
_getItemStyleCache: memoizeOne(() => ({})),
_getRangeToRender () {
const { itemCount, overscanCount } = this.$props
const { isScrolling, scrollDirection, scrollOffset } = this.$data
if (itemCount === 0) {
return [0, 0, 0, 0]
}
const startIndex = getStartIndexForOffset(
this.$props,
scrollOffset,
this._instanceProps()
)
const stopIndex = getStopIndexForStartIndex(
this.$props,
startIndex,
scrollOffset,
this._instanceProps()
)
// Overscan by one item in each direction so that tab/focus works.
// If there isn't at least one extra item, tab loops back around.
const overscanBackward =
!isScrolling || scrollDirection === 'backward'
? Math.max(1, overscanCount)
: 1
const overscanForward =
!isScrolling || scrollDirection === 'forward'
? Math.max(1, overscanCount)
: 1
return [
Math.max(0, startIndex - overscanBackward),
Math.max(0, Math.min(itemCount - 1, stopIndex + overscanForward)),
startIndex,
stopIndex
]
},
_onScrollHorizontal (event) {
const clientWidth = this.$props.width
const { scrollLeft, scrollWidth } = event.currentTarget
if (this.$props.scrollNative) {
this.$props.scrollNative(event)
}
if (this.scrollOffset === scrollLeft) {
return
}
const { direction } = this.$props
let scrollOffset = scrollLeft
if (direction === 'rtl') {
// TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements.
// This is not the case for all browsers though (e.g. Chrome reports values as positive, measured relative to the left).
// It's also easier for this component if we convert offsets to the same format as they would be in for ltr.
// So the simplest solution is to determine which browser behavior we're dealing with, and convert based on it.
switch (getRTLOffsetType()) {
case 'negative':
scrollOffset = -scrollLeft
break
case 'positive-descending':
scrollOffset = scrollWidth - clientWidth - scrollLeft
break
}
}
// Prevent Safari's elastic scrolling from causing visual shaking when scrolling past bounds.
scrollOffset = Math.max(
0,
Math.min(scrollOffset, scrollWidth - clientWidth)
)
this.isScrolling = true
this.scrollDirection = this.scrollOffset < scrollLeft ? 'forward' : 'backward'
this.scrollOffset = scrollOffset
this.scrollUpdateWasRequested = false
Vue.nextTick(this._resetIsScrollingDebounced)
},
_onScrollVertical (event) {
const clientHeight = this.$props.height
const { scrollHeight, scrollTop } = event.currentTarget
if (this.$props.scrollNative) {
this.$props.scrollNative(event)
}
if (this.scrollOffset === scrollTop) {
return
}
// Prevent Safari's elastic scrolling from causing visual shaking when scrolling past bounds.
const scrollOffset = Math.max(
0,
Math.min(scrollTop, scrollHeight - clientHeight)
)
this.isScrolling = true
this.scrollDirection = this.scrollOffset < scrollOffset ? 'forward' : 'backward'
this.scrollOffset = scrollOffset
this.scrollUpdateWasRequested = false
Vue.nextTick(this._resetIsScrollingDebounced)
},
_resetIsScrollingDebounced () {
if (this.resetIsScrollingTimeoutId !== null) {
cancelTimeout(this.resetIsScrollingTimeoutId)
}
this.resetIsScrollingTimeoutId = requestTimeout(
this._resetIsScrolling,
IS_SCROLLING_DEBOUNCE_INTERVAL
)
},
_resetIsScrolling () {
this.resetIsScrollingTimeoutId = null
this.isScrolling = false
Vue.nextTick(() => {
this._getItemStyleCache(-1, null)
})
}
},
mounted () {
const { direction, initialScrollOffset, layout } = this.$props
if (typeof initialScrollOffset === 'number' && this._outerRef != null) {
const outerRef = this._outerRef
// TODO Deprecate direction "horizontal"
if (direction === 'horizontal' || layout === 'horizontal') {
outerRef.scrollLeft = initialScrollOffset
} else {
outerRef.scrollTop = initialScrollOffset
}
}
this._callPropsCallbacks()
},
updated () {
const { direction, layout } = this.$props
const { scrollOffset, scrollUpdateWasRequested } = this.$data
if (scrollUpdateWasRequested && this._outerRef != null) {
const outerRef = this._outerRef
// TODO Deprecate direction "horizontal"
if (direction === 'horizontal' || layout === 'horizontal') {
if (direction === 'rtl') {
// TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements.
// This is not the case for all browsers though (e.g. Chrome reports values as positive, measured relative to the left).
// So we need to determine which browser behavior we're dealing with, and mimic it.
switch (getRTLOffsetType()) {
case 'negative':
outerRef.scrollLeft = -scrollOffset
break
case 'positive-ascending':
outerRef.scrollLeft = scrollOffset
break
default: {
const { clientWidth, scrollWidth } = outerRef
outerRef.scrollLeft = scrollWidth - clientWidth - scrollOffset
break
}
}
} else {
outerRef.scrollLeft = scrollOffset
}
} else {
outerRef.scrollTop = scrollOffset
}
}
this._callPropsCallbacks()
},
beforeDestroy () {
if (this.resetIsScrollingTimeoutId !== null) {
cancelTimeout(this.resetIsScrollingTimeoutId)
}
},
render (h) {
const {
item,
wclass,
direction,
height,
innerRef,
innerElementType,
itemCount,
itemData,
itemKey = defaultItemKey,
layout,
wstyle,
useIsScrolling,
width
} = this.$props
const { isScrolling } = this.$data
// TODO Deprecate direction "horizontal"
const isHorizontal =
direction === 'horizontal' || layout === 'horizontal'
const onScroll = isHorizontal
? this._onScrollHorizontal
: this._onScrollVertical
const [startIndex, stopIndex] = this._getRangeToRender()
const items = []
if (itemCount > 0) {
for (let index = startIndex; index <= stopIndex; index++) {
items.push(
h(item, {
key: itemKey(index, itemData),
props: {
data: itemData,
index,
isScrolling: useIsScrolling ? isScrolling : undefined,
css: this._getItemStyle(index)
}
})
)
}
}
// Read this value AFTER items have been created,
// So their actual sizes (if variable) are taken into consideration.
const estimatedTotalSize = getEstimatedTotalSize(
this.$props,
this._instanceProps()
)
const scrollViewName = process.env.TARO_ENV === 'h5' ? 'taro-scroll-view' : 'scroll-view'
return h(
scrollViewName,
{
class: wclass,
ref: this._outerRefSetter,
style: {
position: 'relative',
height: this._getStyleValue(height),
width: this._getStyleValue(width),
overflow: 'auto',
WebkitOverflowScrolling: 'touch',
willChange: 'transform',
direction,
...wstyle
},
attrs: {
scrollY: layout === 'vertical',
scrollX: layout === 'horizontal'
},
on: {
scroll: onScroll
}
},
[
h(
innerElementType,
{
ref: innerRef,
style: {
height: this._getStyleValue(isHorizontal ? '100%' : estimatedTotalSize),
pointerEvents: isScrolling ? 'none' : undefined,
width: this._getStyleValue(isHorizontal ? estimatedTotalSize : '100%')
}
},
items
)
]
)
}
}
}
export default {
install: (Vue) => {
const VirtualList = createListComponent({
Vue,
getItemOffset: ({
itemSize
}, index) => index * itemSize,
getItemSize: ({
itemSize
}) => itemSize,
getEstimatedTotalSize: ({
itemCount,
itemSize
}) => itemSize * itemCount,
getOffsetForIndexAndAlignment: ({
direction,
height,
itemCount,
itemSize,
layout,
width
}, index, align, scrollOffset) => {
// TODO Deprecate direction "horizontal"
const isHorizontal = direction === 'horizontal' || layout === 'horizontal'
const size = isHorizontal ? width : height
const lastItemOffset = Math.max(0, itemCount * itemSize - size)
const maxOffset = Math.min(lastItemOffset, index * itemSize)
const minOffset = Math.max(0, index * itemSize - size + itemSize)
if (align === 'smart') {
if (scrollOffset >= minOffset - size && scrollOffset <= maxOffset + size) {
align = 'auto'
} else {
align = 'center'
}
}
switch (align) {
case 'start':
return maxOffset
case 'end':
return minOffset
case 'center':
{
// "Centered" offset is usually the average of the min and max.
// But near the edges of the list, this doesn't hold true.
const middleOffset = Math.round(minOffset + (maxOffset - minOffset) / 2)
if (middleOffset < Math.ceil(size / 2)) {
return 0 // near the beginning
} else if (middleOffset > lastItemOffset + Math.floor(size / 2)) {
return lastItemOffset // near the end
} else {
return middleOffset
}
}
case 'auto':
default:
if (scrollOffset >= minOffset && scrollOffset <= maxOffset) {
return scrollOffset
} else if (scrollOffset < minOffset) {
return minOffset
} else {
return maxOffset
}
}
},
getStartIndexForOffset: ({
itemCount,
itemSize
}, offset) => {
return Math.max(0, Math.min(itemCount - 1, Math.floor(offset / itemSize)))
},
getStopIndexForStartIndex: ({
direction,
height,
itemCount,
itemSize,
layout,
width
}, startIndex, scrollOffset) => {
// TODO Deprecate direction "horizontal"
const isHorizontal = direction === 'horizontal' || layout === 'horizontal'
const offset = startIndex * itemSize
const size = isHorizontal ? width : height
const numVisibleItems = Math.ceil((size + scrollOffset - offset) / itemSize)
return Math.max(0, Math.min(itemCount - 1, startIndex + numVisibleItems - 1 // -1 is because stop index is inclusive
))
},
initInstanceProps () { // Noop
},
shouldResetStyleCacheOnItemSizeChange: true
})
Vue.component('virtual-list', VirtualList)
}
}