wux-weapp
Version:
一套组件化、可复用、易扩展的微信小程序 UI 组件库
317 lines (310 loc) • 11 kB
JavaScript
import baseComponent from '../helpers/baseComponent'
import classNames from '../helpers/libs/classNames'
import styleToCssString from '../helpers/libs/styleToCssString'
import { debounce } from '../helpers/shared/debounce'
import { useRect } from '../helpers/hooks/useDOM'
import { mapVirtualToProps, getVisibleItemBounds } from './utils'
baseComponent({
relations: {
'../virtual-item/index': {
type: 'descendant',
observer() {
this.callDebounceFn(this.updated)
},
},
},
properties: {
prefixCls: {
type: String,
value: 'wux-virtual-list',
},
itemHeight: {
type: Number,
value: 50,
},
itemBuffer: {
type: Number,
value: 0,
},
scrollToIndex: {
type: Number,
value: 0,
},
upperThreshold: {
type: Number,
value: 50,
},
lowerThreshold: {
type: Number,
value: 50,
},
scrollWithAnimation: {
type: Boolean,
value: false,
},
enableBackToTop: {
type: Boolean,
value: false,
},
disableScroll: {
type: Boolean,
value: false,
},
enablePageScroll: {
type: Boolean,
value: false,
},
height: {
type: Number,
value: 300,
},
debounce: {
type: Number,
value: 0,
},
},
data: {
wrapStyle: '', // 最外层容器样式
scrollOffset: 0, // 用于记录滚动条实际位置
innerScrollOffset: 0, // 用于设置滚动条位置
startIndex: 0, // 第一个元素的索引值
endIndex: -1, // 最后一个元素的索引值
},
computed: {
classes: ['prefixCls', function(prefixCls) {
const wrap = classNames(prefixCls)
const mask = `${prefixCls}__mask`
const scrollView = `${prefixCls}__scroll-view`
const scrollArea = `${prefixCls}__scroll-area`
return {
wrap,
mask,
scrollView,
scrollArea,
}
}],
},
observers: {
itemHeight(newVal) {
this.updated(newVal)
},
height(newVal) {
this.updatedStyle(newVal)
},
debounce(newVal) {
this.setScrollHandler(newVal)
},
['enablePageScroll, height, itemHeight, itemBuffer']() {
if (this.firstRendered) {
this.onChange(this.data.scrollOffset, true)
}
},
scrollToIndex(newVal) {
if (this.firstRendered) {
this.scrollToIndex(newVal)
}
},
},
methods: {
/**
* 设置子元素的高度
* @param {Number} itemHeight 子元素高度
*/
updated(itemHeight = this.data.itemHeight) {
const { startIndex } = this.data
const elements = this.getRelationsByName('../virtual-item/index')
if (elements.length > 0) {
elements.forEach((element, index) => {
element.updated(startIndex + index, itemHeight)
})
}
},
/**
* 设置最外层容器样式
* @param {Number} height page 高度
*/
updatedStyle(height) {
this.setValue(styleToCssString({ height }), 'wrapStyle')
},
/**
* set value
* @param {Any} value 属性值
* @param {String} field 字段值
* @param {Boolean} isForce 是否强制更新
*/
setValue(value, field = 'scrollOffset', isForce) {
if (this.data[field] !== value || isForce) {
this.setData({
[field]: value,
})
}
},
/**
* 用于计算虚拟列表数据
* @param {Function} callback 设置完成后的回调函数
*/
loadData(callback) {
const { itemHeight, startIndex, endIndex, scrollOffset } = this.data
const options = {
items: this.items,
itemHeight,
}
const indexes = {
startIndex,
endIndex,
}
const values = mapVirtualToProps(options, indexes)
this.setData(values, () => {
if (typeof callback === 'function') {
callback.call(this, { ...values, ...indexes, scrollOffset })
}
})
},
/**
* 数据变化时的回调函数
* @param {Number} scrollOffset 记录滚动条实际位置
* @param {Boolean} scrolled 是否设置滚动条位置
* @param {Function} callback 设置完成后的回调函数
*/
onChange(scrollOffset, scrolled, callback) {
// 计算起始点是否发生变化
const { itemHeight, height, itemBuffer, startIndex, endIndex, offsetTop, enablePageScroll } = this.data
const itemCount = Math.max(0, this.items.length - 1)
const listTop = enablePageScroll ? offsetTop : 0
const viewTop = scrollOffset - listTop
const state = getVisibleItemBounds(viewTop, height, itemCount, itemHeight, itemBuffer)
const hasChanged = state.startIndex !== startIndex || state.endIndex !== endIndex
// 计算起始点是否可视
const direction = scrollOffset > this.data.scrollOffset ? 'Down' : 'Up'
const firstItemVisible = direction === 'Up' && viewTop < startIndex * itemHeight
const lastItemVisible = direction === 'Down' && viewTop > (endIndex * itemHeight - height)
// 判断起始点大小
if (state === undefined || state.startIndex > state.endIndex) return
// 判断起始点是否发生变化及是否可视状态
if (hasChanged && (firstItemVisible || lastItemVisible) || scrolled) {
this.setData(state, () => {
this.loadData((values) => {
// scroll into view
if (scrolled) {
this.setValue(scrollOffset, 'innerScrollOffset', true)
}
// trigger change
this.triggerEvent('change', { ...values, direction, scrollOffset })
// trigger callback
if (typeof callback === 'function') {
callback.call(this, { ...values, direction, scrollOffset })
}
})
})
}
// 记录滚动条的位置(仅记录不去设置)
this.setValue(scrollOffset)
},
/**
* 滚动时触发的事件
*/
onScroll(e) {
this.onChange(e.detail.scrollTop)
this.triggerEvent('scroll', e.detail)
},
/**
* 滚动到顶部时触发的事件
*/
onScrollToUpper(e) {
this.triggerEvent('scrolltoupper', e.detail)
},
/**
* 滚动到底部时触发的事件
*/
onScrollToLower(e) {
this.triggerEvent('scrolltolower', e.detail)
},
/**
* 根据索引值获取偏移量
* @param {Number} index 指定的索引值
* @param {Number} itemHeight 子元素高度
* @param {Number} itemSize 子元素个数
*/
getOffsetForIndex(index, itemHeight = this.data.itemHeight, itemSize = this.items.length) {
const realIndex = Math.max(0, Math.min(index, itemSize - 1))
const scrollOffset = realIndex * itemHeight
return scrollOffset
},
/**
* 更新组件
* @param {Array} items 实际数据列表,当需要动态加载数据时设置
* @param {Function} success 设置完成后的回调函数
*/
render(items, success) {
let { scrollOffset } = this.data
if (Array.isArray(items)) {
this.items = items
}
// 首次渲染时滚动至 scrollToIndex 指定的位置
if (!this.firstRendered) {
this.firstRendered = true
scrollOffset = this.getOffsetForIndex(this.data.scrollToIndex)
}
this.getBoundingClientRect(() => this.onChange(scrollOffset, true, success))
},
/**
* 滚动到指定的位置
* @param {Number} scrollOffset 指定的位置
* @param {Function} success 设置完成后的回调函数
*/
scrollTo(scrollOffset, success) {
if (typeof scrollOffset === 'number') {
const offset = Math.max(0, Math.min(scrollOffset, this.items.length * this.data.itemHeight))
this.onChange(offset, true, success)
}
},
/**
* 根据索引值滚动到指定的位置
* @param {Number} index 指定元素的索引值
* @param {Function} success 设置完成后的回调函数
*/
scrollToIndex(index, success) {
if (typeof index === 'number') {
this.onChange(this.getOffsetForIndex(index), true, success)
}
},
/**
* 绑定滚动事件
* @param {Boolean} useDebounce 是否防抖
*/
setScrollHandler(useDebounce = this.data.debounce) {
this.scrollHandler = useDebounce ? debounce(this.onScroll.bind(this), useDebounce, { leading: true, maxWait: useDebounce, trailing: true }) : this.onScroll
},
/**
* 阻止触摸移动
*/
noop() {},
/**
* 获取容器的偏移量
* @param {Function} callback 设置完成后的回调函数
* @param {Boolean} isForce 是否强制更新
*/
getBoundingClientRect(callback, isForce) {
if (this.data.offsetTop !== undefined && !isForce) {
callback.call(this)
return
}
useRect(`.${this.data.prefixCls}`, this)
.then((rect) => {
if (!rect) return
this.setData({ offsetTop: rect.top }, callback)
})
},
},
created() {
this.items = []
this.firstRendered = false
},
ready() {
const { height, debounce } = this.data
this.updatedStyle(height)
this.setScrollHandler(debounce)
this.getBoundingClientRect()
this.loadData()
},
})