npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rngui/collection-view

v0.5.0

Published

A real UICollectionView on iOS and a real RecyclerView on Android, for React Native, with a Radix-style compound API.

Readme

@rngui/collection-view

A real iOS UICollectionView for React Native — UICollectionViewListCell, UICollectionViewCompositionalLayout and UICollectionViewDiffableDataSource, driven by a Radix-style compound API.

Not a reimplementation of a list. The cells are UIKit's, the recycling is UIKit's, the swipe actions, the pressed states, the section-header pinning and the large-title collapse are all UIKit's. What this package adds is a way to describe them from React.

npm install @rngui/collection-view

Requires the New Architecture. iOS and Android: a real UICollectionView on one, a real RecyclerView on the other. Each platform gets its own idiom rather than one drawn in the other's colours — see Platform differences for where they deliberately diverge, and what does nothing on Android for the short list of props that are accepted and ignored.

A first list

import { CollectionView } from '@rngui/collection-view'

export function Settings() {
  return (
    <CollectionView.Root>
      <CollectionView.Section
        header="General"
        footer="Applies to this device only."
      >
        <CollectionView.Row id="wifi" onPress={openWifi}>
          <CollectionView.Icon systemImage="wifi" />
          <CollectionView.Label>Wi-Fi</CollectionView.Label>
          <CollectionView.Value>Not Connected</CollectionView.Value>
          <CollectionView.Chevron />
        </CollectionView.Row>

        <CollectionView.Row id="airplane">
          <CollectionView.Label>Airplane Mode</CollectionView.Label>
          <CollectionView.Switch value={airplane} onValueChange={setAirplane} />
        </CollectionView.Row>
      </CollectionView.Section>
    </CollectionView.Root>
  )
}

The children are never mounted. Everything except Host renders null and exists only to be read: the tree is walked into flat descriptors, handed across as one JSON string, and UIKit builds and recycles its own cells from it. A thousand rows are a thousand descriptors and however many cells fit on screen.

That is also why a row's kind is never stated. It is inferred from which slots the row fills — a row with a Value is a value cell, one with a Description is a subtitle cell — so there is no second source of truth to disagree with the children.

Components

| Structure | | | --------- | ----------------------------------------------------------------------- | | Root | The list itself. Props below. | | Section | id · header · footer · indexTitle · layout · action | | Row | id · onPress · height · font | | Host | id · height · background · onPress — hosts a real React subtree |

| Row slots | | | ------------- | ------------------------------------------------------------------ | | Label | The title line | | Description | Second line. tinted draws it in the tint colour rather than grey | | Value | Trailing detail text |

| Accessories | | | ----------------------------------- | ------------------------------------------------------------------------------- | | Icon | systemImage · materialSymbol · monogram · color · background · size | | Badge | color — the red count bubble, text as children | | Chevron · Checkmark · Spinner | | | Checkbox · Radio | value · onValueChange · disabled |

| Controls | | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | | Switch | value · onValueChange · disabled | | TextField | value · onChangeText · onFocusChange · placeholder · keyboardType · autoCapitalize · returnKeyType · secure · unit · disabled | | TextArea | the above except unit, plus maxLines — it grows with its content, and a suffix has no line to sit on | | Menu | items · value · onSelect · disabled — a UIMenu | | DatePicker | value · onChange · mode · variant · minimumDate · maximumDate | | Slider | value · onValueChange · onSlidingComplete · minimumValue · maximumValue · step · minimumImage · maximumImage · disabled | | Button | role · onPress · disabled | | Card | value · caption · systemImage · color — a rich stacked cell that still recycles |

| Swipe actions | | | -------------- | ------------------------------------------------------------------------ | | SwipeActions | edge'trailing' (default) or 'leading' | | SwipeAction | id · title · systemImage · style · backgroundColor · onPress |

Both edges. A row takes one SwipeActions group per edge. edge defaults to 'trailing', revealed by swiping left; 'leading' is revealed by swiping right, the same handedness on both platforms:

<CollectionView.Row id={task.id}>
  <CollectionView.Label>{task.title}</CollectionView.Label>
  <CollectionView.SwipeActions>
    <CollectionView.SwipeAction
      id="delete"
      title="Delete"
      systemImage="trash"
      style="destructive"
      onPress={() => remove(task.id)}
    />
  </CollectionView.SwipeActions>
  <CollectionView.SwipeActions edge="leading">
    <CollectionView.SwipeAction
      id="complete"
      title="Complete"
      systemImage="checkmark.circle"
      backgroundColor="#34C759"
      onPress={() => complete(task.id)}
    />
  </CollectionView.SwipeActions>
