Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/primitives/virtual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions src/primitives/Virtual.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,20 @@ function createVirtual<T>(

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;
Expand Down
99 changes: 99 additions & 0 deletions tests/contract-virtual.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof v.vi.spyOn> | undefined;
try {
const [duration, setDuration] = s.createSignal(1000);
let row!: lng.ElementNode;
dispose = await mount(() => (
<view width={1920} height={1080}>
<Component
ref={row}
autofocus
x={50}
y={50}
each={twelve}
displaySize={4}
transitionLeft={false}
transitionRight={false}
transitionUp={false}
transitionDown={false}
animationSettings={{ duration: duration() }}
>
{(item) => <Item item={item()} />}
</Component>
</view>
));
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;

Expand Down
84 changes: 84 additions & 0 deletions tests/webgl/virtual-animation.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<view width={1920} height={1080}>
<Component
ref={row}
autofocus
x={50}
y={50}
each={Array.from({ length: 12 }, (_, i) => i)}
displaySize={4}
transitionLeft={false}
transitionRight={false}
transitionUp={false}
transitionDown={false}
animationSettings={{ duration: duration() }}
>
{(item) => (
<view
id={`item-${item()}`}
item={item()}
width={200}
height={100}
/>
)}
</Component>
</view>
);
});
try {
await settle();
keys.dispatchEvent(new KeyboardEvent('keydown', { key }));
// Navigation and applyShift run in microtasks, before an animation frame.
await new Promise<void>((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;
}
},
);
}