Skip to content

Commit 4568b95

Browse files
authored
Merge pull request #299 from scdekov/feat-virtual-scroll-dynamic-row-height
feat: Add dynamic-height virtualization
2 parents 3badb38 + fac360b commit 4568b95

File tree

4 files changed

+234
-62
lines changed

4 files changed

+234
-62
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,9 @@ plugins: [
119119
| showDoubleQuotes | Show doublequotes on key | boolean | true |
120120
| virtual | Use virtual scroll | boolean | false |
121121
| height | The height of list when using virtual | number | 400 |
122-
| itemHeight | The height of node when using virtual | number | 20 |
122+
| itemHeight | Fixed row height when using virtual (ignored if `dynamicHeight` is true) | number | 20 |
123+
| dynamicHeight | Enable dynamic row heights (measured per row) | boolean | false |
124+
| estimatedItemHeight | Estimated row height used before measurement when `dynamicHeight` is true | number | 20 |
123125
| selectedValue(v-model) | Selected data path | string, array | - |
124126
| rootPath | Root data path | string | `root` |
125127
| nodeSelectable | Defines whether a node supports selection | (node) => boolean | - |

example/VirtualList.vue

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@
1010
<label>itemHeight</label>
1111
<input v-model="state.itemHeight" type="number" />
1212
</div>
13+
<div>
14+
<label>dynamicHeight</label>
15+
<input v-model="state.dynamicHeight" type="checkbox" />
16+
</div>
17+
<div v-if="state.dynamicHeight">
18+
<label>estimatedItemHeight</label>
19+
<input v-model="state.estimatedItemHeight" type="number" />
20+
</div>
1321
<div>
1422
<label>showLine</label>
1523
<input v-model="state.showLine" type="checkbox" />
@@ -49,6 +57,8 @@
4957
:collapsed-node-length="state.collapsedNodeLength"
5058
:virtual="true"
5159
:item-height="+state.itemHeight"
60+
:dynamic-height="state.dynamicHeight"
61+
:estimated-item-height="+state.estimatedItemHeight"
5262
:data="state.data"
5363
:deep="state.deep"
5464
:show-line="state.showLine"
@@ -74,7 +84,9 @@ const defaultData = {
7484
for (let i = 0; i < 10000; i++) {
7585
defaultData.data.push({
7686
news_id: i,
77-
title: 'iPhone X Review: Innovative future with real black technology',
87+
title: `Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32.
88+
89+
The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from "de Finibus Bonorum et Malorum" by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.`,
7890
source: 'Netease phone',
7991
});
8092
}
@@ -93,6 +105,8 @@ export default defineComponent({
93105
deep: 3,
94106
collapsedNodeLength: Infinity,
95107
itemHeight: 20,
108+
dynamicHeight: true,
109+
estimatedItemHeight: 20,
96110
});
97111
98112
const { localDarkMode, toggleLocalDarkMode, globalDarkModeState } = useDarkMode();

src/components/Tree/index.tsx

Lines changed: 212 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
ref,
88
PropType,
99
CSSProperties,
10+
nextTick,
1011
} from 'vue';
1112
import TreeNode, { treeNodePropsPass, NodeDataType } from 'src/components/TreeNode';
1213
import { emitError, jsonFlatten, cloneDeep } from 'src/utils';
@@ -40,11 +41,21 @@ export default defineComponent({
4041
type: Number,
4142
default: 400,
4243
},
43-
// When using virtual scroll, define the height of each row.
44+
// When using virtual scroll without dynamicHeight, define the height of each row.
4445
itemHeight: {
4546
type: Number,
4647
default: 20,
4748
},
49+
// Enable dynamic row heights for virtual scroll.
50+
dynamicHeight: {
51+
type: Boolean,
52+
default: false,
53+
},
54+
// Estimated item height used before measurement in dynamic mode.
55+
estimatedItemHeight: {
56+
type: Number,
57+
default: 20,
58+
},
4859
// When there is a selection function, define the selected path.
4960
// For multiple selections, it is an array ['root.a','root.b'], for single selection, it is a string of 'root.a'.
5061
selectedValue: {
@@ -104,8 +115,73 @@ export default defineComponent({
104115
translateY: 0,
105116
visibleData: null as NodeDataType[] | null,
106117
hiddenPaths: initHiddenPaths(props.deep, props.collapsedNodeLength),
118+
startIndex: 0,
119+
endIndex: 0,
107120
});
108121

122+
// Dynamic height bookkeeping
123+
// heights[i] is the measured height of row i in the current flatData (or estimated if not measured yet)
124+
// offsets[i] is the cumulative offset before row i (offsets[0] = 0, offsets[length] = totalHeight)
125+
let heights: number[] = [];
126+
let offsets: number[] = [];
127+
let totalHeight = 0;
128+
const rowRefs: Record<number, HTMLElement | null> = {};
129+
const OVERSCAN_COUNT = 5;
130+
131+
const initDynamicHeights = (length: number) => {
132+
heights = Array(length)
133+
.fill(0)
134+
.map(() => props.estimatedItemHeight || props.itemHeight || 20);
135+
offsets = new Array(length + 1);
136+
offsets[0] = 0;
137+
for (let i = 0; i < length; i++) {
138+
offsets[i + 1] = offsets[i] + heights[i];
139+
}
140+
totalHeight = offsets[length] || 0;
141+
};
142+
143+
const recomputeOffsetsFrom = (start: number) => {
144+
const length = heights.length;
145+
if (start < 0) start = 0;
146+
if (start > length) start = length;
147+
for (let i = start; i < length; i++) {
148+
offsets[i + 1] = offsets[i] + heights[i];
149+
}
150+
totalHeight = offsets[length] || 0;
151+
};
152+
153+
const setRowRef = (index: number, el: HTMLElement | null) => {
154+
if (el) {
155+
rowRefs[index] = el;
156+
} else {
157+
delete rowRefs[index];
158+
}
159+
};
160+
161+
const lowerBound = (arr: number[], target: number) => {
162+
// first index i where arr[i] >= target
163+
let lo = 0;
164+
let hi = arr.length - 1;
165+
while (lo < hi) {
166+
const mid = (lo + hi) >>> 1;
167+
if (arr[mid] < target) lo = mid + 1;
168+
else hi = mid;
169+
}
170+
return lo;
171+
};
172+
173+
const findStartIndexByScrollTop = (scrollTop: number) => {
174+
// largest i such that offsets[i] <= scrollTop
175+
const i = lowerBound(offsets, scrollTop + 0.0001); // epsilon to handle exact matches
176+
return Math.max(0, Math.min(i - 1, heights.length - 1));
177+
};
178+
179+
const findEndIndexByViewport = (scrollTop: number, viewportHeight: number) => {
180+
const target = scrollTop + viewportHeight;
181+
const i = lowerBound(offsets, target);
182+
return Math.max(0, Math.min(i + 1, heights.length));
183+
};
184+
109185
const flatData = computed(() => {
110186
let startHiddenItem: null | NodeDataType = null;
111187
const data = [];
@@ -154,31 +230,89 @@ export default defineComponent({
154230
: '';
155231
});
156232

233+
const listHeight = computed(() => {
234+
if (props.dynamicHeight) {
235+
return totalHeight || 0;
236+
}
237+
return flatData.value.length * props.itemHeight;
238+
});
239+
157240
const updateVisibleData = () => {
158241
const flatDataValue = flatData.value;
242+
if (!flatDataValue) return;
159243
if (props.virtual) {
160-
const visibleCount = props.height / props.itemHeight;
161244
const scrollTop = treeRef.value?.scrollTop || 0;
162-
const scrollCount = Math.floor(scrollTop / props.itemHeight);
163-
let start =
164-
scrollCount < 0
165-
? 0
166-
: scrollCount + visibleCount > flatDataValue.length
167-
? flatDataValue.length - visibleCount
168-
: scrollCount;
169-
if (start < 0) {
170-
start = 0;
245+
246+
if (props.dynamicHeight) {
247+
// Ensure dynamic arrays are initialized and consistent with data length
248+
if (heights.length !== flatDataValue.length) {
249+
initDynamicHeights(flatDataValue.length);
250+
}
251+
252+
const start = findStartIndexByScrollTop(scrollTop);
253+
const endNoOverscan = findEndIndexByViewport(scrollTop, props.height);
254+
const startWithOverscan = Math.max(0, start - OVERSCAN_COUNT);
255+
const endWithOverscan = Math.min(flatDataValue.length, endNoOverscan + OVERSCAN_COUNT);
256+
257+
state.startIndex = startWithOverscan;
258+
state.endIndex = endWithOverscan;
259+
state.translateY = offsets[startWithOverscan] || 0;
260+
state.visibleData = flatDataValue.slice(startWithOverscan, endWithOverscan);
261+
262+
// Measure after render and update heights/offets if needed
263+
nextTick().then(() => {
264+
let changed = false;
265+
for (let i = state.startIndex; i < state.endIndex; i++) {
266+
const el = rowRefs[i];
267+
if (!el) continue;
268+
const h = el.offsetHeight;
269+
if (h && heights[i] !== h) {
270+
heights[i] = h;
271+
// Update offsets from i forward
272+
offsets[i + 1] = offsets[i] + heights[i];
273+
recomputeOffsetsFrom(i + 1);
274+
changed = true;
275+
}
276+
}
277+
if (changed) {
278+
// Recalculate slice based on new offsets
279+
updateVisibleData();
280+
}
281+
});
282+
} else {
283+
const visibleCount = props.height / props.itemHeight;
284+
const scrollCount = Math.floor(scrollTop / props.itemHeight);
285+
let start =
286+
scrollCount < 0
287+
? 0
288+
: scrollCount + visibleCount > flatDataValue.length
289+
? flatDataValue.length - visibleCount
290+
: scrollCount;
291+
if (start < 0) {
292+
start = 0;
293+
}
294+
const end = start + visibleCount;
295+
state.translateY = start * props.itemHeight;
296+
state.startIndex = start;
297+
state.endIndex = end;
298+
state.visibleData = flatDataValue.slice(start, end);
171299
}
172-
const end = start + visibleCount;
173-
state.translateY = start * props.itemHeight;
174-
state.visibleData = flatDataValue.filter((item, index) => index >= start && index < end);
175300
} else {
301+
state.translateY = 0;
302+
state.startIndex = 0;
303+
state.endIndex = flatDataValue.length;
176304
state.visibleData = flatDataValue;
177305
}
178306
};
179307

308+
let rafId: number | null = null;
180309
const handleTreeScroll = () => {
181-
updateVisibleData();
310+
if (rafId) {
311+
cancelAnimationFrame(rafId);
312+
}
313+
rafId = requestAnimationFrame(() => {
314+
updateVisibleData();
315+
});
182316
};
183317

184318
const handleSelectedChange = ({ path }: NodeDataType) => {
@@ -251,10 +385,26 @@ export default defineComponent({
251385

252386
watchEffect(() => {
253387
if (flatData.value) {
388+
if (props.virtual && props.dynamicHeight) {
389+
if (heights.length !== flatData.value.length) {
390+
initDynamicHeights(flatData.value.length);
391+
}
392+
}
254393
updateVisibleData();
255394
}
256395
});
257396

397+
// Re-initialize dynamic height arrays when data shape changes significantly
398+
watch(
399+
() => [props.dynamicHeight, props.estimatedItemHeight, originFlatData.value.length],
400+
() => {
401+
if (props.virtual && props.dynamicHeight) {
402+
initDynamicHeights(flatData.value.length);
403+
nextTick(updateVisibleData);
404+
}
405+
},
406+
);
407+
258408
watch(
259409
() => props.deep,
260410
val => {
@@ -274,47 +424,52 @@ export default defineComponent({
274424
const renderNodeValue = props.renderNodeValue ?? slots.renderNodeValue;
275425
const renderNodeActions = props.renderNodeActions ?? slots.renderNodeActions ?? false;
276426

277-
const nodeContent =
278-
state.visibleData &&
279-
state.visibleData.map(item => (
280-
<TreeNode
281-
key={item.id}
282-
data={props.data}
283-
rootPath={props.rootPath}
284-
indent={props.indent}
285-
node={item}
286-
collapsed={!!state.hiddenPaths[item.path]}
287-
theme={props.theme}
288-
showDoubleQuotes={props.showDoubleQuotes}
289-
showLength={props.showLength}
290-
checked={selectedPaths.value.includes(item.path)}
291-
selectableType={props.selectableType}
292-
showLine={props.showLine}
293-
showLineNumber={props.showLineNumber}
294-
showSelectController={props.showSelectController}
295-
selectOnClickNode={props.selectOnClickNode}
296-
nodeSelectable={props.nodeSelectable}
297-
highlightSelectedNode={props.highlightSelectedNode}
298-
editable={props.editable}
299-
editableTrigger={props.editableTrigger}
300-
showIcon={props.showIcon}
301-
showKeyValueSpace={props.showKeyValueSpace}
302-
renderNodeKey={renderNodeKey}
303-
renderNodeValue={renderNodeValue}
304-
renderNodeActions={renderNodeActions}
305-
onNodeClick={handleNodeClick}
306-
onNodeMouseover={handleNodeMouseover}
307-
onBracketsClick={handleBracketsClick}
308-
onIconClick={handleIconClick}
309-
onSelectedChange={handleSelectedChange}
310-
onValueChange={handleValueChange}
311-
style={
312-
props.itemHeight && props.itemHeight !== 20
313-
? { lineHeight: `${props.itemHeight}px` }
314-
: {}
315-
}
316-
/>
317-
));
427+
const nodeContent = state.visibleData?.map((item, localIndex) => {
428+
const globalIndex = state.startIndex + localIndex;
429+
return (
430+
<div key={item.id} ref={el => setRowRef(globalIndex, (el as HTMLElement) || null)}>
431+
<TreeNode
432+
data={props.data}
433+
rootPath={props.rootPath}
434+
indent={props.indent}
435+
node={item}
436+
collapsed={!!state.hiddenPaths[item.path]}
437+
theme={props.theme}
438+
showDoubleQuotes={props.showDoubleQuotes}
439+
showLength={props.showLength}
440+
checked={selectedPaths.value.includes(item.path)}
441+
selectableType={props.selectableType}
442+
showLine={props.showLine}
443+
showLineNumber={props.showLineNumber}
444+
showSelectController={props.showSelectController}
445+
selectOnClickNode={props.selectOnClickNode}
446+
nodeSelectable={props.nodeSelectable}
447+
highlightSelectedNode={props.highlightSelectedNode}
448+
editable={props.editable}
449+
editableTrigger={props.editableTrigger}
450+
showIcon={props.showIcon}
451+
showKeyValueSpace={props.showKeyValueSpace}
452+
renderNodeKey={renderNodeKey}
453+
renderNodeValue={renderNodeValue}
454+
renderNodeActions={renderNodeActions}
455+
onNodeClick={handleNodeClick}
456+
onNodeMouseover={handleNodeMouseover}
457+
onBracketsClick={handleBracketsClick}
458+
onIconClick={handleIconClick}
459+
onSelectedChange={handleSelectedChange}
460+
onValueChange={handleValueChange}
461+
class={props.dynamicHeight ? 'dynamic-height' : undefined}
462+
style={
463+
props.dynamicHeight
464+
? {}
465+
: props.itemHeight && props.itemHeight !== 20
466+
? { lineHeight: `${props.itemHeight}px` }
467+
: {}
468+
}
469+
/>
470+
</div>
471+
);
472+
});
318473

319474
return (
320475
<div
@@ -336,10 +491,7 @@ export default defineComponent({
336491
>
337492
{props.virtual ? (
338493
<div class="vjs-tree-list" style={{ height: `${props.height}px` }}>
339-
<div
340-
class="vjs-tree-list-holder"
341-
style={{ height: `${flatData.value.length * props.itemHeight}px` }}
342-
>
494+
<div class="vjs-tree-list-holder" style={{ height: `${listHeight.value}px` }}>
343495
<div
344496
class="vjs-tree-list-holder-inner"
345497
style={{ transform: `translateY(${state.translateY}px)` }}

0 commit comments

Comments
 (0)