</CollectionView.Row>

An id is unique per row, not per edge — a row's handlers are keyed by action id alone, so delete on both edges would keep only the last one registered. Nothing warns about it.

Pressing an action never removes the row. Native reports the tap and springs the row back, and the row leaves on the next commit as an animated diff, so the layout and the data source never disagree about whether it is gone.

On Android, a swipe competes with the system back gesture, and this is the failure people actually hit. Back is an inward drag from either screen edge, so a leading swipe collides with it on the left exactly as a trailing swipe does on the right. The list publishes systemGestureExclusionRects for the rows that carry actions, which is what makes swiping work at all on a gesture-navigation device. But the platform rations that budget to 200dp per edge and drops the rest, keeping the topmost rects — so on a list where every row is swipeable, rows past roughly the first four still lose near the screen edge. Invisible on an emulator with three-button navigation.

An Icon is grey by default, not tinted — in a list these are labels for the row, and a tinted glyph reads as an interactive control. Give it a background and it becomes the platform's own icon container instead — Settings' 29pt continuous-corner square on iOS, M3's 40dp circle on Android — rendered once per symbol-and-colour and cached, with the layout width reserved so untiled rows in the same section still line up. monogram puts one or two letters in that container rather than a glyph, which is the avatar an address book falls back to; that one is a circle on both, because an avatar always has been. A size reserves its width the same way, which is what keeps a large glyph from eating the row's leading margin.

A Badge takes its text as children rather than a number, because iOS puts version strings and a bare ! in the same bubble. It sits inside the disclosure chevron rather than replacing it, and it is not a row kind — a value row with a badge is still a value row.

A Section's action is the "Show All" beside a header title — { title?, systemImage?, disabled?, onPress? }. It becomes a real UIButton in the header's own trailing accessory slot, so it lines up with the disclosure chevrons below it and keeps its own pressed and disabled states. It needs a header to attach to: UIKit builds no header view for a section that asked for none, and __DEV__ warns rather than dropping the action silently.

Enums: keyboardType is default | numeric | decimal | email | phone | url | asciiCapable, autoCapitalize is none | sentences | words | characters, returnKeyType is default | done | go | next | search | send, mode is date | time | dateAndTime, variant is compact | inline | wheels, role is default | destructive | plain, and a swipe action's style is normal | destructive.

Root

Layout. listAppearance is insetGrouped (the Settings look, and the default), grouped, or plain — the last pins section headers to the top of the viewport as you scroll.

Appearance. appearance and darkAppearance take the same shape, and anything left unset keeps the platform's own value:

<CollectionView.Root
  appearance={{ tintColor: '#0FA3A3', font: { family: 'ui-rounded' } }}
  darkAppearance={{ tintColor: '#5AC8C8' }}
/>

Fields: background · backgroundGradient · rowBackground · separator · labelColor · secondaryLabelColor · headerTextColor · headerBackgroundStyle · footerTextColor · tintColor · sectionSpacing · firstSectionSpacing · font · headerFont · footerFont. A FontSpec is { family, size, weight, variations, scaled }.

firstSectionSpacing is the one field you are likely to want on a screen without a large title. A grouped list reserves about 35pt above its first section — UITableView's grouped inheritance, which NSCollectionLayoutSection.list(using:) still carries — and UIKit only drops it when the list sits under an expanded large title, which is the arrangement where the title supplies that separation itself. Set headerLargeTitle: false and the reserved gap stays, with nothing above it to explain it. firstSectionSpacing: 0 closes it; unset keeps the platform's value, because that gap is what a hand-written UICollectionViewController has.

If that first section has a header, the number is the gap above the header rather than between the header and its rows — a header sits outside the section's insets, so it is the only reading that means anything.

A section with no rows draws nothing and takes no space, on either platform. A <Section> whose rows are all conditional and currently absent is dropped rather than laid out, so it cannot push the list down by a gap with nothing in it. One consequence worth knowing: a section with a header but no rows does not render that header.

family takes the same names React Native's <Text> takes. Five of them are generic — system-ui, ui-sans-serif, ui-serif, ui-rounded, ui-monospace — and they are CSS's names, which React Native's iOS text layer already maps onto UIFontDescriptor.SystemDesign. Using the same spelling means one vocabulary on a screen that mixes rows and <Text>, instead of two names for SF Rounded depending on which component is asking. On Android they resolve to the matching system face, with ui-rounded degrading to the default one because Android has none.

