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
           = ~8

A 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:

  • fieldToInterp scope: A Vector2DFieldInterpolator's fieldToInterp resolves node IDs within the component that owns the Animation node. 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 (key and fieldToInterp) at construction time; as a result, the app does not need to create BrightScript nodes at runtime.
  • A unique animationId is generated per Tween instance (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 the targetId, animationId, and to fields.

BRS controller (Tween.brs)

The onToChanged handler fires on the BrightScript render thread every time the to field changes:

  1. Looks up targetNode through getParent().findNode(targetId) and reads translation[0], which is the current position on the GPU, even during an animation.
  2. Finds the sibling <animation> on first use through getParent().findNode(animationId).
  3. Finds the interpolator on first use through m.anim.getChild(0) (TSX created it).
  4. Calls m.anim.control = "stop", writes m.interp.keyValue = [[fromX, 0], [toX, 0]], then m.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:

FilePurpose
pooled-row.tsxReusable container: pooling logic, key handling, scroll/highlight translation
pooled-item-row.tsxExample that uses PooledRow: defines Card and wires render props and callbacks
tween.tsxSolidJS wrapper: renders <animation> (with interpolator child) + <tween> fragment
Tween.xmlBrightScript component declaration (targetId, animationId, to)
Tween.brsRender-thread controller: reads the position during an animation and drives the animation
Tween.xml.d.tsGenerated 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:

PropSignatureNotes
renderItem(dataIndex: () => number) => JSX.ElementCalled once per pooled slot at mount. dataIndex is a reactive accessor; read it inside JSX to stay reactive. Container owns x-translation.
renderHighlight() => JSX.ElementOptional. Return the highlight overlay; container owns x-translation via Tween. Omit to render no highlight.

Callbacks

The PooledRow component accepts these callbacks:

PropSignatureNotes
onFocus(index: number) => voidFires on every left/right navigation with the new focused index. Called synchronously in handleKeyEvent alongside setHighlightedIndex.
onSelect(index: number) => voidFires on OK press with the currently focused index.

Key constants

This table lists the constants, their defaults, and the reason for each:

ConstantDefaultRationale
CARD_SPACINGitemWidth + itemGapDerived from props
translationRequiredRow group position on screen; standardized with the rest of the codebase
leftOffsetRequiredInset between translation[0] and item 0; reduces VISIBLE_WIDTH and sets base x for cardsGroup/highlight
VISIBLE_WIDTHscreenWidth - translation[0] - leftOffsetWidth available for visible cards
LEFT_BUFFER2 slotsCards behind focus retain data through the full animation before recycling.
RIGHT_BUFFER1 slotOne prefetch card ahead of focus
tweenDuration0.333 s20 frames at 60 fps; matches the platform reference scroller (TWENTY_FRAMES)
tweenEaseFunction"outCubic"Matches easeOutCubic used in the reference fixed scroller

Did this page help you?