Migrating a component to expo-ui (native)
LiftLog is incrementally moving off react-native-paper onto native controls via @expo/ui - SwiftUI
on iOS, Jetpack Compose (M3 expressive) on Android. Each migrated control is a platform-split triple.
The triple
For a control foo, in its own folder under the relevant components/presentation/.../foo/:
foo-props.ts- the shared props interface + any shared enums. Both platform files import from here. Mark platform-only props (iconis Android XML drawable;systemImageis an iOS SF Symbol).foo.tsx- the iOS implementation, using@expo/ui/swift-ui(Host,Button, …) and@expo/ui/swift-ui/modifiers.foo.android.tsx- the Android implementation, using@expo/ui/jetpack-compose.
Metro picks .android.tsx on Android and .tsx elsewhere. Reference exemplar:
components/presentation/foundation/native-button/ (props + both platforms, clean and current).
Theming: seedColor is (almost) the only input
- On every
Host, passseedColor={colors.seedColor}fromuseAppTheme()- nevercolors.primary.seedColoris the real source colour that seeds the whole M3/SwiftUI palette;colors.primaryis already derived from it, so re-seeding from it produces the wrong ramp.seedColorisundefinedfor the default scheme, which correctly lets Compose fall back to Material You / the wallpaper palette. - Let native components own their slot colours. Don't pass a
colors={{ containerColor, contentColor, checked… }}object to a native control, and don'ttint/foregroundStyleanIconthat sits inside a native button/toggle - the component already colours its container/content/checked slots from the seeded palette, and anIconchild inherits the content colour. Hand-colouring only fights the palette. - Do pick the right variant (e.g.
HorizontalFloatingToolbar variant="standard"vs"vibrant"), since that changes which ramp a slot sources from. tint/foregroundStyleis warranted only for a bareIcon/label not inside a colour-providing native control (e.g. anIconin aRow), or on iOS where aborderless/plainbutton drops its label tint - reach for it last.
Icons inside Compose must be native
Import an XML vector drawable per icon from @expo/material-symbols and render it with expo-ui's own
Icon - not the RN Paper / @material-symbols-react-native wrapper:
import { Icon } from "@expo/ui/jetpack-compose";
import AddIcon from "@expo/material-symbols/add.xml";
<Icon source={AddIcon} size={24} />;
An RN Icon hosted inside a Compose button does not survive interop and fails differently per
component (mangled FAB with the icon stranded; or the icon silently renders to nothing while the label
shows) - it looks like a styling bug but it's interop; manual sizing won't fix it. No Metro config
needed: xml is in Expo's default assetExts and @expo/ui/jetpack-compose registers its own asset
transformer (don't import that subpath yourself - it isn't in the package exports).
Compose gotchas
- A
Hostneeds a definite size if any child uses theweightmodifier -matchContentsmeasures against content, soweight(1)has no bounded width to divide and silently collapses to zero. Shape.Pill({})must be called, not used as JSX (<Shape.Pill />loses the brand and fails typecheck).Row'shorizontalArrangementtakes{ spacedBy: n }, not a string.- Compose
Textis invisible to Maestro's view hierarchy - a Compose button can't be tapped by label; tap bypoint. Expo-ui switches on Android carry notestID.
iOS (SwiftUI) notes
buttonStyle('borderedProminent' | 'bordered' | 'borderless')maps to filled/tonal/text;'plain'drops the tint along with the chrome, leaving a label that doesn't read as a button.buttonStyle('glass' | 'glassProminent')andglassEffect(...)are iOS 26+.
Native rebuild caveat
New native deps / config-plugin changes need a native rebuild - don't run expo run:* yourself;
describe the change and ask Liam to rebuild, then verify. JS-only edits to these files hot-reload.
Verify
From app/: npm run typecheck and npm run lint (the react-compiler rule runs via eslint). Then look
at both platforms on device.