Pooled item row example
This app demonstrates graphics processing unit (GPU) animated pooled row scrolling with the RSG 2.0 SDK. The app combines SolidJS reactive state management with the native animation system of Roku SceneGraph.
The problem
Implementing a horizontally scrolling row with hundreds of cards on Roku requires solving two problems:
1. Node pooling
Creating a SceneGraph node for every item in a large dataset is expensive. The row must create only enough nodes to fill the visible screen (plus small buffers) and recycle them as the user scrolls, updating their data in place rather than destroying and recreating nodes.
2. Smooth animation
The row translation must animate between keypress steps (333 ms, outCubic ease), matching the platform reference scroller. This requirement conflicts with pooling: the row must determine when a slot can be recycled.
If the pool is too small, a card that scrolled off screen is recycled (its dataIndex and x update reactively) before the animation finishes sliding it off. As a result, the card visibly jumps to a new position during the animation.
The solution
The solution pools nodes with animation-aware buffers and animates only the group container.
Pooling with animation-aware buffers
The pool size is calculated as:
POOL_SIZE = VISIBLE_CARDS + LEFT_BUFFER + RIGHT_BUFFER
= ~5 + 2 + 1
= ~8A LEFT_BUFFER value of 2 (not 1) provides the extra slot. The card that scrolled off retains its data for two keypress steps, long enough for the 333 ms animation to complete, before the row recycles it to a new dataIndex.
Each slot's state is a pure createMemo derived from highlightedIndex. The app calculates windowStart with LEFT_BUFFER as the look-behind:
const windowStart = Math.max(0, Math.min(idx - LEFT_BUFFER, NUM_CARDS - POOL_SIZE));Slot translation is data-driven, not animated
Each Card sets its translation to dataIndex * CARD_SPACING reactively. Only the group container (cardsGroup) is animated. When a slot is recycled, jumping its x is invisible because it is off screen at that point.
The Tween component
Animating a node's translation from SolidJS is not straightforward because of two SceneGraph constraints:
fieldToInterpscope: AVector2DFieldInterpolator'sfieldToInterpresolves node IDs within the component that owns theAnimationnode. If the Animation lives inside a custom component, it cannot see sibling nodes in the parent component.- Restart deduplication: Writing
control = "start"from the JavaScript thread is deduplicated if the field already holds"start". After an animation ends, SceneGraph writes"stopped"back on the render thread, but the JavaScript side still caches"start"; therefore, subsequent keypresses do nothing.
A Tween composite component solves both problems.
Architecture
The tween.tsx file renders a fragment, which places nodes directly under the parent component's scope:
<>
<animation id={animationId} duration={...} easeFunction={...}>
{/* Static fields are pre-populated; BrightScript writes only keyValue */}
<vector2dFieldInterpolator key={[0.0, 1.0]} fieldToInterp={`${targetId}.translation`} />
</animation>
<tween targetId={...} animationId={animationId} to={...} />
</>- The native
<animation>and its<vector2dFieldInterpolator>child live in the parent component's scope (for example,rowContainer); therefore,fieldToInterp = "cardsGroup.translation"resolves correctly against siblings. - TSX sets all static interpolator data (
keyandfieldToInterp) at construction time; as a result, the app does not need to create BrightScript nodes at runtime. - A unique
animationIdis generated perTweeninstance (tweenAnim_0,tweenAnim_1, and so on) so multiple tweens can coexist in the same parent scope. - The
<tween>node is a lightweight BrightScript controller (extends Group) that holds thetargetId,animationId, andtofields.
BRS controller (Tween.brs)
The onToChanged handler fires on the BrightScript render thread every time the to field changes:
- Looks up
targetNodethroughgetParent().findNode(targetId)and readstranslation[0], which is the current position on the GPU, even during an animation. - Finds the sibling
<animation>on first use throughgetParent().findNode(animationId). - Finds the interpolator on first use through
m.anim.getChild(0)(TSX created it). - Calls
m.anim.control = "stop", writesm.interp.keyValue = [[fromX, 0], [toX, 0]], thenm.anim.control = "start".
Because the handler issues stop and start from the render thread (inside an onChange handler), the JavaScript side does not deduplicate the control field value.
Usage
You can use the Tween component as follows:
<group id="cardsGroup" translation={[0, 0]}>
...cards...
</group>
<Tween targetId="cardsGroup" to={getCardsTranslation()} duration={0.333} easeFunction="outCubic" />The getCardsTranslation() function is a plain reactive accessor, and SolidJS writes the new to value whenever highlightedIndex changes, which triggers the BrightScript handler.
File overview
This table lists the files in the example:
| File | Purpose |
|---|---|
pooled-row.tsx | Reusable container: pooling logic, key handling, scroll/highlight translation |
pooled-item-row.tsx | Example that uses PooledRow: defines Card and wires render props and callbacks |
tween.tsx | SolidJS wrapper: renders <animation> (with interpolator child) + <tween> fragment |
Tween.xml | BrightScript component declaration (targetId, animationId, to) |
Tween.brs | Render-thread controller: reads the position during an animation and drives the animation |
Tween.xml.d.ts | Generated TypeScript declarations for <tween> JSX |
PooledRow API
The PooledRow component is the reusable container. The pooled-item-row.tsx file is a concrete example that uses it.
<PooledRow
// Data
itemCount={40}
// Item dimensions: used for pooling math and per-slot x-translation
itemWidth={360}
itemHeight={203}
itemGap={20}
// Layout
translation={[0, 440]} // positions the row group on screen
leftOffset={384} // inset between translation[0] and item 0
screenWidth={1920}
// Pool tuning (optional)
leftBuffer={2} // default: 2
rightBuffer={1} // default: 1
// Tween config (optional)
tweenDuration={0.333}
tweenEaseFunction="outCubic"
// Render props
renderItem={(dataIndex) => <Card dataIndex={dataIndex} />}
renderHighlight={() => <rectangle color="0xFF0000FF" width={40} height={40} />}
// Callbacks
gainFocus
onFocus={(index) => setFocusedIndex(index)}
onSelect={(index) => setLastSelected(index)}
/>Render props
The PooledRow component accepts these render props:
| Prop | Signature | Notes |
|---|---|---|
renderItem | (dataIndex: () => number) => JSX.Element | Called once per pooled slot at mount. dataIndex is a reactive accessor; read it inside JSX to stay reactive. Container owns x-translation. |
renderHighlight | () => JSX.Element | Optional. Return the highlight overlay; container owns x-translation via Tween. Omit to render no highlight. |
Callbacks
The PooledRow component accepts these callbacks:
| Prop | Signature | Notes |
|---|---|---|
onFocus | (index: number) => void | Fires on every left/right navigation with the new focused index. Called synchronously in handleKeyEvent alongside setHighlightedIndex. |
onSelect | (index: number) => void | Fires on OK press with the currently focused index. |
Key constants
This table lists the constants, their defaults, and the reason for each:
| Constant | Default | Rationale |
|---|---|---|
CARD_SPACING | itemWidth + itemGap | Derived from props |
translation | Required | Row group position on screen; standardized with the rest of the codebase |
leftOffset | Required | Inset between translation[0] and item 0; reduces VISIBLE_WIDTH and sets base x for cardsGroup/highlight |
VISIBLE_WIDTH | screenWidth - translation[0] - leftOffset | Width available for visible cards |
LEFT_BUFFER | 2 slots | Cards behind focus retain data through the full animation before recycling. |
RIGHT_BUFFER | 1 slot | One prefetch card ahead of focus |
tweenDuration | 0.333 s | 20 frames at 60 fps; matches the platform reference scroller (TWENTY_FRAMES) |
tweenEaseFunction | "outCubic" | Matches easeOutCubic used in the reference fixed scroller |
Updated about 3 hours ago