index.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. import baseComponent from '../helpers/baseComponent'
  2. import classNames from '../helpers/libs/classNames'
  3. import styleToCssString from '../helpers/libs/styleToCssString'
  4. import { debounce } from '../helpers/shared/debounce'
  5. import { useRect } from '../helpers/hooks/useDOM'
  6. import { mapVirtualToProps, getVisibleItemBounds } from './utils'
  7. baseComponent({
  8. relations: {
  9. '../virtual-item/index': {
  10. type: 'descendant',
  11. observer() {
  12. this.callDebounceFn(this.updated)
  13. },
  14. },
  15. },
  16. properties: {
  17. prefixCls: {
  18. type: String,
  19. value: 'wux-virtual-list',
  20. },
  21. itemHeight: {
  22. type: Number,
  23. value: 50,
  24. },
  25. itemBuffer: {
  26. type: Number,
  27. value: 0,
  28. },
  29. scrollToIndex: {
  30. type: Number,
  31. value: 0,
  32. },
  33. upperThreshold: {
  34. type: Number,
  35. value: 50,
  36. },
  37. lowerThreshold: {
  38. type: Number,
  39. value: 50,
  40. },
  41. scrollWithAnimation: {
  42. type: Boolean,
  43. value: false,
  44. },
  45. enableBackToTop: {
  46. type: Boolean,
  47. value: false,
  48. },
  49. disableScroll: {
  50. type: Boolean,
  51. value: false,
  52. },
  53. enablePageScroll: {
  54. type: Boolean,
  55. value: false,
  56. },
  57. height: {
  58. type: Number,
  59. value: 300,
  60. },
  61. debounce: {
  62. type: Number,
  63. value: 0,
  64. },
  65. },
  66. data: {
  67. wrapStyle: '', // 最外层容器样式
  68. scrollOffset: 0, // 用于记录滚动条实际位置
  69. innerScrollOffset: 0, // 用于设置滚动条位置
  70. startIndex: 0, // 第一个元素的索引值
  71. endIndex: -1, // 最后一个元素的索引值
  72. },
  73. computed: {
  74. classes: ['prefixCls', function(prefixCls) {
  75. const wrap = classNames(prefixCls)
  76. const mask = `${prefixCls}__mask`
  77. const scrollView = `${prefixCls}__scroll-view`
  78. const scrollArea = `${prefixCls}__scroll-area`
  79. return {
  80. wrap,
  81. mask,
  82. scrollView,
  83. scrollArea,
  84. }
  85. }],
  86. },
  87. observers: {
  88. itemHeight(newVal) {
  89. this.updated(newVal)
  90. },
  91. height(newVal) {
  92. this.updatedStyle(newVal)
  93. },
  94. debounce(newVal) {
  95. this.setScrollHandler(newVal)
  96. },
  97. ['enablePageScroll, height, itemHeight, itemBuffer']() {
  98. if (this.firstRendered) {
  99. this.onChange(this.data.scrollOffset, true)
  100. }
  101. },
  102. scrollToIndex(newVal) {
  103. if (this.firstRendered) {
  104. this.scrollToIndex(newVal)
  105. }
  106. },
  107. },
  108. methods: {
  109. /**
  110. * 设置子元素的高度
  111. * @param {Number} itemHeight 子元素高度
  112. */
  113. updated(itemHeight = this.data.itemHeight) {
  114. const { startIndex } = this.data
  115. const elements = this.getRelationsByName('../virtual-item/index')
  116. if (elements.length > 0) {
  117. elements.forEach((element, index) => {
  118. element.updated(startIndex + index, itemHeight)
  119. })
  120. }
  121. },
  122. /**
  123. * 设置最外层容器样式
  124. * @param {Number} height page 高度
  125. */
  126. updatedStyle(height) {
  127. this.setValue(styleToCssString({ height }), 'wrapStyle')
  128. },
  129. /**
  130. * set value
  131. * @param {Any} value 属性值
  132. * @param {String} field 字段值
  133. * @param {Boolean} isForce 是否强制更新
  134. */
  135. setValue(value, field = 'scrollOffset', isForce) {
  136. if (this.data[field] !== value || isForce) {
  137. this.setData({
  138. [field]: value,
  139. })
  140. }
  141. },
  142. /**
  143. * 用于计算虚拟列表数据
  144. * @param {Function} callback 设置完成后的回调函数
  145. */
  146. loadData(callback) {
  147. const { itemHeight, startIndex, endIndex, scrollOffset } = this.data
  148. const options = {
  149. items: this.items,
  150. itemHeight,
  151. }
  152. const indexes = {
  153. startIndex,
  154. endIndex,
  155. }
  156. const values = mapVirtualToProps(options, indexes)
  157. this.setData(values, () => {
  158. if (typeof callback === 'function') {
  159. callback.call(this, { ...values, ...indexes, scrollOffset })
  160. }
  161. })
  162. },
  163. /**
  164. * 数据变化时的回调函数
  165. * @param {Number} scrollOffset 记录滚动条实际位置
  166. * @param {Boolean} scrolled 是否设置滚动条位置
  167. * @param {Function} callback 设置完成后的回调函数
  168. */
  169. onChange(scrollOffset, scrolled, callback) {
  170. // 计算起始点是否发生变化
  171. const { itemHeight, height, itemBuffer, startIndex, endIndex, offsetTop, enablePageScroll } = this.data
  172. const itemCount = Math.max(0, this.items.length - 1)
  173. const listTop = enablePageScroll ? offsetTop : 0
  174. const viewTop = scrollOffset - listTop
  175. const state = getVisibleItemBounds(viewTop, height, itemCount, itemHeight, itemBuffer)
  176. const hasChanged = state.startIndex !== startIndex || state.endIndex !== endIndex
  177. // 计算起始点是否可视
  178. const direction = scrollOffset > this.data.scrollOffset ? 'Down' : 'Up'
  179. const firstItemVisible = direction === 'Up' && viewTop < startIndex * itemHeight
  180. const lastItemVisible = direction === 'Down' && viewTop > (endIndex * itemHeight - height)
  181. // 判断起始点大小
  182. if (state === undefined || state.startIndex > state.endIndex) return
  183. // 判断起始点是否发生变化及是否可视状态
  184. if (hasChanged && (firstItemVisible || lastItemVisible) || scrolled) {
  185. this.setData(state, () => {
  186. this.loadData((values) => {
  187. // scroll into view
  188. if (scrolled) {
  189. this.setValue(scrollOffset, 'innerScrollOffset', true)
  190. }
  191. // trigger change
  192. this.triggerEvent('change', { ...values, direction, scrollOffset })
  193. // trigger callback
  194. if (typeof callback === 'function') {
  195. callback.call(this, { ...values, direction, scrollOffset })
  196. }
  197. })
  198. })
  199. }
  200. // 记录滚动条的位置(仅记录不去设置)
  201. this.setValue(scrollOffset)
  202. },
  203. /**
  204. * 滚动时触发的事件
  205. */
  206. onScroll(e) {
  207. this.onChange(e.detail.scrollTop)
  208. this.triggerEvent('scroll', e.detail)
  209. },
  210. /**
  211. * 滚动到顶部时触发的事件
  212. */
  213. onScrollToUpper(e) {
  214. this.triggerEvent('scrolltoupper', e.detail)
  215. },
  216. /**
  217. * 滚动到底部时触发的事件
  218. */
  219. onScrollToLower(e) {
  220. this.triggerEvent('scrolltolower', e.detail)
  221. },
  222. /**
  223. * 根据索引值获取偏移量
  224. * @param {Number} index 指定的索引值
  225. * @param {Number} itemHeight 子元素高度
  226. * @param {Number} itemSize 子元素个数
  227. */
  228. getOffsetForIndex(index, itemHeight = this.data.itemHeight, itemSize = this.items.length) {
  229. const realIndex = Math.max(0, Math.min(index, itemSize - 1))
  230. const scrollOffset = realIndex * itemHeight
  231. return scrollOffset
  232. },
  233. /**
  234. * 更新组件
  235. * @param {Array} items 实际数据列表,当需要动态加载数据时设置
  236. * @param {Function} success 设置完成后的回调函数
  237. */
  238. render(items, success) {
  239. let { scrollOffset } = this.data
  240. if (Array.isArray(items)) {
  241. this.items = items
  242. }
  243. // 首次渲染时滚动至 scrollToIndex 指定的位置
  244. if (!this.firstRendered) {
  245. this.firstRendered = true
  246. scrollOffset = this.getOffsetForIndex(this.data.scrollToIndex)
  247. }
  248. this.getBoundingClientRect(() => this.onChange(scrollOffset, true, success))
  249. },
  250. /**
  251. * 滚动到指定的位置
  252. * @param {Number} scrollOffset 指定的位置
  253. * @param {Function} success 设置完成后的回调函数
  254. */
  255. scrollTo(scrollOffset, success) {
  256. if (typeof scrollOffset === 'number') {
  257. const offset = Math.max(0, Math.min(scrollOffset, this.items.length * this.data.itemHeight))
  258. this.onChange(offset, true, success)
  259. }
  260. },
  261. /**
  262. * 根据索引值滚动到指定的位置
  263. * @param {Number} index 指定元素的索引值
  264. * @param {Function} success 设置完成后的回调函数
  265. */
  266. scrollToIndex(index, success) {
  267. if (typeof index === 'number') {
  268. this.onChange(this.getOffsetForIndex(index), true, success)
  269. }
  270. },
  271. /**
  272. * 绑定滚动事件
  273. * @param {Boolean} useDebounce 是否防抖
  274. */
  275. setScrollHandler(useDebounce = this.data.debounce) {
  276. this.scrollHandler = useDebounce ? debounce(this.onScroll.bind(this), useDebounce, { leading: true, maxWait: useDebounce, trailing: true }) : this.onScroll
  277. },
  278. /**
  279. * 阻止触摸移动
  280. */
  281. noop() {},
  282. /**
  283. * 获取容器的偏移量
  284. * @param {Function} callback 设置完成后的回调函数
  285. * @param {Boolean} isForce 是否强制更新
  286. */
  287. getBoundingClientRect(callback, isForce) {
  288. if (this.data.offsetTop !== undefined && !isForce) {
  289. callback.call(this)
  290. return
  291. }
  292. useRect(`.${this.data.prefixCls}`, this)
  293. .then((rect) => {
  294. if (!rect) return
  295. this.setData({ offsetTop: rect.top }, callback)
  296. })
  297. },
  298. },
  299. created() {
  300. this.items = []
  301. this.firstRendered = false
  302. },
  303. ready() {
  304. const { height, debounce } = this.data
  305. this.updatedStyle(height)
  306. this.setScrollHandler(debounce)
  307. this.getBoundingClientRect()
  308. this.loadData()
  309. },
  310. })