Anything else is a font your app registered — with expo-font, the face is handed to Core Text and resolved here by name, so no per-row styling is involved. Leaving family unset is different again: it keeps whatever face the slot normally uses, which is how font: { size: 20 } on a section header stays a header rather than collapsing to a plain system label.

variations drives a variable font's axes, written flat: 'wght=550,opsz=20'. This is the only way to reach a weight that is not one of the nine weight names, because those nine are the only files a static family ships — an axis is continuous, so wght=350 is as real as wght=400. Axes the face does not expose are ignored, which makes one spec safe to share with a static family.

The two appearances resolve natively, not in JavaScript. Each colour crosses as a light/dark pair and becomes a UIColor(dynamicProvider:), so switching interface style restyles the whole list without a React render. darkAppearance deliberately does not inherit appearance field by field — that is what keeps "fall back to the platform's own colour" expressible.

headerBackgroundStyle decides how a pinned header paints itself, so it only means anything in the plain appearance. opaque hides the rows passing under it; blurred is a material that stops in a straight line, which is how iOS drew these before 26; soft fades that material out so there is no line at all, matching what UIScrollEdgeEffect's soft style does to a navigation bar.

transparent is what the system Contacts app actually uses, and it is worth saying because the other three look like the obvious answers. There is no background at all: the letter is a small grey glyph floating over the rows, and the only thing softening the top of the screen is the navigation bar's own scroll edge effect. Any material is one surface too many, and it reads as a second edge travelling down the screen.

That only works when each row leads with something the letter can pass over — Contacts has an avatar in every row. Over a bare label it is unreadable, and that is when the other three earn their keep.

inverted swaps the grouped look: a plain background with tinted rows rather than iOS's tinted background with plain cards. It is a preset, so anything in appearance still wins.

colorScheme (system | light | dark) pins the interface style rather than following the device. Needed whenever the app has its own theme switch, because UIListContentConfiguration draws labels, separators and accessories with system colours that follow the device.

Insets and keyboard. contentInset · contentInsetAdjustmentBehavior · automaticallyAdjustContentInsets · automaticallyAdjustsScrollIndicatorInsets · automaticallyAdjustKeyboardInsets · keyboardAware · keyboardAwareOffset · keyboardDismissMode, named as ScrollView names them because they are the same UIScrollView properties.

Two deliberate divergences. contentInsetAdjustmentBehavior defaults to automatic rather than ScrollView's never, because this is normally a screen's only scroll view under a native stack and never leaves the content starting behind the navigation bar. And keyboardAware targets the caret rather than the row, which is the difference between seeing the line you are typing and seeing the middle of a tall text area.

Scrolling. scrollEnabled · decelerationRate · showsVerticalScrollIndicator · sectionIndex · onScroll · onContentSizeChange · onVisibleRangeChange.

sectionIndex is the A–Z scrubber, built from each section's indexTitle. Pass true or { rowHeight, callout }. Sections without an indexTitle are skipped rather than given a blank stop, and when there is not enough vertical room the bar thins itself out with separators the way the system one does.

The scroll callbacks are what turn the native events on — a list that does not listen never pays for them. If you attach a reanimated handler instead (which subscribes by view tag, not by prop), set tracksScroll so native knows somebody is listening.

Pull to refresh. refreshControl takes a RefreshControl element, exactly as ScrollView takes one:

<CollectionView.Root
  refreshControl={
    <RefreshControl refreshing={busy} onRefresh={reload} tintColor="#0FA3A3" />
  }
/>

refreshing and onRefresh are also accepted directly, which is FlatList's shorthand for the same thing. The element wins if both are given.

The element is read, never rendered. Its props are unpacked onto the native view, which drives a real UIRefreshControl and a real SwipeRefreshLayout — so it can be React Native's own RefreshControl or anything carrying the same props, but it never mounts, never runs an effect, and never appears in the hierarchy as itself. That is not a shortcut: the React children of this component are exactly the Host subtrees, addressed positionally, so one extra mounted child would shift every hosted row. And on Android React Native's control is a wrapper around the scrollable, which could not have been a child of it in any case.

refreshing is controlled, as RefreshControl documents. Set it to true inside onRefresh or the spinner stops on the next render. A prop cannot express that on its own — a pull starts the spinner natively while JavaScript still says false, so a caller who does nothing changes no prop and there would be nothing to stop it. Root notices the disagreement after its own render and corrects it with a native command, which is what React Native's RefreshControl does too.

