From fac360b4e01b7607f037c594fc5cda4f476d67c1 Mon Sep 17 00:00:00 2001 From: Svetoslav Dekov Date: Wed, 24 Sep 2025 10:19:31 +0300 Subject: [PATCH] Implement dynamic row height support when using virtual scroll --- README.md | 4 +- example/VirtualList.vue | 16 +- src/components/Tree/index.tsx | 272 ++++++++++++++++++++++------ src/components/TreeNode/styles.less | 4 + 4 files changed, 234 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 82876be..7192f29 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,9 @@ plugins: [ | showDoubleQuotes | Show doublequotes on key | boolean | true | | virtual | Use virtual scroll | boolean | false | | height | The height of list when using virtual | number | 400 | -| itemHeight | The height of node when using virtual | number | 20 | +| itemHeight | Fixed row height when using virtual (ignored if `dynamicHeight` is true) | number | 20 | +| dynamicHeight | Enable dynamic row heights (measured per row) | boolean | false | +| estimatedItemHeight | Estimated row height used before measurement when `dynamicHeight` is true | number | 20 | | selectedValue(v-model) | Selected data path | string, array | - | | rootPath | Root data path | string | `root` | | nodeSelectable | Defines whether a node supports selection | (node) => boolean | - | diff --git a/example/VirtualList.vue b/example/VirtualList.vue index 710bd26..9bf40cb 100644 --- a/example/VirtualList.vue +++ b/example/VirtualList.vue @@ -10,6 +10,14 @@ +
+ + +
+
+ + +
@@ -49,6 +57,8 @@ :collapsed-node-length="state.collapsedNodeLength" :virtual="true" :item-height="+state.itemHeight" + :dynamic-height="state.dynamicHeight" + :estimated-item-height="+state.estimatedItemHeight" :data="state.data" :deep="state.deep" :show-line="state.showLine" @@ -74,7 +84,9 @@ const defaultData = { for (let i = 0; i < 10000; i++) { defaultData.data.push({ news_id: i, - title: 'iPhone X Review: Innovative future with real black technology', + 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. + +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.`, source: 'Netease phone', }); } @@ -93,6 +105,8 @@ export default defineComponent({ deep: 3, collapsedNodeLength: Infinity, itemHeight: 20, + dynamicHeight: true, + estimatedItemHeight: 20, }); const { localDarkMode, toggleLocalDarkMode, globalDarkModeState } = useDarkMode(); diff --git a/src/components/Tree/index.tsx b/src/components/Tree/index.tsx index b9f36c3..20ee504 100644 --- a/src/components/Tree/index.tsx +++ b/src/components/Tree/index.tsx @@ -7,6 +7,7 @@ import { ref, PropType, CSSProperties, + nextTick, } from 'vue'; import TreeNode, { treeNodePropsPass, NodeDataType } from 'src/components/TreeNode'; import { emitError, jsonFlatten, cloneDeep } from 'src/utils'; @@ -40,11 +41,21 @@ export default defineComponent({ type: Number, default: 400, }, - // When using virtual scroll, define the height of each row. + // When using virtual scroll without dynamicHeight, define the height of each row. itemHeight: { type: Number, default: 20, }, + // Enable dynamic row heights for virtual scroll. + dynamicHeight: { + type: Boolean, + default: false, + }, + // Estimated item height used before measurement in dynamic mode. + estimatedItemHeight: { + type: Number, + default: 20, + }, // When there is a selection function, define the selected path. // For multiple selections, it is an array ['root.a','root.b'], for single selection, it is a string of 'root.a'. selectedValue: { @@ -104,8 +115,73 @@ export default defineComponent({ translateY: 0, visibleData: null as NodeDataType[] | null, hiddenPaths: initHiddenPaths(props.deep, props.collapsedNodeLength), + startIndex: 0, + endIndex: 0, }); + // Dynamic height bookkeeping + // heights[i] is the measured height of row i in the current flatData (or estimated if not measured yet) + // offsets[i] is the cumulative offset before row i (offsets[0] = 0, offsets[length] = totalHeight) + let heights: number[] = []; + let offsets: number[] = []; + let totalHeight = 0; + const rowRefs: Record = {}; + const OVERSCAN_COUNT = 5; + + const initDynamicHeights = (length: number) => { + heights = Array(length) + .fill(0) + .map(() => props.estimatedItemHeight || props.itemHeight || 20); + offsets = new Array(length + 1); + offsets[0] = 0; + for (let i = 0; i < length; i++) { + offsets[i + 1] = offsets[i] + heights[i]; + } + totalHeight = offsets[length] || 0; + }; + + const recomputeOffsetsFrom = (start: number) => { + const length = heights.length; + if (start < 0) start = 0; + if (start > length) start = length; + for (let i = start; i < length; i++) { + offsets[i + 1] = offsets[i] + heights[i]; + } + totalHeight = offsets[length] || 0; + }; + + const setRowRef = (index: number, el: HTMLElement | null) => { + if (el) { + rowRefs[index] = el; + } else { + delete rowRefs[index]; + } + }; + + const lowerBound = (arr: number[], target: number) => { + // first index i where arr[i] >= target + let lo = 0; + let hi = arr.length - 1; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (arr[mid] < target) lo = mid + 1; + else hi = mid; + } + return lo; + }; + + const findStartIndexByScrollTop = (scrollTop: number) => { + // largest i such that offsets[i] <= scrollTop + const i = lowerBound(offsets, scrollTop + 0.0001); // epsilon to handle exact matches + return Math.max(0, Math.min(i - 1, heights.length - 1)); + }; + + const findEndIndexByViewport = (scrollTop: number, viewportHeight: number) => { + const target = scrollTop + viewportHeight; + const i = lowerBound(offsets, target); + return Math.max(0, Math.min(i + 1, heights.length)); + }; + const flatData = computed(() => { let startHiddenItem: null | NodeDataType = null; const data = []; @@ -154,31 +230,89 @@ export default defineComponent({ : ''; }); + const listHeight = computed(() => { + if (props.dynamicHeight) { + return totalHeight || 0; + } + return flatData.value.length * props.itemHeight; + }); + const updateVisibleData = () => { const flatDataValue = flatData.value; + if (!flatDataValue) return; if (props.virtual) { - const visibleCount = props.height / props.itemHeight; const scrollTop = treeRef.value?.scrollTop || 0; - const scrollCount = Math.floor(scrollTop / props.itemHeight); - let start = - scrollCount < 0 - ? 0 - : scrollCount + visibleCount > flatDataValue.length - ? flatDataValue.length - visibleCount - : scrollCount; - if (start < 0) { - start = 0; + + if (props.dynamicHeight) { + // Ensure dynamic arrays are initialized and consistent with data length + if (heights.length !== flatDataValue.length) { + initDynamicHeights(flatDataValue.length); + } + + const start = findStartIndexByScrollTop(scrollTop); + const endNoOverscan = findEndIndexByViewport(scrollTop, props.height); + const startWithOverscan = Math.max(0, start - OVERSCAN_COUNT); + const endWithOverscan = Math.min(flatDataValue.length, endNoOverscan + OVERSCAN_COUNT); + + state.startIndex = startWithOverscan; + state.endIndex = endWithOverscan; + state.translateY = offsets[startWithOverscan] || 0; + state.visibleData = flatDataValue.slice(startWithOverscan, endWithOverscan); + + // Measure after render and update heights/offets if needed + nextTick().then(() => { + let changed = false; + for (let i = state.startIndex; i < state.endIndex; i++) { + const el = rowRefs[i]; + if (!el) continue; + const h = el.offsetHeight; + if (h && heights[i] !== h) { + heights[i] = h; + // Update offsets from i forward + offsets[i + 1] = offsets[i] + heights[i]; + recomputeOffsetsFrom(i + 1); + changed = true; + } + } + if (changed) { + // Recalculate slice based on new offsets + updateVisibleData(); + } + }); + } else { + const visibleCount = props.height / props.itemHeight; + const scrollCount = Math.floor(scrollTop / props.itemHeight); + let start = + scrollCount < 0 + ? 0 + : scrollCount + visibleCount > flatDataValue.length + ? flatDataValue.length - visibleCount + : scrollCount; + if (start < 0) { + start = 0; + } + const end = start + visibleCount; + state.translateY = start * props.itemHeight; + state.startIndex = start; + state.endIndex = end; + state.visibleData = flatDataValue.slice(start, end); } - const end = start + visibleCount; - state.translateY = start * props.itemHeight; - state.visibleData = flatDataValue.filter((item, index) => index >= start && index < end); } else { + state.translateY = 0; + state.startIndex = 0; + state.endIndex = flatDataValue.length; state.visibleData = flatDataValue; } }; + let rafId: number | null = null; const handleTreeScroll = () => { - updateVisibleData(); + if (rafId) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + updateVisibleData(); + }); }; const handleSelectedChange = ({ path }: NodeDataType) => { @@ -251,10 +385,26 @@ export default defineComponent({ watchEffect(() => { if (flatData.value) { + if (props.virtual && props.dynamicHeight) { + if (heights.length !== flatData.value.length) { + initDynamicHeights(flatData.value.length); + } + } updateVisibleData(); } }); + // Re-initialize dynamic height arrays when data shape changes significantly + watch( + () => [props.dynamicHeight, props.estimatedItemHeight, originFlatData.value.length], + () => { + if (props.virtual && props.dynamicHeight) { + initDynamicHeights(flatData.value.length); + nextTick(updateVisibleData); + } + }, + ); + watch( () => props.deep, val => { @@ -274,47 +424,52 @@ export default defineComponent({ const renderNodeValue = props.renderNodeValue ?? slots.renderNodeValue; const renderNodeActions = props.renderNodeActions ?? slots.renderNodeActions ?? false; - const nodeContent = - state.visibleData && - state.visibleData.map(item => ( - - )); + const nodeContent = state.visibleData?.map((item, localIndex) => { + const globalIndex = state.startIndex + localIndex; + return ( +
setRowRef(globalIndex, (el as HTMLElement) || null)}> + +
+ ); + }); return (
{props.virtual ? (
-
+