diff --git a/docs/primitives/virtual.md b/docs/primitives/virtual.md index 52886ca..ede26e8 100644 --- a/docs/primitives/virtual.md +++ b/docs/primitives/virtual.md @@ -51,6 +51,12 @@ import { VirtualRow, VirtualColumn } from './primitives/Virtual'; Use `cursor` property on the node to get the absolute index in the list of items. +### Scroll Animations + +Changing the reactive `animationSettings.duration` to `0` finishes an active +scroll immediately at its target position. Focus and navigation are preserved. +Changing it back to a positive duration enables animation for subsequent scrolls. + ### Performance Optimization - Renders only a subset of the full list (`slice`) for improved memory and render-time performance. diff --git a/src/primitives/Virtual.tsx b/src/primitives/Virtual.tsx index 1a2ed32..1ae4f39 100644 --- a/src/primitives/Virtual.tsx +++ b/src/primitives/Virtual.tsx @@ -620,6 +620,20 @@ function createVirtual( s.createEffect(s.on([() => props.selected, items], updateSelected)); + s.createEffect(() => { + if ( + props.animationSettings?.duration !== 0 || + !cachedAnimationController || + (cachedAnimationController.state !== 'running' && + cachedAnimationController.state !== 'scheduled') + ) { + return; + } + // Settings are copied when a scroll starts; finish it when motion is disabled. + cachedAnimationController.stop(); + if (targetPosition !== undefined) viewRef.lng[axis] = targetPosition; + }); + s.createEffect( s.on(items, () => { if (!viewRef) return; diff --git a/tests/contract-virtual.test.tsx b/tests/contract-virtual.test.tsx index 6b55b96..017e38f 100644 --- a/tests/contract-virtual.test.tsx +++ b/tests/contract-virtual.test.tsx @@ -72,6 +72,105 @@ const Item = (props: { item: number }) => ( const twelve = Array.from({ length: 12 }, (_, i) => i); +v.describe('Virtual: changing the scroll animation duration', () => { + for (const [Component, axis, key, target] of [ + [VirtualRow, 'x', 'ArrowRight', -180], + [VirtualColumn, 'y', 'ArrowDown', -80], + ] as const) { + v.it( + `${axis}: duration 0 finishes an in-flight scroll and navigation still works`, + async () => { + let now = 1000; + let frame: FrameRequestCallback | undefined; + const clock = v.vi + .spyOn(performance, 'now') + .mockImplementation(() => now); + v.vi.stubGlobal( + 'requestAnimationFrame', + (callback: FrameRequestCallback) => { + frame = callback; + return 1; + }, + ); + const advanceFrame = (time: number) => { + now = time; + const callback = frame; + frame = undefined; + callback?.(time); + }; + lng.Config.animationsEnabled = true; + let animate: ReturnType | undefined; + try { + const [duration, setDuration] = s.createSignal(1000); + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + animate = v.vi.spyOn(row, 'animate'); + await press(key); + advanceFrame(1250); + v.expect(row[axis]).toBeGreaterThan(target); + v.expect(row[axis]).toBeLessThan(50); + + // A non-zero settings change must leave the current animation alone. + setDuration(500); + await flush(); + v.expect(row[axis]).toBeGreaterThan(target); + + setDuration(0); + await flush(); + v.expect(row[axis]).toBe(target); + v.expect(focusedId()).toBe('v1'); + advanceFrame(1500); + v.expect(row[axis]).toBe(target); + + await press(key); + advanceFrame(1600); + v.expect(row[axis]).toBe(target); + v.expect(focusedId()).toBe('v2'); + v.expect(row.cursor).toBe(2); + + setDuration(1000); + await flush(); + await press(key); + advanceFrame(1650); + // Re-enabling animation restores an intermediate position. + v.expect(row[axis]).toBeGreaterThan(target); + advanceFrame(3000); + v.expect(row[axis]).toBe(target); + v.expect(focusedId()).toBe('v3'); + } finally { + // Stop real DOM-renderer animations even when a regression fails early. + for (const result of animate?.mock.results ?? []) { + if (result.type === 'return') result.value.stop(); + } + advanceFrame(now + 10000); + animate?.mockRestore(); + lng.Config.animationsEnabled = false; + clock.mockRestore(); + v.vi.unstubAllGlobals(); + } + }, + ); + } +}); + let dispose: (() => void) | undefined; const animationsEnabled = lng.Config.animationsEnabled; diff --git a/tests/webgl/virtual-animation.test.tsx b/tests/webgl/virtual-animation.test.tsx new file mode 100644 index 0000000..aaf171e --- /dev/null +++ b/tests/webgl/virtual-animation.test.tsx @@ -0,0 +1,84 @@ +// The scroll controller must finish on the real renderer as well as the DOM one. +import * as v from 'vitest'; +import * as s from 'solid-js'; +import { activeElement, Config, type ElementNode } from '@solidtv/solid'; +import { + VirtualRow, + VirtualColumn, + useFocusManager, + type KeyEventTarget, +} from '@solidtv/solid/primitives'; +import { render, renderer, settle } from './setup.js'; + +for (const [Component, axis, key, target] of [ + [VirtualRow, 'x', 'ArrowRight', -180], + [VirtualColumn, 'y', 'ArrowDown', -80], +] as const) { + v.it.each(['scheduled', 'running'] as const)( + `${axis}: changing duration to 0 finishes a %s scroll on WebGL`, + async (state) => { + const animationsEnabled = Config.animationsEnabled; + Config.animationsEnabled = true; + const [duration, setDuration] = s.createSignal(10000); + const keys = new EventTarget(); + let row!: ElementNode; + const dispose = render(() => { + useFocusManager(undefined, keys as unknown as KeyEventTarget); + return ( + + i)} + displaySize={4} + transitionLeft={false} + transitionRight={false} + transitionUp={false} + transitionDown={false} + animationSettings={{ duration: duration() }} + > + {(item) => ( + + )} + + + ); + }); + try { + await settle(); + keys.dispatchEvent(new KeyboardEvent('keydown', { key })); + // Navigation and applyShift run in microtasks, before an animation frame. + await new Promise((resolve) => queueMicrotask(resolve)); + v.expect(row.cursor).toBe(1); + if (state === 'running') { + // Advance the real controller deterministically, without a wall-clock wait. + renderer.animationManager.step(1); + v.expect(row[axis]).toBeLessThan(50); + } + v.expect(row[axis]).toBeGreaterThan(target); + + setDuration(0); + v.expect(row[axis]).toBe(target); + await settle(); + v.expect(row[axis]).toBe(target); + v.expect(activeElement()?.id).toBe('item-1'); + + keys.dispatchEvent(new KeyboardEvent('keydown', { key })); + await settle(); + v.expect(row.cursor).toBe(2); + v.expect(activeElement()?.id).toBe('item-2'); + v.expect(row[axis]).toBe(target); + } finally { + dispose(); + Config.animationsEnabled = animationsEnabled; + } + }, + ); +}