enabled is React Native's Android-only prop and works on both here; the divergence is additive. colors, progressBackgroundColor and size are Android's, and do nothing on iOS — a UIRefreshControl has one colour and one size. title and titleColor are the mirror image. There is no horizontal mode to guard against: this list is vertical by construction.

Hosting React children

Host is the escape hatch for content no row kind describes — a chart, a map, a custom control:

<CollectionView.Host id="chart" height={160}>
  <MyChart />
</CollectionView.Host>

Its children genuinely are rendered: they mount as Fabric children of the native view, and native reparents each into the contentView of the cell that owns it. The child is a real subview — UIKit clips it, hit-tests it and scrolls it, with no floating overlay and no per-frame repositioning.

Omit height and the subtree measures itself: Root reads it with onLayout and sends it back down. State it whenever you know it, though — measuring costs one extra render, which on first mount is a visible settle.

The cell draws no background unless you ask for one. Pass background="card" and the row takes the section's background, corner treatment and separators exactly as a described row in the same section does, theme changes included:

<CollectionView.Host id="chart" height={160} background="card">
  <MyChart />
</CollectionView.Host>

The default is "none" because a hosted subtree usually brings its own surface, and a card behind one that does reads as two cards with mismatched corners. A row that opts out draws no separators either — fencing a backgroundless row between two hairlines is not opting out of anything.

A hosted row cannot recycle. Every one is a distinct React subtree with distinct state, so there is no pool of interchangeable views to draw from. Prefer Card for anything that repeats, and for a long list of hosted rows, window them:

const [range, setRange] = useState({ firstIndex: 0, lastIndex: 0 })

<CollectionView.Root onVisibleRangeChange={setRange}>
  {rows.map((row, index) => (
    <CollectionView.Host key={row.id} id={row.id} height={72}>
      {index >= range.firstIndex - 4 && index <= range.lastIndex + 4 ? <Row {...row} /> : null}
    </CollectionView.Host>
  ))}
</CollectionView.Root>

The rows all still exist — same ids, same heights, same place in the scroll — so nothing jumps. Only the content comes and goes. The indices are into the flattened row list, every section's rows concatenated in tree order.

Bottom sheets

import { BottomSheetCollectionView } from '@rngui/collection-view/bottom-sheet'

A separate entry point, because importing it pulls in @gorhom/bottom-sheet, react-native-reanimated and react-native-gesture-handler — all optional peers, so the cost falls on the import rather than on everyone who renders a list.

It takes Root's props, minus the few the sheet owns. Two things are worth knowing: contentInsetAdjustmentBehavior is pinned to never (the sheet locks the list by scrolling it to 0, which only works if 0 is the top), so pass contentInset={{ bottom }} yourself if the sheet reaches the screen edge. And onScroll arrives wrapped in { nativeEvent } here, because gorhom re-wraps the event before handing it on.

refreshControl is dropped here, and it is the one prop that is real on Root and deliberately not forwarded. Inside a sheet the pull and the sheet's own collapse are the same gesture: at contentOffset 0 the list cannot scroll up, so gesture-handler never activates and the sheet keeps the drag — and below the tallest detent gorhom locks the list outright, which switches the refresh layout off on Android and stops the rubber-band on iOS. A control that renders and never fires is worse than one that was never accepted. @gorhom/bottom-sheet has the same conflict with a plain BottomSheetScrollView.

Why React Native codegen, not Nitro

Nitro is a better authoring experience for almost everything, and it cannot do the one thing this component is built around. Nitro assigns its view as a Fabric component's contentView, so React's children arrive as siblings, and RCTViewComponentView's default mount/unmount assert that a child is exactly where React put it. A hosted view can then only be floated over its cell.

A hand-written Fabric component view overrides mountChildComponentView: / unmountChildComponentView: and owns child placement outright — the same trick react-native-screens uses to move children into view controllers. That is what makes <CollectionView.Host> a genuine subview of cell.contentView.

Android

A RecyclerView with Material 3 grouped cards, not a FlatList in a trench coat. The manager implements the generated codegen interface rather than declaring its own @ReactProp setters, so the Kotlin compiler is what keeps it in step with the TypeScript spec — a prop cannot be added on one side and forgotten on the other.

