StatsHub Docs
Mobile

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 body

Everything 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.

On this page