Platform differences
Why components are built on SwiftUI and Jetpack Compose through @expo/ui, and the three-file shape that hides the difference from callers.
The kit is built on the platforms' own controls rather than on drawings of
them: SwiftUI on iOS, Jetpack Compose on Android, both through @expo/ui. This
is how a component carries both without its callers ever knowing there are two.
The shape
Three colocated files per component, in the component's own folder:
segmented/scheme-segmented-control.tsx the API
segmented/scheme-segmented-control.ios.tsx the SwiftUI body
segmented/scheme-segmented-control.android.tsx the Compose bodyEverything imports the bare path. Metro resolves .ios / .android ahead of
the plain file, so a call site names the component and gets whichever body the
platform has. Nothing imports a variant by name, and no screen branches.
The .tsx holds the API and nothing platform-specific. The exported props
interface and the doc comment saying what the component is FOR and why it exists.
It also carries the default body — plain React
Native — which is what web and any future platform get, and what TypeScript
resolves imports against.
The .ios.tsx and .android.tsx hold a body and nothing else. They import
the props type from the entry and re-state none of its reasoning; a body's own
comment explains only what is true of that platform.
Why files rather than Platform.OS
A ternary inside one file keeps both bodies in one module, and an import is
evaluated wherever the module loads. @expo/ui/swift-ui exists only on iOS, so
an Android build that merely imports it has already lost — which is the exact
failure this codebase has taken twice, once from DynamicColorIOS at module
scope and once from expo-maps (see map/expo-maps, which needs a guarded
require for the same reason). Split by file, Android never loads the SwiftUI
module at all.
It is also React Native's own guidance: branch inline for a VALUE, split the file when the difference is a component.
Platform.OS is still right for a number or a flag — a padding that differs, a
capability check. It is wrong for a body.
What must match, and what must not
The layout matches. Same slot, same height class, same order of the same content. A screen written against this API lays out the same on both platforms, because layout is the app's decision and it is the same app.
The component does not, and should not. A segmented control on iOS is a pill track with a sliding thumb; Material's is a row of outlined buttons with a checkmark. A grouped list on iOS is inset rounded sections; Android's is a divided column. Those are the same CHOICE expressed the way each platform expresses it, and making Android imitate iOS gives its users a control that behaves like their platform's in no respect except position. Android should look Android. That is the point, not a compromise.
The same goes for the chrome the navigator owns — stack headers, the back affordance, the bottom bar. Let each platform draw its own; the app decides what is in them, not what they look like.
Tone
Colours still come from the theme, not from the platform's defaults, because a
club's ramp and the card family are the app's identity on both. CARD_HEX is
indexed by scheme and is what a pinned surface passes down — Material takes
explicit colours where SwiftUI takes an environment, so the same two columns
arrive by different routes and land in the same place.