The descriptor model is generated too: scripts/gen-kotlin-types.mjs emits data classes and hand-written org.json decoders from the same src/tree.ts the Swift model comes from, and both platforms decode the same fixture files in their tests. kotlinx.serialization is not used because it is a compiler plugin, and React Native app templates — Expo's included — do not put one on the root project's classpath.

prop → native in one place:

| Prop | Android | | -------------------------------- | ------------------------------------------------------------------------------------------- | | listAppearance | grouped cards with first/last/middle corner shapes; plain is edge-to-edge | | appearance / darkAppearance | resolved against the configuration; a theme flip rebinds the visible rows with no JS commit | | colorScheme | overrides the device's night mode for this list | | sectionIndex | a fast-scroller thumb with a letter bubble — not an A–Z rail | | contentInset* | padding with clipToPadding = false, so rows scroll through the inset | | contentInsetAdjustmentBehavior | system-bar insets; never applies none | | decelerationRate | 0 suppresses the fling exactly; other values approximate through fling velocity | | scrollTo | scrollToPosition(0) for the (0, 0) case, which is exact |

Material 3

The Android side follows the M3 list spec rather than translating the iOS look. Concretely:

  • Every default colour is an M3 colour rolesurface, surfaceContainer, onSurface, onSurfaceVariant, outlineVariant, primary, secondaryContainer — resolved from the app's Material theme, so an unthemed list inherits dynamic colour on Android 12+. Anything set in appearance still wins.
  • Selection controls are the real components: MaterialSwitch, MaterialCheckBox, MaterialRadioButton. The row owns the tap; the control displays state.
  • Items are 56dp minimum with 16dp horizontal padding.
  • A selected item — a checked checkbox or radio — takes secondaryContainer and a larger corner radius. The shape changing is the point; colour alone is not how M3 signals selection.

androidListStyle picks between the two arrangements the spec defines:

| | standard | segmented | | ---------- | ---------------------------- | ------------------------ | | Items | flush | own container, 4dp gap | | Separators | dividers | none — the gaps separate | | Corners | square, or grouped-card ends | uniformly rounded |

Unset follows listAppearance: segmented for insetGrouped and grouped, standard for plain. It is ignored on iOS, where the shape comes from listAppearance alone.

The library brings com.google.android.material and themes its own context with Theme.Material3.DayNight. That is not optional — Material widgets read theme attributes at construction and crash without them — and supplying it here rather than requiring one of the consuming app is what keeps this a library you install rather than one you configure.

Platform differences

Decisions, not gaps. Each one is the platform's own idiom rather than the other's.

| Concept | iOS | Android | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | insetGrouped | UICollectionLayoutListConfiguration | M3 containers — segmented by default, see androidListStyle | | plain + pinned headers | free from compositional layout | a hand-written ItemDecoration, with push-off | | systemImage | SF Symbols | Material Symbols, via a curated map — partial by nature; see materialSymbol | | Section index | an A–Z rail | a fast-scroller thumb with a letter bubble. Android has never had a rail | | plain pinned headers | iOS 26+ only — see below | always pinned, via a hand-written ItemDecoration | | Swipe actions | UISwipeActionsConfiguration | ItemTouchHelper revealing a tray — off-idiom; Material says swipe means dismiss, and an Android-first design should reach for an overflow menu | | SwipeActions edge="leading" | a full-height slab, as on the trailing edge | the same mirrored tray — but the right-swipe competes with the system back gesture, and only the first ~200dp of swipeable rows win it | | TextField unit | a trailing UILabel in the field's stack. Both static labels are hidden from VoiceOver and the field speaks for them | a trailing TextView — and the row gains the leading label it never drew. The label stays a node, points at the field with labelFor and carries the unit in its description — TalkBack drops an editable node's hint once it holds text, so a hint would go quiet exactly when there is a value to qualify | | datePickerStyle: 'compact' font | family and weight are pushed onto the pill's own labels; size is ignored, because UIKit does not grow the pill's background to fit one. UIDatePicker has no font API, so a future iOS that draws the pill differently falls back to the system font | the date is an ordinary TextView and takes the row's font whole, size included | | datePickerStyle: 'wheels' | a drum picker | no M3 equivalent exists; falls back to the platform dialog and warns once | | datePickerMode: 'dateAndTime' | one combined wheel | two dialogs, chained — Material has no combined picker | | Slider | UISlider — thin track, capsule knob | com.google.android.material.slider.Slider — M3 Expressive's thick track, gap and handle bar | | Slider step | enforced, but not drawn | enforced and drawn, as tick marks. UISlider has never had them | | Slider min/max images | minimumValueImage slots | icon views laid out either side; Material's slider has no such property | | Icon background | Settings' 29pt rounded square | M3's 40dp circle. The tile is Apple's — Android's leading element is a bare icon or a round avatar | | Icon monogram | initials on a circle | the same, and the one case where the container shape does not differ: an avatar is round on both | | Overscroll | rubber-band bounce | stretch or glow | | refreshControl | UIRefreshControl — one colour, and a caption under it | SwipeRefreshLayout — a circle that cycles colors, and no room for a caption | | refreshControl unstyled | the system tint | the list's own appearance.tintColor, rather than the stock blue | | contentSize.height | exact | an estimate, from computeVerticalScrollRange() |

