ListView#

ListView renders large lists efficiently using virtualization. Only visible rows exist, and they are recycled as you scroll, so a list of ten thousand rows costs about what the dozen on screen cost. Maps to UI Toolkit's ListView.

Import#

import { ListView } from "onejs-react"

Basic Usage#

renderItem returns the JSX for one row. Components, hooks, context, className and event props all work inside it, exactly as anywhere else.

const items = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]

function FruitList() {
    return (
        <ListView
            itemsSource={items}
            fixedItemHeight={40}
            renderItem={(item: string) => <Label text={item} style={{ paddingLeft: 12 }} />}
            style={{ height: 300 }}
        />
    )
}

A row is usually more than one element, and that costs nothing extra:

function ContactList({ contacts, onCall }) {
    return (
        <ListView
            itemsSource={contacts}
            fixedItemHeight={60}
            renderItem={(contact: Contact, index: number) => (
                <View style={{ flexDirection: "row", alignItems: "center", flexGrow: 1, paddingLeft: 10 }}>
                    <View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: contact.color }} />
                    <View style={{ marginLeft: 10, flexGrow: 1 }}>
                        <Label text={contact.name} style={{ fontSize: 16 }} />
                        <Label text={contact.email} style={{ fontSize: 12, color: "#999" }} />
                    </View>
                    <Button text="Call" onClick={() => onCall(index)} />
                </View>
            )}
            style={{ height: 500 }}
        />
    )
}

renderItem needs onejs-react 0.1.56 or newer.

Props#

Required Props#

PropTypeDescription
itemsSourceunknown[]Array of items to display
renderItem(item, index) => ReactNodeJSX for one row. Use this, or the imperative pair below, not both

Row Props (imperative alternative)#

PropTypeDescription
makeItem() => VisualElementFactory that creates one row element
bindItem(element, index) => voidPopulates a recycled row element
unbindItem(element, index) => voidCalled when a row is recycled
destroyItem(element) => voidCalled when a row element is retired

Optional Props#

PropTypeDefaultDescription
fixedItemHeightnumberFixed height for all rows
virtualizationMethod"FixedHeight", "DynamicHeight""FixedHeight"Virtualization strategy

Selection Props#

PropTypeDefaultDescription
selectionType"None", "Single", "Multiple""Single"Selection mode
selectedIndexnumberCurrently selected index
selectedIndicesnumber[]Selected indices (multiple)
onSelectionChange(indices: number[]) => voidSelection change callback
onItemsChosen(items: unknown[]) => voidDouble-click/Enter callback

Reordering Props#

PropTypeDefaultDescription
reorderablebooleanfalseEnable drag reordering
reorderMode"Simple", "Animated""Simple"Reorder animation style

Appearance Props#

PropTypeDefaultDescription
showBorderbooleanfalseShow border around list
showAlternatingRowBackgrounds"None", "ContentOnly", "All""None"Zebra striping
showFoldoutHeaderbooleanfalseCollapsible header
headerTitlestringHeader text
showAddRemoveFooterbooleanfalseAdd/remove buttons

How Rows Work#

The ListView keeps a small pool of row elements and moves the visible window over your data, so scrolling rebinds a row rather than building one. renderItem does not take that away. Each pooled row becomes the target of a React portal, so React owns what is inside a row while the ListView keeps doing the virtualization.

Two consequences worth knowing:

  • A row is rebuilt when it is pointed at a different item. State inside a row belongs to the item, not to the recycled element, so an expanded row or a half-typed field cannot follow the pool onto a different item. Rows are cheap to rebuild; only the visible ones ever are.
  • A list is blank for one frame when it first appears. The opening binds arrive while React is still committing the list itself, so the first fill lands on the next frame. Every scroll after that is on time: measured in the editor against 10,000 rows, every recycled row's JSX was committed on the frame the ListView bound it and laid out in that same frame's layout pass.

Selection#

function SelectableList({ items, onSelect }) {
    const [selectedIndex, setSelectedIndex] = useState(-1)

    return (
        <ListView
            itemsSource={items}
            fixedItemHeight={50}
            selectionType="Single"
            selectedIndex={selectedIndex}
            onSelectionChange={(indices) => {
                setSelectedIndex(indices[0] ?? -1)
                onSelect(items[indices[0]])
            }}
            renderItem={(item: Item, index: number) => (
                <Label
                    text={item.name}
                    style={{ paddingLeft: 10, flexGrow: 1, color: index === selectedIndex ? "#5b9cf8" : "#e6e8ef" }}
                />
            )}
            style={{ height: 400 }}
        />
    )
}

selectionType="Multiple" works the same way, with selectedIndices and an onSelectionChange that hands you every selected index.

Imperative Rows#

makeItem and bindItem are the older API, and still the right one when a row is a single element whose text you want to set with no React in the path at all. You build the element and populate it yourself; the element is recycled, so bindItem must set every property it cares about.

<ListView
    itemsSource={items}
    fixedItemHeight={40}
    showAlternatingRowBackgrounds="ContentOnly"
    makeItem={() => new CS.UnityEngine.UIElements.Label()}
    bindItem={(element, index) => {
        element.text = items[index]
    }}
/>

Two things differ from the JSX path, because these are raw C# elements rather than React ones:

  • Styles want real structs: element.style.color = new CS.UnityEngine.Color(0.9, 0.9, 0.9, 1), since a CSS colour string is not converted here.
  • Reach children by position with element.ElementAt(i), which needs no setup. Q(name) also works after a one-time useExtensions(CS.UnityEngine.UIElements.UQueryExtensions) (OneJS 3.1.3+).

Pass renderItem or the imperative pair, never both. TypeScript rejects a ListView that has both.

Performance Tips#

  1. Use fixedItemHeight when possible: it is faster than dynamic height.
  2. Keep a row's tree shallow. Rows are rebuilt as they recycle, so a row with fifty elements costs fifty per scrolled row.
  3. Hoist what does not change. A style object or a handler built inside renderItem is rebuilt per row per bind; one defined outside is not.
  4. Reach for the imperative pair when a row is one element and the list is enormous, which skips React on the row entirely.