UNPKG

@tarojs/components

Version:

Taro 组件库。

590 lines (531 loc) • 17.5 kB
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) } }