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
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';
Template
<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 imports

Install

npx expo install @expo/ui
import { 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 SwiftUI Menu. Its trigger is the label input or a <ui-slot name="label">; its items are children.
  • UiButton - a SwiftUI Button, as a menu item or on its own. role is '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's label. extraProps is what the slot tells its parent about itself, such as a swipe group's edge.
  • UiList - a SwiftUI List.
  • UiSwipeActions - the system's swipe actions on a list row, iOS only. The first child is the row, and each edge's actions are ui-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.
  • UiVStack and UiHStack - SwiftUI's stacks, with alignment and spacing, and UiSpacer for the room left over in one.
  • UiSlider, UiStepper and UiToggle - with valueChanged, valueChange and isOnChange for what the user did.
  • UiTextField - its text is a nativeState(''), which the field writes to on the UI thread; textChange reports each change.
  • UiColorPicker, UiGauge and UiProgress.
  • UiForm, UiSection and UiLabeledContent - settings-style grouped rows.
  • UiImage - an SF Symbol by systemName, or a picture by uiImage URL.
  • UiText - a SwiftUI Text.
  • UiDatePicker - a SwiftUI DatePicker. selection is an ISO string; dateChange reports 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/expo
import { 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

matchContents
boolean | { readonly vertical?: boolean; readonly horizontal?: boolean }

ignoreSafeArea
'all' | 'keyboard' | 'container'

useViewportSizeMeasurement
boolean

ui-menucomponent@ng-native/expo
import { 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

label
string

systemImage
string

An SF Symbol name.

accessibilityLabel
string

modifiers
readonly UiModifier[]

ui-buttoncomponent@ng-native/expo
import { 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

label
string

systemImage
string

An SF Symbol name.

role
'default' | 'cancel' | 'destructive'

modifiers
readonly UiModifier[]

Outputs

buttonPress
NativeSyntheticEvent<Record<string, never>>

ui-dividercomponent@ng-native/expo
import { UiDivider } from '@ng-native/expo';and add UiDivider to the component's imports

A SwiftUI Divider : a separator between groups of menu items.

Inputs

modifiers
readonly UiModifier[]

ui-slotcomponent@ng-native/expo
import { 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

namerequired
string

extraProps
Readonly<Record<string, unknown>>

ui-listcomponent@ng-native/expo
import { 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

modifiers
readonly UiModifier[]

ui-swipe-actionscomponent@ng-native/expo
import { 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

modifiers
readonly UiModifier[]

ui-vstackcomponent@ng-native/expo
import { UiVStack } from '@ng-native/expo';and add UiVStack to the component's imports

A SwiftUI VStack .

Inputs

alignment
'leading' | 'center' | 'trailing'

spacing
number

modifiers
readonly UiModifier[]

ui-hstackcomponent@ng-native/expo
import { UiHStack } from '@ng-native/expo';and add UiHStack to the component's imports

A SwiftUI HStack .

Inputs

alignment
'top' | 'center' | 'bottom' | 'firstTextBaseline'

spacing
number

modifiers
readonly UiModifier[]

ui-spacercomponent@ng-native/expo
import { UiSpacer } from '@ng-native/expo';and add UiSpacer to the component's imports

A SwiftUI Spacer : the room left over in its stack.

Inputs

modifiers
readonly UiModifier[]

ui-slidercomponent@ng-native/expo
import { 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

value
number

min
number

max
number

steps
number

How many steps the range is divided into; none moves the thumb continuously.

modifiers
readonly UiModifier[]

Outputs

valueChanged
UiSliderChangeEvent

ui-steppercomponent@ng-native/expo
import { UiStepper } from '@ng-native/expo';and add UiStepper to the component's imports

A SwiftUI Stepper , between min and max by step .

Inputs

value
number

min
number

max
number

step
number

label
string

modifiers
readonly UiModifier[]

Outputs

valueChange
UiStepperChangeEvent

ui-togglecomponent@ng-native/expo
import { UiToggle } from '@ng-native/expo';and add UiToggle to the component's imports

A SwiftUI Toggle . isOnChange reports each flip.

Inputs

isOn
boolean

label
string

modifiers
readonly UiModifier[]

Outputs

isOnChange
UiToggleChangeEvent

ui-text-fieldcomponent@ng-native/expo
import { 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

text
NativeState<string> | null

placeholder
string

modifiers
readonly UiModifier[]

Outputs

textChange
UiTextFieldChangeEvent

ui-color-pickercomponent@ng-native/expo
import { UiColorPicker } from '@ng-native/expo';and add UiColorPicker to the component's imports

A SwiftUI ColorPicker . selection is a colour as hex.

Inputs

selection
string

label
string

supportsOpacity
boolean

modifiers
readonly UiModifier[]

Outputs

selectionChange
UiColorChangeEvent

ui-gaugecomponent@ng-native/expo
import { 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

value
number

min
number

max
number

type
string

currentValueLabel
string

minimumValueLabel
string

maximumValueLabel
string

modifiers
readonly UiModifier[]

ui-progresscomponent@ng-native/expo
import { 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

value
number

modifiers
readonly UiModifier[]

ui-formcomponent@ng-native/expo
import { UiForm } from '@ng-native/expo';and add UiForm to the component's imports

A SwiftUI Form : settings-style grouped rows.

Inputs

modifiers
readonly UiModifier[]

ui-sectioncomponent@ng-native/expo
import { 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

title
string

modifiers
readonly UiModifier[]

ui-labeled-contentcomponent@ng-native/expo
import { UiLabeledContent } from '@ng-native/expo';and add UiLabeledContent to the component's imports

A SwiftUI LabeledContent : a label, and its content beside it.

Inputs

label
string

modifiers
readonly UiModifier[]

ui-imagecomponent@ng-native/expo
import { 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

systemName
string

uiImage
string

size
number

color
string

modifiers
readonly UiModifier[]

ui-textcomponent@ng-native/expo
import { UiText } from '@ng-native/expo';and add UiText to the component's imports

A SwiftUI Text .

Inputs

text
string

modifiers
readonly UiModifier[]

ui-date-pickercomponent@ng-native/expo
import { 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

title
string

selection
string

displayedComponents
readonly ('date' | 'hourAndMinute')[]

range
{ readonly start?: string; readonly end?: string }

accessibilityLabel
string

modifiers
readonly UiModifier[]

disabled
boolean = false

Two-way

value
Date | null = null

The picked date: what [formField] binds.

Outputs

touch
void

The user picked, which is when a form shows a field's errors: a picker has no blur.

dateChange
UiDateChangeEvent

Angular components as native iOS and Android views, built and shipped with Expo.

An alpha. MIT licensed. Sponsor its development.

An independent project, not affiliated with or endorsed by Google, the Angular team or Expo. Angular is a trademark of Google LLC.