Skip to content

Commit

Permalink
feat(cdk:scroll): add simulated scroll support to VirtualScroll (#1812)
Browse files Browse the repository at this point in the history
  • Loading branch information
sallerli1 committed Jan 16, 2024
1 parent f7abe16 commit 17ccefa
Show file tree
Hide file tree
Showing 51 changed files with 1,134 additions and 72 deletions.
@@ -1,21 +1,25 @@
// Vitest Snapshot v1

exports[`VirtualScroll > basic work > render work 1`] = `
"<div class=\\"cdk-virtual-scroll\\">
<div class=\\"cdk-virtual-scroll-holder\\" style=\\"max-height: 200px; width: 100%;\\">
"<div class=\\"cdk-virtual-scroll cdk-virtual-scroll-overflowed-vertical\\">
<div class=\\"cdk-virtual-scroll-holder\\" style=\\"max-height: 200px; width: 100%; overflow-x: auto; overflow-y: auto;\\">
<!---->
<div class=\\"cdk-virtual-scroll-filler-vertical\\" style=\\"height: 400px; width: 0px;\\"></div>
<div class=\\"cdk-virtual-scroll-content\\" style=\\"margin-top: 0px; margin-left: 0px;\\"><span class=\\"virtual-item\\" style=\\"height: 20px;\\">key-0 - 0</span><span class=\\"virtual-item\\" style=\\"height: 20px;\\">key-1 - 1</span><span class=\\"virtual-item\\" style=\\"height: 20px;\\">key-2 - 2</span><span class=\\"virtual-item\\" style=\\"height: 20px;\\">key-3 - 3</span><span class=\\"virtual-item\\" style=\\"height: 20px;\\">key-4 - 4</span><span class=\\"virtual-item\\" style=\\"height: 20px;\\">key-5 - 5</span><span class=\\"virtual-item\\" style=\\"height: 20px;\\">key-6 - 6</span><span class=\\"virtual-item\\" style=\\"height: 20px;\\">key-7 - 7</span><span class=\\"virtual-item\\" style=\\"height: 20px;\\">key-8 - 8</span><span class=\\"virtual-item\\" style=\\"height: 20px;\\">key-9 - 9</span><span class=\\"virtual-item\\" style=\\"height: 20px;\\">key-10 - 10</span></div>
</div>
<!---->
<!---->
</div>"
`;
exports[`VirtualScroll > basic work > rowRender work 1`] = `
"<div class=\\"cdk-virtual-scroll\\">
<div class=\\"cdk-virtual-scroll-holder\\" style=\\"max-height: 200px; width: 100%;\\">
"<div class=\\"cdk-virtual-scroll cdk-virtual-scroll-overflowed-vertical\\">
<div class=\\"cdk-virtual-scroll-holder\\" style=\\"max-height: 200px; width: 100%; overflow-x: auto; overflow-y: auto;\\">
<!---->
<div class=\\"cdk-virtual-scroll-filler-vertical\\" style=\\"height: 400px; width: 0px;\\"></div>
<div class=\\"cdk-virtual-scroll-content\\" style=\\"margin-top: 0px; margin-left: 0px;\\"><span class=\\"virtual-item\\">key-0 - 0</span><span class=\\"virtual-item\\">key-1 - 1</span><span class=\\"virtual-item\\">key-2 - 2</span><span class=\\"virtual-item\\">key-3 - 3</span><span class=\\"virtual-item\\">key-4 - 4</span><span class=\\"virtual-item\\">key-5 - 5</span><span class=\\"virtual-item\\">key-6 - 6</span><span class=\\"virtual-item\\">key-7 - 7</span><span class=\\"virtual-item\\">key-8 - 8</span><span class=\\"virtual-item\\">key-9 - 9</span><span class=\\"virtual-item\\">key-10 - 10</span></div>
</div>
<!---->
<!---->
</div>"
`;
7 changes: 6 additions & 1 deletion packages/cdk/scroll/__tests__/virtualScroll.spec.ts
Expand Up @@ -28,15 +28,20 @@ describe('VirtualScroll', () => {
const VirtualScrollMount = (options?: MountingOptions<Partial<VirtualScrollProps>>) => {
const { props, ...rest } = options || {}
const mergedOptions = { props: { ...defaultProps, ...props }, ...rest } as MountingOptions<VirtualScrollProps>
const scrollHeight = (props?.dataSource?.length ?? 0) * 20

vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => scrollHeight)

return mount(VirtualScroll, mergedOptions) as VueWrapper<VirtualScrollInstance>
}

beforeAll(() => {
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 200)
})

afterAll(() => {
afterEach(() => {
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockClear()
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockClear()
})

describe('basic work', () => {
Expand Down
14 changes: 14 additions & 0 deletions packages/cdk/scroll/demo/SimulatedScroll.md
@@ -0,0 +1,14 @@
---
title:
zh: 模拟滚动
en: Simulated scroll
order: 4
---

## zh

使用模拟滚动替代原生滚动。

## en

Use simulated scroll instead of native scroll.
77 changes: 77 additions & 0 deletions packages/cdk/scroll/demo/SimulatedScroll.vue
@@ -0,0 +1,77 @@
<template>
<div class="demo-both-scroll-wrapper">
<CdkVirtualScroll
ref="listRef"
:dataSource="rowData"
:height="200"
:rowHeight="20"
:colWidth="200"
:virtual="true"
:rowRender="rowRender"
getKey="key"
>
<template #col="{ row, item, index }">
<span class="virtual-item" @click="onItemClick(item.key)">{{ row.key }} - {{ index }}</span>
</template>
</CdkVirtualScroll>
</div>
</template>

<script setup lang="ts">
import { h, ref } from 'vue'
import { VirtualRowRenderFn, VirtualScrollInstance, VirtualScrollRowData } from '@idux/cdk/scroll'
const listRef = ref<VirtualScrollInstance>()
const colData: { key: string }[] = []
for (let index = 0; index < 1000; index++) {
colData.push({ key: `col-key-${index}` })
}
const rowData: VirtualScrollRowData[] = []
for (let index = 0; index < 1000; index++) {
rowData.push({
key: `row-key-${index}`,
data: colData,
})
}
const rowRender: VirtualRowRenderFn = ({ children }) =>
h(
'div',
{
class: 'virtual-row',
},
children,
)
const onItemClick = (key: string) => {
console.log('click:', key)
}
</script>

<style lang="less">
.demo-both-scroll-wrapper {
height: 240px;
.cdk-virtual-scroll {
border: 1px solid red;
margin-bottom: 8px;
}
.virtual-row {
flex-shrink: 0;
display: flex;
height: 20px;
flex-wrap: nowrap;
border: 1px solid gray;
}
.virtual-item {
flex-shrink: 0;
padding-left: 16px;
border: 1px solid gray;
height: 100%;
width: 200px;
line-height: 18px;
}
}
</style>
19 changes: 17 additions & 2 deletions packages/cdk/scroll/docs/Api.zh.md
Expand Up @@ -17,10 +17,13 @@
| `fullHeight` | 是否永远使用 `height` 作为容器高度 | `boolean` | `false` | - | 仅在不符合虚拟滚动条件时生效 |
| `width` | 列表的宽度 | `number \| 100%` | `0` | - | 设置为大于 0 时才可以启用虚拟滚动 |
| `fullHeight` | 是否永远使用 `width` 作为容器的宽度 | `boolean` | `false` | - | 仅在不符合虚拟滚动条件时生效 |
| `rowHeight` | 列表行的高度 | `number` | `0` | - | 设置为大于 0 时才可以启用虚拟滚动 |
| `colWidth` | 列表列的宽度 | `number` | `0` | - | 设置为大于 0 时才可以启用虚拟滚动 |
| `rowHeight` | 默认列表行的高度 | `number` | `0` | - | 设置为大于 0 时才可以启用虚拟滚动 |
| `colWidth` | 默认列表列的宽度 | `number` | `0` | - | 设置为大于 0 时才可以启用虚拟滚动 |
| `getRowHeight` | 列表行的高度获取函数 | `(rowKey: VKey) => number \| undefined` | - | - | 在行高不固定的场景下尽可能减少抖动 |
| `getColWidth` | 列表列的宽度获取函数 | `(rowKey: VKey, colWidth: VKey) => number \| undefined` | - | - | 在列宽不固定的场景下尽可能减少抖动 |
| `rowRender` | 列表行的渲染函数 | `VirtualRowRenderFn \| #row={item, index}` | - | - | 必须设置或者提供 `row` 插槽 |
| `colRender` | 列表列的渲染函数 | `VirtualColRenderFn \| #col={row, item, index}` | - | - | 必须设置或者提供 `col` 插槽 |
| `scrollMode` | 使用的滚动模式,支持原生滚动和模拟滚动 | `'native' \| 'simulated'` | `'native'` | - | - |
| `virtual` | 是否启用虚拟滚动 | `boolean \| VirtualScrollEnabled` | `{ horizontal: true, vertical: false }` | - | - |
| `buffer` | 缓冲区大小 | `numnber` | `0` | - | - |
| `bufferOffset` | 在距离数据边界有几项时开始渲染下一屏数据 | `numnber` | `0` | - | - |
Expand All @@ -45,6 +48,8 @@ type VirtualScrollEnabled = { horizontal: boolean; vertical: boolean }
| --- | --- | --- | --- |
| `scrollTo` | 手动设置滚动条位置 | `(value?: number \| VirtualScrollToOptions) => void` | 支持滚动到具体的 key 或者 index, 以及设置偏移量 |
### FAQ
#### 如何开启横向虚拟滚动
##### 设置virtual
Expand Down Expand Up @@ -75,3 +80,13 @@ interface RowData {
在开启了 `buffer` 后,会在当前窗口可渲染的行列基础上,再多渲染一些数据来缓解渲染频率过高的问题,但这在数据的渲染过于复杂的情况下并不适用,因为单次渲染的节点会增加,从而会导致渲染卡顿。

`bufferOffset` 的意义,是在滚动到距离所有渲染的项目边界还有几项时就提前渲染下一屏的数据,它可以一定程度上让滚动更加顺滑,但是也增加了渲染频率。

#### 虚拟滚动组件在滚动比较快的时候出现短暂白屏如何处理?

由于虚拟滚动默认使用原生滚动来实现,而原生滚动触发的渲染总是会在虚拟滚动内部计算和渲染之前,因此当滚动过快或者内部元素渲染的复杂度太高时,不可避免会出现短暂的白屏。

优化方式有以下几种:

1. 当内部的渲染可优化的空间很大,且短暂的白屏区域并不会影响用户体验时,建议保留原生滚动,对渲染过程进行优化,减少dom节点数,将不必要的计算提前,复用重复的组件等。

2. 当以上无法解决痛点时,可以将 `scrollMode` 配置为 `simulated` 开启模拟滚动,这种滚动方式会在滚动发生之后首先通知组件进行计算并准备下一步的渲染,可视区域的滚动位置和虚拟滚动区域的渲染就会发生在同一时间,因此不会出现白屏。但是需要特别注意的是,由于模拟滚动主要是为了处理性能问题,模拟滚动并不会像原生滚动那样平滑,建议尽量避免使用模拟滚动。
3 changes: 3 additions & 0 deletions packages/cdk/scroll/index.ts
Expand Up @@ -6,6 +6,8 @@
*/

export * from './src/strategy'
export * from './src/scrollbar'
export * from './src/useScroll'
export * from './src/utils'

import type { VirtualScrollComponent } from './src/virtual/types'
Expand All @@ -27,4 +29,5 @@ export type {
VirtualScrollToFn,
VirtualScrollEnabled,
VirtualScrollRowData,
VirtualScrollMode,
} from './src/virtual/types'
55 changes: 55 additions & 0 deletions packages/cdk/scroll/src/scrollbar/Scrollbar.tsx
@@ -0,0 +1,55 @@
/**
* @license
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
*/

import { computed, defineComponent, normalizeStyle, ref } from 'vue'

import { convertCssPixel } from '@idux/cdk/utils'

import { useScrollbarState } from './composables/useScrollbarState'
import { scrollbarProps } from './types'

export default defineComponent({
props: scrollbarProps,
setup(props) {
const scrollbarRef = ref<HTMLElement>()
const thumbRef = ref<HTMLElement>()

const { canScroll, offset, isDragging, thumbSize } = useScrollbarState(props, scrollbarRef, thumbRef)

const classes = computed(() => ({
'cdk-scrollbar': true,
'cdk-scrollbar-vertical': !props.horizontal,
'cdk-scrollbar-horizontal': !!props.horizontal,
}))
const style = computed(() => normalizeStyle(props.containerStyle))
const thumbClass = computed(() => ({
'cdk-scrollbar-thumb': true,
'cdk-scrollbar-thumb-moving': isDragging.value,
}))
const thumbStyle = computed(() => {
const thumbSizeStyle = convertCssPixel(thumbSize.value)
const offsetSizeStyle = convertCssPixel(offset.value)
const _style = props.horizontal
? {
marginLeft: offsetSizeStyle,
width: thumbSizeStyle,
}
: {
marginTop: offsetSizeStyle,
height: thumbSizeStyle,
}

return normalizeStyle([_style, props.thumbStyle])
})

return () => (
<div ref={scrollbarRef} class={classes.value} style={style.value}>
<div v-show={canScroll.value} ref={thumbRef} class={thumbClass.value} style={thumbStyle.value} />
</div>
)
},
})
125 changes: 125 additions & 0 deletions packages/cdk/scroll/src/scrollbar/composables/useScrollbarState.ts
@@ -0,0 +1,125 @@
/**
* @license
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
*/

import type { ScrollbarProps } from '../types'

import { type ComputedRef, type Ref, computed, onBeforeUnmount, onMounted } from 'vue'

import { callEmit, cancelRAF, rAF, useEventListener, useState } from '@idux/cdk/utils'

export interface ScrollbarStateContext {
canScroll: ComputedRef<boolean>
offset: ComputedRef<number>
thumbSize: ComputedRef<number>
isDragging: ComputedRef<boolean>
}

export function useScrollbarState(
props: ScrollbarProps,
scrollbarRef: Ref<HTMLElement | undefined>,
thumbRef: Ref<HTMLElement | undefined>,
): ScrollbarStateContext {
const thumbSize = computed(() => getThumbSize(props.thumbMinSize, props.containerSize, props.scrollRange))

const enabledScrollRange = computed(() => Math.max(props.scrollRange - props.containerSize, 0))
const enabledOffsetRange = computed(() => Math.max(props.containerSize - thumbSize.value, 0))
const offset = computed(() => {
if (props.scrollOffset === 0 || enabledOffsetRange.value === 0) {
return 0
}

return (props.scrollOffset / enabledScrollRange.value) * enabledOffsetRange.value
})
const canScroll = computed(() => enabledScrollRange.value > 0)

const [isDragging, setIsDragging] = useState(false)
const [startOffset, setStartOffset] = useState(0)
const [pageXY, setPageXY] = useState(0)
let rafId: number

const onThumbMouseDown = (evt: MouseEvent | TouchEvent) => {
setIsDragging(true)
setPageXY(getPageXY(evt, props.horizontal))
setStartOffset(offset.value)

callEmit(props.onMoveStart)
evt.stopPropagation()
evt.preventDefault()
}

const onMouseMove = (evt: MouseEvent | TouchEvent) => {
cancelRAF(rafId)
if (!isDragging.value) {
return
}

const _enabledScrollRange = enabledScrollRange.value
const movedOffset = getPageXY(evt, props.horizontal) - pageXY.value
const newOffset = startOffset.value + movedOffset

const ptg = _enabledScrollRange ? newOffset / enabledOffsetRange.value : 0

let newScrollOffset = Math.ceil(ptg * _enabledScrollRange)
newScrollOffset = Math.max(newScrollOffset, 0)
newScrollOffset = Math.min(newScrollOffset, _enabledScrollRange)

rafId = rAF(() => {
callEmit(props.onScroll, newScrollOffset)
})
}
const onMouseUp = () => {
setIsDragging(false)
callEmit(props.onMoveEnd)
}

const onScrollbarTouchStart = (evt: TouchEvent) => {
evt.preventDefault()
}

let listenerStops: (() => void)[] = []
const clearListeners = () => {
listenerStops.forEach(stop => stop())
}

onMounted(() => {
listenerStops = [
useEventListener(scrollbarRef, 'touchstart', onScrollbarTouchStart),
useEventListener(thumbRef, 'touchstart', onThumbMouseDown),
useEventListener(thumbRef, 'mousedown', onThumbMouseDown),
useEventListener(window, 'mousemove', onMouseMove),
useEventListener(window, 'touchmove', onMouseMove),
useEventListener(window, 'mouseup', onMouseUp),
useEventListener(window, 'touchend', onMouseUp),
]
})
onBeforeUnmount(() => {
clearListeners()
cancelRAF(rafId)
})

return {
canScroll,
offset,
thumbSize,
isDragging,
}
}

function getPageXY(evt: MouseEvent | TouchEvent, horizontal: boolean) {
const pageData = 'touches' in evt ? evt.touches[0] : evt
return pageData[horizontal ? 'pageX' : 'pageY']
}

function getThumbSize(thumbMinSize: number, containerSize = 0, scrollRange = 0) {
let baseSize = (containerSize / scrollRange) * containerSize
if (isNaN(baseSize)) {
baseSize = 0
}
baseSize = Math.max(baseSize, thumbMinSize)
baseSize = Math.min(baseSize, containerSize / 2)
return Math.floor(baseSize)
}
16 changes: 16 additions & 0 deletions packages/cdk/scroll/src/scrollbar/index.ts
@@ -0,0 +1,16 @@
/**
* @license
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
*/

import type { ScrollbarComponent } from './types'

import Scrollbar from './Scrollbar'

const CdkScrollbar = Scrollbar as ScrollbarComponent

export { CdkScrollbar }

export type { ScrollbarComponent, ScrollBarInstance, ScrollbarPublicProps as ScrollbarProps } from './types'

0 comments on commit 17ccefa

Please sign in to comment.