Lists
import { VirtualList, SectionList } from '@ng-native/components';
<virtual-list><section-list> once they are in the component's importsVirtual list
<virtual-list> is this project's replacement for FlatList, SectionList and
VirtualizedList, none of which are reachable because they are React component trees several
layers deep. It is a native scroll view sized to the full extent of the list, with only the
visible rows and a surrounding buffer (overscan, four rows by default, to cover fling gaps)
actually rendered and absolutely positioned inside it - the windowing a long list needs to stay
fast.
The rendering loop is deliberately not something this component owns. <virtual-list> computes
which rows should exist, as window(), and the caller's own @for renders them - so tracking,
reuse and destruction all behave exactly as they would for any other @for:
<virtual-list #list [items]="rows()" [itemHeight]="56" (endReached)="loadMore()" class="flex-1">
<text listHeader>{{ rows().length }} results</text>
@for (row of list.window(); track row.slot) {
<view [virtualListRow]="row">
<text>{{ row.item.label }}</text>
</view>
}
<text listFooter>End of list</text>
</virtual-list>virtualListRow applies the row's style and reports its layout back to the list;
[style]="row.style" does the first half alone, which is all a list of fixed heights needs.
Rows that size themselves
Leave itemHeight unset and each row takes the height its content gives it, as a feed's posts
and a chat's messages do. The rendered rows are laid out in normal flow after a spacer standing in
for the rows above them, so native places every row correctly in the frame it first appears in,
and each row's layout tells the list its height for sizing what is not rendered. A row that has not
been measured yet counts as estimatedItemHeight (a number, or a function of the item and its
index; 50 by default). Measurements are kept by keyExtractor, so they follow their item across
inserts, removals and moves:
<virtual-list
#feed
[items]="posts()"
[estimatedItemHeight]="estimateOf"
[keyExtractor]="idOf"
[maintainVisibleContentPosition]="{ minIndexForVisible: 0, autoscrollToTopThreshold: 40 }"
>
@for (row of feed.window(); track row.slot) {
<view [virtualListRow]="row"><app-post [post]="row.item" /></view>
}
</virtual-list>When a row above the one on screen turns out taller or shorter than its estimate, the offset is
corrected by the difference, so what the user is reading does not move. The correction is a
scrollTo made after the commit, so one made mid-fling lands where the last scroll event said the
list was.
Keeping position when rows change
maintainVisibleContentPosition holds what is on screen still when rows are inserted or removed
before it: new posts arriving at the top of a feed, older messages loaded above the ones being
read. It takes React Native's shape. Rows before minIndexForVisible never anchor the position,
and within autoscrollToTopThreshold points of the start the list scrolls to what arrived instead,
which is what a feed at its top and a chat at its newest message want. Without it an insert above
the viewport moves the content, as UITableView and FlatList do.
Identity, recycling and row state
A row's key is keyExtractor's answer for its item, or the item itself without one. Each
item in the window holds a slot, and keeps it for as long as it stays in the window, inserts
and removals included. track row.slot recycles: a row scrolling in takes the slot, and so the
views, of one that has scrolled out, and only its bindings are updated. On a fast fling that is
most of the work a frame does.
A slot whose item leaves the window is not destroyed at once: up to six per row type are kept
rendered but hidden (display: none, marked row.parked, and always last in window()), so the
next row to arrive reuses their views however many passes later, as a UITableView reuse queue
does. When rows come in different kinds - a text post and a photo post, a message and a date
divider - itemType (a function of the item and its index) keeps a pool per kind, so a slot is
only recycled into a row of its own kind and its views fit without being rebuilt.
A recycled row's components keep their fields, as a reused UITableViewCell does, so state held
in a row component passes to whichever item arrives in its slot. Keep per-item state outside the
row (in the list's data, or a store keyed by id), or derive it from the item so it resets when the
item changes, which is prepareForReuse in Angular's terms:
readonly expanded = linkedSignal({ source: () => this.post().id, computation: () => false });Track by row.key instead when a row must never be recycled - a focused text input, a row
mid-animation. track row.index keeps a view with a position rather than an item, so after an
insert above it shows a different item; avoid it for rows with any state of their own.
Content marked listHeader or listFooter renders above and below the windowed rows in normal
flow, the way FlatList's header and footer components do. itemHeight takes either a fixed
number or a function of the item and its index, for rows that are not all the same height.
horizontal lays rows out along x instead of y; inverted flips the list to scroll from the
bottom, with every row flipped back the right way up, so a chat transcript needs no reversed
array. stickyIndices pins specific rows to the leading edge while the rest scroll underneath
them, and stickyHeader pins the listHeader content until the first sticky row pushes it off -
between them, FlatList's stickyHeaderIndices. A <refresh-control> (see the
scroll view page) projects like any other child.
The list is a native scroll view and takes the scroll view's own props as FlatList does:
pagingEnabled, snapToInterval, decelerationRate, showsHorizontalScrollIndicator,
scrollEventThrottle, keyboardDismissMode, contentInsetAdjustmentBehavior and the rest, with
the same defaults, so a horizontal list rubber-bands sideways and 'fast' is resolved to the rate
UIKit takes. horizontal, maintainVisibleContentPosition and keyboardShouldPersistTaps are the
list's own, described above.
A separator is an <ng-template virtualListSeparator>, drawn between each pair of rows and not
after the last, and told the rows either side as FlatList's ItemSeparatorComponent is. It is
drawn at the trailing edge of the leading row's slot, so itemHeight includes it, as
getItemLayout's length does in React Native. A row that sizes itself has its separator drawn
over its trailing edge once its height is known:
<virtual-list #list [items]="rows()" [itemHeight]="57">
@for (row of list.window(); track row.slot) {
<view [virtualListRow]="row"><text>{{ row.item.label }}</text></view>
}
<ng-template virtualListSeparator let-leading let-trailing="trailingItem">
<view [style]="{ height: 1, backgroundColor: '#ddd' }"></view>
</ng-template>
</virtual-list>endReached fires once per change in item count, when the scroll position comes within
endReachedThreshold viewport-heights of the end - the hook for loading another page.
viewableItemsChanged reports which rows are currently on screen by
itemVisiblePercentThreshold.
virtual-listcomponent@ng-native/componentsimport { VirtualList } from '@ng-native/components';and add VirtualList to the component's imports Windowed list: a native scroll view whose content is sized to the full list, with only the visible slice rendered. Commits as RCTScrollView .
Inputs
itemsrequiredreadonly T[]itemHeightVirtualItemHeight<T>A fixed height, or a function for variable ones, when every row's size is known up front. The function form builds a cumulative offset table and binary-searches it, which is what section lists need: a header row and an item row are different heights, so uniform maths cannot place them. Leave it unset for rows that size themselves.
estimatedItemHeightVirtualItemHeight<T> = 50What a row that sizes itself counts as until it has been measured. The closer it is, the more honest the scroll bar is and the less a jump to an unmeasured row has to correct.
keyExtractor(item: T, index: number) => unknown An item's identity, as FlatList's keyExtractor : what a measured height, a slot and a held position follow across an insert. Without it an item is its own key, which is enough for items that are replaced, not copied, when they change.
itemType(item: T, index: number) => unknown What kind of row an item is, when rows of different kinds share the list: a text post and a photo post, a message and a date divider. A slot is only ever recycled into a row of the same type, so its views are reused rather than torn down for another kind's, as UIKit's reuse identifiers and FlashList's getItemType do.
maintainVisibleContentPositionVirtualListVisiblePositionKeep what is on screen still when rows are inserted or removed before it.
overscannumber = 4Rows rendered beyond each edge of the viewport, to cover fling gaps.
endReachedThresholdnumber = 2 How close to the end, in viewport heights, (endReached) fires. RN's default is 2.
horizontalboolean = falseLay the rows out along x instead of y, and scroll that way.
invertedboolean = falseStart at the end and scroll backwards, with every row flipped back the right way up.
stickyIndicesreadonly number[] = []Rows that pin to the leading edge while the ones under them scroll past. Ascending; a row is pinned from the moment it would leave until the next sticky row pushes it off.
stickyHeaderboolean = false Pin the listHeader content to the leading edge until the first sticky row pushes it off.
keyboardShouldPersistTapsKeyboardShouldPersistTaps = 'never' What a tap in the list does while a text input has the keyboard up. See ScrollView .
itemVisiblePercentThresholdnumber = 0How much of a row must be on screen to count as viewable. RN's default is 0, meaning any.
Outputs
endReached{ distanceFromEnd: number } The scroll position is within endReachedThreshold of the end. Once per item count.
viewableItemsChanged{
viewable: readonly VirtualRow<T>[];
entered: readonly number[];
left: readonly number[];
}Which rows are on screen, and what changed since last time.
Signals
measuringunknownRows size themselves and are laid out in flow, rather than placed at a known offset.
windowVirtualRow<T>[]Methods
measureRow(row: VirtualRow<T>, layout: { width?: number; height?: number } | undefined): void A row laid itself out; what virtualListRow calls. Only a row that sizes itself is measured, and a height that has not changed changes nothing.
scrollToIndex(options: { index: number; animated?: boolean; viewOffset?: number }): void Scroll so that a row is at the start of the viewport, less viewOffset . The window moves there at once, and the row is held there while the rows around it are measured.
scrollToOffset(options: { offset: number; animated?: boolean }): voidScroll to an offset along the list's own axis: x when it is horizontal, y otherwise.
Section list
<section-list> is SectionList: sections of { title, data }, drawn from templates for the
section header, each item, the section footer and the separator between items of one section.
Like React Native's, it is the windowed list over flattened rows - a header, the items, then a
footer for each section - so it windows exactly as <virtual-list> does.
<section-list
#contacts="sectionList"
[sections]="sections()"
[itemHeight]="44"
[sectionHeaderHeight]="28"
(endReached)="loadMore()"
>
<ng-template sectionHeader let-section><text>{{ section.title }}</text></ng-template>
<ng-template sectionItem let-item let-index="index"><text>{{ item.name }}</text></ng-template>
<ng-template sectionSeparator><view [style]="line"></view></ng-template>
</section-list>Heights are fixed per row, as getItemLayout makes them: itemHeight, sectionHeaderHeight and
sectionFooterHeight are numbers or functions, and an item's height includes its separator.
stickySectionHeadersEnabled defaults to on for iOS and off for Android, as in React Native.
scrollToLocation({ sectionIndex, itemIndex }) counts the header as item 0, as React Native does,
and allows for a pinned header. listHeader and listFooter content and a <refresh-control>
pass through.
It has no SectionSeparatorComponent, no horizontal or inverted, and no viewability events,
and its host is a plain view with the list filling it.
section-listcomponent@ng-native/componentsimport { SectionList } from '@ng-native/components';and add SectionList to the component's imports RN's SectionList: sections of items, each with a header and footer, over <virtual-list> .
Inputs
sectionsrequiredreadonly S[]itemHeightrequiredHeight<[item: T, index: number, section: S]>Each item's height, separator included.
sectionHeaderHeightHeight<[section: S]> = 0sectionFooterHeightHeight<[section: S]> = 0stickySectionHeadersEnabledboolean | undefinedPin the current section's header. Defaults to true on iOS and false on Android, as RN.
overscannumber = 4endReachedThresholdnumber = 2Outputs
endReached{ distanceFromEnd: number } The end of the last section is within endReachedThreshold . See <virtual-list> .
Methods
scrollToLocation(options: {
sectionIndex: number;
itemIndex: number;
viewOffset?: number;
animated?: boolean;
}): void Scroll to an item, RN's way: itemIndex 0 is the section's header and 1 its first item, and with sticky headers the header's height is allowed for so the item is not hidden under it.