keyboardShouldPersistTaps is ScrollView's prop by the same name — never (default), always, or handled, where "handled" means the row under the finger has an onPress. One deliberate difference from ScrollView: there, never also swallows the tap that dismissed the keyboard; here the row's onPress still fires, because the tap target is the row and eating the first tap after typing reads as a dropped tap. keyboardDismissMode defaults to onDrag on both platforms, so scrolling dismisses.

Sticky headers in a plain list need iOS 26. Below that they scroll with their rows, and the reason is a trade rather than an omission: pinToVisibleBounds on a .list(using:) section does not pin on iOS 18 and it silently disables that section's swipe actions. Both symptoms, one cause — swipe works on 18 in a grouped list, which never sets the pin, and fails in a plain one, which did. Setting it was costing a working feature to buy a broken one, so it is now gated on 26. Android is unaffected: its pinned headers are drawn by an ItemDecoration of our own and owe nothing to UIKit.

contentOffset.y is exact on both. On Android it is accumulated from onScrolled's dy rather than read from computeVerticalScrollOffset(), which is an average-item-height estimate that does not return exactly zero at the top — and @gorhom/bottom-sheet compares it against 0.

materialSymbol

systemImage names an SF Symbol, and the two icon sets overlap in meaning but never in naming, so Android carries a curated map. An unmapped name renders nothing and warns once. Set materialSymbol on an <Icon> to name the Android glyph directly; it wins over systemImage there and is ignored on iOS.

The bundled face is subset — the full Material Symbols variable font is 14 MB — so a materialSymbol outside the subset also renders nothing and warns. Add it to scripts/symbol-map.mjs and re-run npm run gen:material-symbols.

Documented no-ops

Accepted so shared screens keep type-checking, and deliberately doing nothing:

| Prop | Why | | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | sectionIndexRowHeight | sets the per-letter height of an A–Z rail. There is no rail on Android | | automaticallyAdjustsScrollIndicatorInsets | the indicator is drawn inside the list's padding already | | keyboardDismissMode: 'interactive' | maps to onDrag. Android has no interactive dismissal | | datePickerStyle: 'inline' \| 'wheels' | both fall back to the platform dialog, and warn once | | RefreshControl title / titleColor | Material's indicator is a bare circle with no room for a caption | | RefreshControl tintColor | the Android equivalent is colors, a list. Left unset, the indicator resolves through appearance.tintColor instead, which is the value a themed screen already set |

Insets and navigation headers

Content insets are computed from how much system chrome actually overlaps the list, not from the window's own insets. Under a transparent header the list starts at the top of the window and takes the full status-bar inset; under an ordinary opaque toolbar it starts below one already, and taking it again would leave a bar-shaped gap.

What Android has no equivalent for is the header's own height. On iOS contentInsetAdjustmentBehavior: automatic folds in the navigation bar because UIKit knows how tall it is; a toolbar's height is not a window inset, so headerTransparent on Android is a promise the list cannot keep on its own. Use an opaque header, or pass the height yourself through contentInset={{ top }}.

Host rows

<CollectionView.Host> children are mounted into an invisible parking bay and reparented into the cell that owns them. A holder releases its child only if it still owns it — during a reload the incoming holder binds before the outgoing one is recycled, so an unguarded release blanks a row that is on screen and correct.

The bay is a view of ours that is invisible, never React's view made invisible, and the distinction is load-bearing on iOS. React pools component views app-wide and never restores hidden on the way out — prepareForRecycle does not touch it, and the one assignment in updateLayoutMetrics: is gated on old metrics that a recycled view never has. A child handed back hidden is therefore hidden for the rest of the process, in whatever unrelated screen draws it next. Moving a child between the bay and a cell is the only thing that changes its visibility.

License

AGPL-3.0-only.