Skip to content

Commit 006a60c

Browse files
committed
fix(virtual-core): smooth scrolling for dynamic item sizes
1 parent 5d6acc9 commit 006a60c

File tree

2 files changed

+170
-94
lines changed

2 files changed

+170
-94
lines changed

examples/react/dynamic/src/main.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ function RowVirtualizerDynamic() {
2626
enabled,
2727
})
2828

29+
React.useEffect(() => {
30+
virtualizer.scrollToIndex(count - 1, { align: 'end' })
31+
}, [])
32+
2933
const items = virtualizer.getVirtualItems()
3034

3135
return (
@@ -40,7 +44,7 @@ function RowVirtualizerDynamic() {
4044
<span style={{ padding: '0 4px' }} />
4145
<button
4246
onClick={() => {
43-
virtualizer.scrollToIndex(count / 2)
47+
virtualizer.scrollToIndex(count / 2, { behavior: 'smooth' })
4448
}}
4549
>
4650
scroll to the middle

packages/virtual-core/src/index.ts

Lines changed: 165 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,22 @@ export interface VirtualizerOptions<
348348
useAnimationFrameWithResizeObserver?: boolean
349349
}
350350

351+
type ScrollState = {
352+
// what we want
353+
index: number | null
354+
align: ScrollAlignment
355+
behavior: ScrollBehavior
356+
357+
// lifecycle
358+
startedAt: number
359+
360+
// target tracking
361+
lastTargetOffset: number
362+
363+
// settling
364+
stableFrames: number
365+
}
366+
351367
export class Virtualizer<
352368
TScrollElement extends Element | Window,
353369
TItemElement extends Element,
@@ -357,7 +373,7 @@ export class Virtualizer<
357373
scrollElement: TScrollElement | null = null
358374
targetWindow: (Window & typeof globalThis) | null = null
359375
isScrolling = false
360-
private currentScrollToIndex: number | null = null
376+
private scrollState: ScrollState | null = null
361377
measurementsCache: Array<VirtualItem> = []
362378
private itemSizeCache = new Map<Key, number>()
363379
private laneAssignments = new Map<number, number>() // index → lane cache
@@ -535,6 +551,9 @@ export class Virtualizer<
535551
this.scrollOffset = offset
536552
this.isScrolling = isScrolling
537553

554+
if (this.scrollState) {
555+
this.scheduleScrollReconcile()
556+
}
538557
this.maybeNotify()
539558
}),
540559
)
@@ -546,6 +565,63 @@ export class Virtualizer<
546565
}
547566
}
548567

