Expo ui migration
Use when moving a react-native-paper component to native expo-ui (SwiftUI on iOS / Jetpack Compose on Android), or authoring any new platform-split native control - i.e. creating a `foo.tsx` + `foo.android.tsx` + `foo-props.ts` triple that renders `@expo/ui` `Host`s. Covers the seedColor rule, the icon-interop trap, and the Compose/SwiftUI gotchas that are easy to get wrong.From its SKILL.md
npx -y skills add LiamMorrow/LiftLog --skill expo-ui-migrationAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
SKILL.md
4.7 KB, as published. Nobody here has run it
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.