Expo UI
@expo/ui is the closest thing in the ecosystem to what this project is for: every one of its
views is a real platform control - a real Picker, a real BottomSheet, a real Gauge - rather
than something drawn to look like one. It reaches them through requireNativeView('ExpoUI', ...),
the same derivation registerExpoView already mirrors, so the whole surface is available here for
the price of a table of names.
import {
UiHost,
UiMenu,
UiButton,
UiDivider,
UiSlot,
UiList,
UiSwipeActions,
UiVStack,
UiHStack,
UiSpacer,
UiSlider,
UiStepper,
UiToggle,
UiTextField,
UiColorPicker,
UiGauge,
UiProgress,
UiForm,
UiSection,
UiLabeledContent,
UiImage,
UiText,
UiDatePicker,
} from '@ng-native/expo';
<ui-host><ui-menu><ui-button><ui-divider><ui-slot><ui-list><ui-swipe-actions><ui-vstack><ui-hstack><ui-spacer><ui-slider><ui-stepper><ui-toggle><ui-text-field><ui-color-picker><ui-gauge><ui-progress><ui-form><ui-section><ui-labeled-content><ui-image><ui-text><ui-date-picker> once they are in the component's importsInstall
npx expo install @expo/uiimport { registerExpoUiViews } from '@ng-native/expo';
import { UiHost, UiMenu, UiButton } from '@ng-native/expo/expo-ui-components';The smallest thing that works
import { Platform } from 'react-native';
import { registerExpoUiViews } from '@ng-native/expo';
registerExpoUiViews(Platform.OS); // once, before the app mounts<ui-host style="height: 44">
<ui-slider [value]="volume()" (valueChanged)="volume.set($event.nativeEvent.value)" />
</ui-host>Registering the views
registerExpoUiViews(platform) registers every @expo/ui view the current platform has, all
at once - unlike the other register* functions in this package, because these are all one
module: an app with @expo/ui installed has all of them, and an app without it has none. Call it
once at startup with Platform.OS.
Element names are platform-neutral where both platforms have the control, so a template writes
<ui-vstack> once and gets Compose's Column on Android. Where only one platform has a control - a
Gauge is SwiftUI's, a SearchBar is Compose's - the element exists only there; registering for
the wrong platform is an element that commits as nothing, which registerExpoUiViews avoids by
reading the platform you pass it.
<ui-host> is required
SwiftUI and Compose lay out their own subtrees. <ui-host> is the bridge from Yoga's layout to
theirs; every other ui-* element must sit inside one, or the control has no size and does not
appear - which looks exactly like a module that failed to install. UiHost's matchContents input
sizes the host to the SwiftUI content instead of the other way round; ignoreSafeArea and
useViewportSizeMeasurement are the other two host-level controls.
Names, and the typed components
Most @expo/ui views are, deliberately, names rather than components: there are ninety-odd of
them, each with its own props and its own modifier system, and a component per view, prop for
prop, would be a second place for every one of them to be wrong. The element plus the module's own
documentation covers the common case:
<ui-gauge [value]="0.4" [modifiers]="[{ $type: 'frame', width: 80, height: 80 }]" />The exception is strict templates, where an unknown element - and every prop on it - is a type
error. expo-ui-components.ts has thin typed components for the views an app reaches for most:
UiHost- the bridge above.UiMenu- a SwiftUIMenu. Its trigger is thelabelinput or a<ui-slot name="label">; its items are children.UiButton- a SwiftUIButton, as a menu item or on its own.roleis'default','cancel'or'destructive'.UiDivider- a separator between groups of menu items.UiSlot- content for a named slot of its parent view, such as a menu'slabel.extraPropsis what the slot tells its parent about itself, such as a swipe group'sedge.UiList- a SwiftUIList.UiSwipeActions- the system's swipe actions on a list row, iOS only. The first child is the row, and each edge's actions areui-buttons in a<ui-slot name="actions" [extraProps]="{ edge: 'trailing', allowsFullSwipe: true }">. A row in a scroll view that also scrolls sideways loses its swipes to that scroll view.UiVStackandUiHStack- SwiftUI's stacks, withalignmentandspacing, andUiSpacerfor the room left over in one.UiSlider,UiStepperandUiToggle- withvalueChanged,valueChangeandisOnChangefor what the user did.UiTextField- itstextis anativeState(''), which the field writes to on the UI thread;textChangereports each change.UiColorPicker,UiGaugeandUiProgress.UiForm,UiSectionandUiLabeledContent- settings-style grouped rows.UiImage- an SF Symbol bysystemName, or a picture byuiImageURL.UiText- a SwiftUIText.UiDatePicker- a SwiftUIDatePicker.selectionis an ISO string;dateChangereports the new one, also as an ISO string.
Each input goes straight through to the node as a prop, modifiers included -
UiModifier is one SwiftUI modifier, shaped exactly as @expo/ui's own modifier functions build
them ({ $type: 'frame', ... }). Events are declared as outputs purely for their type: $event in
a template binding is typed as the native payload, but the output itself is never emitted. Angular
binds a template's (dateChange) to the element's own native event directly, so that is the only
place it arrives - a programmatic subscription to the output from code never receives anything.
Subscribe in the template, not to the output in code.
Still call registerExpoUiViews alongside importing these. The component supplies the types; the
registration is what makes the element commit as the SwiftUI or Compose view. Add a component here
when a template wants one of the other views typed, from @expo/ui's own props for it - the rest
stay names.
Text field state: nativeState
TextFieldView and SecureFieldView declare their text prop as an ObservableState rather
than a plain string, and what actually travels over the prop is the shared object's id, a number.
Binding the string itself is the obvious thing to write, and it fails silently as
FieldInvalidTypeException, logged rather than thrown - the field renders and simply ignores
everything the app sets, which looks like it works because both fields manage their own text when
the prop is absent, and only setting the value from Angular does nothing.
nativeState(initial) builds one of these shared objects and returns a NativeState<T>:
protected readonly name = nativeState('');<ui-text-field [text]="name?.id" (textChange)="typed.set($event.nativeEvent.value)" />Bind name?.id, not name. The state lives on the native side and both sides hold a reference,
so writing to it moves the caret in a field that is already on screen, where a signal and a
re-render would not - that is the whole reason the prop is shaped this way. get() reads the
current value (a write is scheduled onto the UI thread, so it is not readable back until that has
run - @expo/ui's own accessors behave the same way), set() writes it, and release() detaches
from the native object; worth calling from DestroyRef for a field inside a list that comes and
goes, since a state that lives as long as the app does not need one.
nativeState() returns null off a device, where there is no native module to hold the state -
bind name?.id and the prop is simply absent, which is the field's own unmanaged behaviour rather
than a crash.
Without the module
An element registered for @expo/ui when it is not installed commits as nothing
(UnimplementedNativeView). nativeState() returns null.
Reference
ui-hostcomponent@ng-native/expoimport { UiHost } from '@ng-native/expo';and add UiHost to the component's imports The bridge from Yoga's layout to SwiftUI's; every other ui-* view goes inside one. matchContents sizes the host to the SwiftUI content rather than the other way round, on both axes or on the ones named. Native reads a flag per axis, which is what this splits it into, as @expo/ui 's own Host does: without them the host has no size of its own, and the control drawn in it spills outside it, where it can be seen but not touched or read by VoiceOver.
Inputs
matchContentsboolean | { readonly vertical?: boolean; readonly horizontal?: boolean }ignoreSafeArea'all' | 'keyboard' | 'container'useViewportSizeMeasurementbooleanui-menucomponent@ng-native/expoimport { UiMenu } from '@ng-native/expo';and add UiMenu to the component's imports A SwiftUI Menu . Its trigger is the label or a <ui-slot name="label"> ; its items are children.
Inputs
labelstringsystemImagestringAn SF Symbol name.
accessibilityLabelstringmodifiersreadonly UiModifier[]ui-buttoncomponent@ng-native/expoimport { UiButton } from '@ng-native/expo';and add UiButton to the component's imports A SwiftUI Button , as a menu item or on its own.
Inputs
labelstringsystemImagestringAn SF Symbol name.
role'default' | 'cancel' | 'destructive'modifiersreadonly UiModifier[]Outputs
buttonPressNativeSyntheticEvent<Record<string, never>>ui-dividercomponent@ng-native/expoimport { UiDivider } from '@ng-native/expo';and add UiDivider to the component's imports A SwiftUI Divider : a separator between groups of menu items.
Inputs
modifiersreadonly UiModifier[]ui-slotcomponent@ng-native/expoimport { UiSlot } from '@ng-native/expo';and add UiSlot to the component's imports Content for a named slot of its parent view, such as a menu's label . extraProps is what the slot tells its parent about itself: a ui-swipe-actions group's edge and allowsFullSwipe .
Inputs
namerequiredstringextraPropsReadonly<Record<string, unknown>>ui-listcomponent@ng-native/expoimport { UiList } from '@ng-native/expo';and add UiList to the component's imports A SwiftUI List : rows as SwiftUI lays them out, which is what lets a row take the system's own swipe actions through ui-swipe-actions .
Inputs
modifiersreadonly UiModifier[]ui-swipe-actionscomponent@ng-native/expoimport { UiSwipeActions } from '@ng-native/expo';and add UiSwipeActions to the component's imports The system's swipe actions on a list row, iOS only. The first child is the row; the actions go in <ui-slot name="actions" [extraProps]="{ edge: 'trailing', allowsFullSwipe: true }"> , one slot per edge, as ui-button s.
Inputs
modifiersreadonly UiModifier[]ui-vstackcomponent@ng-native/expoimport { UiVStack } from '@ng-native/expo';and add UiVStack to the component's imports A SwiftUI VStack .
Inputs
alignment'leading' | 'center' | 'trailing'spacingnumbermodifiersreadonly UiModifier[]ui-hstackcomponent@ng-native/expoimport { UiHStack } from '@ng-native/expo';and add UiHStack to the component's imports A SwiftUI HStack .
Inputs
alignment'top' | 'center' | 'bottom' | 'firstTextBaseline'spacingnumbermodifiersreadonly UiModifier[]ui-spacercomponent@ng-native/expoimport { UiSpacer } from '@ng-native/expo';and add UiSpacer to the component's imports A SwiftUI Spacer : the room left over in its stack.
Inputs
modifiersreadonly UiModifier[]ui-slidercomponent@ng-native/expoimport { UiSlider } from '@ng-native/expo';and add UiSlider to the component's imports A SwiftUI Slider , iOS only. valueChanged reports each new value as the thumb moves; note the d, which the native event has and @expo/ui 's React prop does not.
Inputs
valuenumberminnumbermaxnumberstepsnumberHow many steps the range is divided into; none moves the thumb continuously.
modifiersreadonly UiModifier[]Outputs
valueChangedUiSliderChangeEventui-steppercomponent@ng-native/expoimport { UiStepper } from '@ng-native/expo';and add UiStepper to the component's imports A SwiftUI Stepper , between min and max by step .
Inputs
valuenumberminnumbermaxnumberstepnumberlabelstringmodifiersreadonly UiModifier[]Outputs
valueChangeUiStepperChangeEventui-togglecomponent@ng-native/expoimport { UiToggle } from '@ng-native/expo';and add UiToggle to the component's imports A SwiftUI Toggle . isOnChange reports each flip.
Inputs
isOnbooleanlabelstringmodifiersreadonly UiModifier[]Outputs
isOnChangeUiToggleChangeEventui-text-fieldcomponent@ng-native/expoimport { UiTextField } from '@ng-native/expo';and add UiTextField to the component's imports A SwiftUI TextField . text is where it keeps what is typed, a nativeState('') : the field writes to it on the UI thread, and textChange reports each change.
Inputs
textNativeState<string> | nullplaceholderstringmodifiersreadonly UiModifier[]Outputs
textChangeUiTextFieldChangeEventui-color-pickercomponent@ng-native/expoimport { UiColorPicker } from '@ng-native/expo';and add UiColorPicker to the component's imports A SwiftUI ColorPicker . selection is a colour as hex.
Inputs
selectionstringlabelstringsupportsOpacitybooleanmodifiersreadonly UiModifier[]Outputs
selectionChangeUiColorChangeEventui-gaugecomponent@ng-native/expoimport { UiGauge } from '@ng-native/expo';and add UiGauge to the component's imports A SwiftUI Gauge : value between min and max , drawn as type says.
Inputs
valuenumberminnumbermaxnumbertypestringcurrentValueLabelstringminimumValueLabelstringmaximumValueLabelstringmodifiersreadonly UiModifier[]ui-progresscomponent@ng-native/expoimport { UiProgress } from '@ng-native/expo';and add UiProgress to the component's imports A SwiftUI ProgressView : a bar filled to value , from 0 to 1, or a spinner without one.
Inputs
valuenumbermodifiersreadonly UiModifier[]ui-formcomponent@ng-native/expoimport { UiForm } from '@ng-native/expo';and add UiForm to the component's imports A SwiftUI Form : settings-style grouped rows.
Inputs
modifiersreadonly UiModifier[]ui-sectioncomponent@ng-native/expoimport { UiSection } from '@ng-native/expo';and add UiSection to the component's imports A SwiftUI Section of a form or a list, under title .
Inputs
titlestringmodifiersreadonly UiModifier[]ui-labeled-contentcomponent@ng-native/expoimport { UiLabeledContent } from '@ng-native/expo';and add UiLabeledContent to the component's imports A SwiftUI LabeledContent : a label, and its content beside it.
Inputs
labelstringmodifiersreadonly UiModifier[]ui-imagecomponent@ng-native/expoimport { UiImage } from '@ng-native/expo';and add UiImage to the component's imports A SwiftUI Image : an SF Symbol by systemName , or a picture by uiImage URL.
Inputs
systemNamestringuiImagestringsizenumbercolorstringmodifiersreadonly UiModifier[]ui-textcomponent@ng-native/expoimport { UiText } from '@ng-native/expo';and add UiText to the component's imports A SwiftUI Text .
Inputs
textstringmodifiersreadonly UiModifier[]ui-date-pickercomponent@ng-native/expoimport { UiDatePicker } from '@ng-native/expo';and add UiDatePicker to the component's imports A date picker: SwiftUI's DatePicker on iOS, Compose's on Android.
Inputs
titlestringselectionstringdisplayedComponentsreadonly ('date' | 'hourAndMinute')[]range{ readonly start?: string; readonly end?: string }accessibilityLabelstringmodifiersreadonly UiModifier[]disabledboolean = falseTwo-way
valueDate | null = null The picked date: what [formField] binds.
Outputs
touchvoidThe user picked, which is when a form shows a field's errors: a picker has no blur.
dateChangeUiDateChangeEvent