568+
private rafId: number | null = null
569+
private scheduleScrollReconcile() {
570+
if (!this.targetWindow) return
571+
if (this.rafId != null) return
572+
this.rafId = this.targetWindow.requestAnimationFrame(() => {
573+
this.rafId = null
574+
this.reconcileScroll()
575+
})
576+
}
577+
private reconcileScroll() {
578+
if (!this.scrollState) return
579+
580+
const el = this.scrollElement
581+
if (!el) return
582+
583+
const targetOffset = this.scrollState.index
584+
? this.getOffsetForIndex(
585+
this.scrollState.index,
586+
this.scrollState.align,
587+
)[0]
588+
: this.getOffsetForAlignment(
589+
this.scrollState.lastTargetOffset,
590+
this.scrollState.align,
591+
)
592+
593+
// Require one stable frame where target matches scroll offset.
594+
// approxEqual() already tolerates minor fluctuations, so one frame is sufficient
595+
// to confirm scroll has reached its target without premature cleanup.
596+
const STABLE_FRAMES = 1
597+
598+
const targetChanged = targetOffset !== this.scrollState.lastTargetOffset
599+
600+
if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
601+
this.scrollState.stableFrames++
602+
if (this.scrollState.stableFrames >= STABLE_FRAMES) {
603+
this.scrollState = null
604+
} else {
605+
this.scheduleScrollReconcile()
606+
}
607+
return
608+
}
609+
610+
this.scrollState.stableFrames = 0
611+
612+
if (targetChanged) {
613+
this.scrollState.lastTargetOffset = targetOffset
614+
// Switch to 'auto' behavior once measurements cause target to change
615+
// We want to jump directly to the correct position, not smoothly animate to it
616+
this.scrollState.behavior = 'auto'
617+
618+
this._scrollToOffset(targetOffset, {
619+
adjustments: undefined,
620+
behavior: 'auto',
621+
})
622+
}
623+
}
624+
549625
private getSize = () => {
550626
if (!this.options.enabled) {
551627
this.scrollRect = null
@@ -859,6 +935,38 @@ export class Virtualizer<
859935
return parseInt(indexStr, 10)
860936
}
861937

938+
/**
939+
* Determines if an item at the given index should be measured during smooth scroll.
940+
* During smooth scroll, only items within a buffer range around the target are measured
941+
* to prevent items far from the target from pushing it away.
942+
*/
943+
private shouldMeasureDuringScroll = (index: number): boolean => {
944+
// No scroll state or not smooth scroll - always allow measurements
945+
if (!this.scrollState || this.scrollState.behavior !== 'smooth') {
946+
return true
947+
}
948+
949+
const scrollIndex =
950+
this.scrollState.index ??
951+
this.getVirtualItemForOffset(this.scrollState.lastTargetOffset)?.index
952+
953+
if (scrollIndex !== undefined && this.range) {
954+
// Allow measurements within a buffer range around the scroll target
955+
const bufferSize = Math.max(
956+
this.options.overscan,
957+
Math.ceil((this.range.endIndex - this.range.startIndex) / 2),
958+
)
959+
const minIndex = Math.max(0, scrollIndex - bufferSize)
960+
const maxIndex = Math.min(
961+
this.options.count - 1,
962+
scrollIndex + bufferSize,
963+
)
964+
return index >= minIndex && index <= maxIndex
965+
}
966+
967+
return true
968+
}
969+
862970
private _measureElement = (
863971
node: TItemElement,
864972
entry: ResizeObserverEntry | undefined,
@@ -879,7 +987,7 @@ export class Virtualizer<
879987
this.elementsCache.set(key, node)
880988
}
881989

882-
if (node.isConnected) {
990+
if (node.isConnected && this.shouldMeasureDuringScroll(index)) {
883991
this.resizeItem(index, this.options.measureElement(node, entry, this))
884992
}
885993
}
@@ -894,14 +1002,14 @@ export class Virtualizer<
8941002

8951003
if (delta !== 0) {
8961004
if (
897-
this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
1005+
this.scrollState?.behavior !== 'smooth' &&
1006+
(this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
8981007
? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this)
899-
: item.start < this.getScrollOffset() + this.scrollAdjustments
1008+
: item.start < this.getScrollOffset() + this.scrollAdjustments)
9001009
) {
9011010
if (process.env.NODE_ENV !== 'production' && this.options.debug) {
9021011
console.info('correction', delta)
9031012
}
904-
9051013
this._scrollToOffset(this.getScrollOffset(), {
9061014
adjustments: (this.scrollAdjustments += delta),
9071015
behavior: undefined,
@@ -1013,14 +1121,15 @@ export class Virtualizer<
10131121
getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => {
10141122
index = Math.max(0, Math.min(index, this.options.count - 1))
10151123

1124+
const size = this.getSize()
1125+
const scrollOffset = this.getScrollOffset()
1126+
10161127
const item = this.measurementsCache[index]
10171128
if (!item) {
1018-
return undefined
1129+
console.warn('No measurement found for index:', index)
1130+
return [scrollOffset, align] as const
10191131
}
10201132

1021-
const size = this.getSize()
1022-
const scrollOffset = this.getScrollOffset()
1023-
10241133
if (align === 'auto') {
10251134
if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) {
10261135
align = 'end'
@@ -1048,110 +1157,73 @@ export class Virtualizer<
10481157
] as const
10491158
}
10501159

1051-
private isDynamicMode = () => this.elementsCache.size > 0
1052-
10531160
scrollToOffset = (
10541161
toOffset: number,
1055-
{ align = 'start', behavior }: ScrollToOffsetOptions = {},
1162+
{ align = 'start', behavior = 'auto' }: ScrollToOffsetOptions = {},
10561163
) => {
1057-
if (behavior === 'smooth' && this.isDynamicMode()) {
1058-
console.warn(
1059-
'The `smooth` scroll behavior is not fully supported with dynamic size.',
1060-
)
1061-
}
1164+
const offset = this.getOffsetForAlignment(toOffset, align)
10621165

1063-
this._scrollToOffset(this.getOffsetForAlignment(toOffset, align), {
1064-
adjustments: undefined,
1166+
const now = performance.now()
1167+
this.scrollState = {
1168+
index: null,
1169+
align,
10651170
behavior,
1066-
})
1171+
startedAt: now,
1172+
lastTargetOffset: offset,
1173+
stableFrames: 0,
1174+
}
1175+
1176+
this._scrollToOffset(offset, { adjustments: undefined, behavior })
1177+
1178+
this.scheduleScrollReconcile()
10671179
}
10681180

10691181
scrollToIndex = (
10701182
index: number,
1071-
{ align: initialAlign = 'auto', behavior }: ScrollToIndexOptions = {},
1183+
{
1184+
align: initialAlign = 'auto',
1185+
behavior = 'auto',
1186+
}: ScrollToIndexOptions = {},
10721187
) => {
1073-
if (behavior === 'smooth' && this.isDynamicMode()) {
1074-
console.warn(
1075-
'The `smooth` scroll behavior is not fully supported with dynamic size.',
1076-
)
1077-
}
1078-
10791188
index = Math.max(0, Math.min(index, this.options.count - 1))
1080-
this.currentScrollToIndex = index
1081-
1082-
let attempts = 0
1083-
const maxAttempts = 10
1084-
1085-
const tryScroll = (currentAlign: ScrollAlignment) => {
1086-
if (!this.targetWindow) return
1087-
1088-
const offsetInfo = this.getOffsetForIndex(index, currentAlign)
1089-
if (!offsetInfo) {
1090-
console.warn('Failed to get offset for index:', index)
1091-
return
1092-
}
1093-
const [offset, align] = offsetInfo
1094-
this._scrollToOffset(offset, { adjustments: undefined, behavior })
1095-
1096-
this.targetWindow.requestAnimationFrame(() => {
1097-
const verify = () => {
1098-
// Abort if a new scrollToIndex was called with a different index
1099-
if (this.currentScrollToIndex !== index) return
1100-
1101-
const currentOffset = this.getScrollOffset()
1102-
const afterInfo = this.getOffsetForIndex(index, align)
1103-
if (!afterInfo) {
1104-
console.warn('Failed to get offset for index:', index)
1105-
return
1106-
}
11071189

1108-
if (!approxEqual(afterInfo[0], currentOffset)) {
1109-
scheduleRetry(align)
1110-
}
1111-
}
1190+
const offsetInfo = this.getOffsetForIndex(index, initialAlign)
1191+
const [offset, align] = offsetInfo
11121192

1113-
// In dynamic mode, wait an extra frame for ResizeObserver to measure newly visible elements
1114-
if (this.isDynamicMode()) {
1115-
this.targetWindow!.requestAnimationFrame(verify)
1116-
} else {
1117-
verify()
1118-
}
1119-
})
1193+
const now = performance.now()
1194+
this.scrollState = {
1195+
index,
1196+
align,
1197+
behavior,
1198+
startedAt: now,
1199+
lastTargetOffset: offset,
1200+
stableFrames: 0,
11201201
}
11211202

1122-
const scheduleRetry = (align: ScrollAlignment) => {
1123-
if (!this.targetWindow) return
1124-
1125-
// Abort if a new scrollToIndex was called with a different index
1126-
if (this.currentScrollToIndex !== index) return
1203+
this._scrollToOffset(offset, { adjustments: undefined, behavior })
11271204

1128-
attempts++
1129-
if (attempts < maxAttempts) {
1130-
if (process.env.NODE_ENV !== 'production' && this.options.debug) {
1131-
console.info('Schedule retry', attempts, maxAttempts)
1132-
}
1133-
this.targetWindow.requestAnimationFrame(() => tryScroll(align))
1134-
} else {
1135-
console.warn(
1136-
`Failed to scroll to index ${index} after ${maxAttempts} attempts.`,
1137-
)
1138-
}
1139-
}
1140-
1141-
tryScroll(initialAlign)
1205+
this.scheduleScrollReconcile()
11421206
}
11431207

1144-
scrollBy = (delta: number, { behavior }: ScrollToOffsetOptions = {}) => {
1145-
if (behavior === 'smooth' && this.isDynamicMode()) {
1146-
console.warn(
1147-
'The `smooth` scroll behavior is not fully supported with dynamic size.',
1148-
)
1149-
}
1208+
scrollBy = (
1209+
delta: number,
1210+
{ behavior = 'auto' }: ScrollToOffsetOptions = {},
1211+
) => {
1212+
const offset = this.getScrollOffset() + delta
1213+
const now = performance.now()
11501214

1151-
this._scrollToOffset(this.getScrollOffset() + delta, {
1152-
adjustments: undefined,
1215+
this.scrollState = {
1216+
index: null,
1217+
align: 'start',
11531218
behavior,
1154-
})
1219+
startedAt: now,
1220+
lastTargetOffset: offset,
1221+
stableFrames: 0,
1222+
}
1223+
1224+
this._scrollToOffset(offset, { adjustments: undefined, behavior })
1225+
1226+
this.scheduleScrollReconcile()
11551227
}
11561228

11571229
getTotalSize = () => {

0 commit comments

Comments
 (0)