In this chapter, we'll learn how to create a native view for iOS (using SwiftUI), and Android (using Jetpack Compose), and use it in our Expo app. While the imperative APIs we built in earlier chapters exposed functions and events, native views work differently. A native view is a React component that renders native UI directly, while still giving us the ability to pass props from JavaScript.
- Display the current audio route (speaker, wired headset, or Bluetooth) using a native segmented control component
- Use SwiftUI on iOS and Jetpack Compose on Android
- Update the native view automatically when the route changes
- Plan and define the TypeScript API for the native views
- Implement a native view in SwiftUI and/or Jetpack Compose
- Build and test the view on a physical device
Before we build the native view, we need to install @expo/ui. Bridged SwiftUI and Jetpack Compose views can't take a style prop directly, so they need a JavaScript-side wrapper that gives them their size and position. @expo/ui provides that wrapper as a <Host> component, and it also gives us the modifier-aware base props (CommonViewModifierProps on iOS, PrimitiveBaseProps on Android) we'll use in our JavaScript bindings.
npx expo install @expo/uiJust like we did in previous chapters, let's begin by defining the JavaScript API for our view component. We'll add types, then create platform-specific bindings so iOS and Android can each plug into the @expo/ui modifier system.
First, let's update our types file with the prop types for our view:
File: modules/expo-audio-route/src/ExpoAudioRoute.types.ts
+import { StyleProp, ViewStyle } from 'react-native';
+
export type AudioRoute = 'speaker' | 'wiredHeadset' | 'bluetooth' | 'unknown';
export type RouteChangeEvent = {
route: AudioRoute;
};
export type ExpoAudioRouteModuleEvents = {
onAudioRouteChange: (params: RouteChangeEvent) => void;
};
+export type OptionChangeEventPayload = {
+ index: number;
+ value: string;
+};
+
+export type ExpoAudioRouteViewProps = {
+ options: string[];
+ selectedIndex?: number;
+ onOptionChange: (event: {
+ nativeEvent: OptionChangeEventPayload;
+ }) => void;
+ style?: StyleProp<ViewStyle>;
+};Next, let's create the view component in TypeScript. We'll use three files:
ExpoAudioRouteView.ios.tsx, picked up automatically on iOS.ExpoAudioRouteView.android.tsx, picked up automatically on Android.ExpoAudioRouteView.tsx, a fallback for any other platform.
Each platform-specific file extends our shared prop type with the @expo/ui modifier base props for that platform, and wires modifiers through to the native view using createViewModifierEventListener. This is what lets <Host> apply layout and gesture modifiers to our bridged view.
touch modules/expo-audio-route/src/ExpoAudioRouteView.tsx
touch modules/expo-audio-route/src/ExpoAudioRouteView.ios.tsx
touch modules/expo-audio-route/src/ExpoAudioRouteView.android.tsxFile: modules/expo-audio-route/src/ExpoAudioRouteView.ios.tsx
import { type CommonViewModifierProps } from '@expo/ui/swift-ui';
import { createViewModifierEventListener } from '@expo/ui/swift-ui/modifiers';
import { requireNativeView } from 'expo';
import { ExpoAudioRouteViewProps as SharedProps } from './ExpoAudioRoute.types';
export type ExpoAudioRouteViewProps = SharedProps & CommonViewModifierProps;
const NativeView =
requireNativeView<ExpoAudioRouteViewProps>('ExpoAudioRoute', 'ExpoAudioRouteView');
export default function ExpoAudioRouteView({ modifiers, ...rest }: ExpoAudioRouteViewProps) {
return (
<NativeView
modifiers={modifiers}
{...(modifiers ? createViewModifierEventListener(modifiers) : undefined)}
{...rest}
/>
);
}File: modules/expo-audio-route/src/ExpoAudioRouteView.android.tsx
import { type PrimitiveBaseProps } from '@expo/ui/jetpack-compose';
import { createViewModifierEventListener } from '@expo/ui/jetpack-compose/modifiers';
import { requireNativeView } from 'expo';
import { ExpoAudioRouteViewProps as SharedProps } from './ExpoAudioRoute.types';
export type ExpoAudioRouteViewProps = SharedProps & PrimitiveBaseProps;
const NativeView =
requireNativeView<ExpoAudioRouteViewProps>('ExpoAudioRoute', 'ExpoAudioRouteView');
export default function ExpoAudioRouteView({ modifiers, ...rest }: ExpoAudioRouteViewProps) {
return (
<NativeView
modifiers={modifiers}
{...(modifiers ? createViewModifierEventListener(modifiers) : undefined)}
{...rest}
/>
);
}File: modules/expo-audio-route/src/ExpoAudioRouteView.tsx
import { type PrimitiveBaseProps } from '@expo/ui/jetpack-compose';
import { requireNativeView } from 'expo';
import { ExpoAudioRouteViewProps as SharedProps } from './ExpoAudioRoute.types';
export type ExpoAudioRouteViewProps = SharedProps & PrimitiveBaseProps;
const NativeView =
requireNativeView<ExpoAudioRouteViewProps>('ExpoAudioRoute', 'ExpoAudioRouteView');
export default function ExpoAudioRouteView({ modifiers, ...rest }: ExpoAudioRouteViewProps) {
console.warn('You are using ExpoAudioRouteView on an unsupported platform.');
return null;
}File: modules/expo-audio-route/index.ts
export { default } from './src/ExpoAudioRouteModule';
export * from './src/ExpoAudioRoute.types';
export { useAudioRoute, useAudioRouteChangedEvent } from './src/ExpoAudioRouteModule';
+export { default as ExpoAudioRouteView } from './src/ExpoAudioRouteView';SwiftUI is Apple's modern declarative framework for building user interfaces. With Expo Modules, we can use SwiftUI views directly in our React Native app.
Note
π We strongly recommend using Xcode for this section, as it will surface any potential build/compile issues early
Throughout this section, @expo/ui refers to the npm package you installed in Exercise 0, and ExpoUI refers to the Swift module shipped inside that package. The Swift code imports ExpoUI; the JavaScript code imports from @expo/ui.
Expo Modules provides special protocols (ExpoSwiftUI.View and UIBaseViewProps) that allow SwiftUI views to be used as React Native components. A bridged SwiftUI view will typically make use of the following:
- A props class extending
UIBaseViewProps(provided by theExpoUISwift module), which defines properties that can be set from React (likeoptionsandselectedIndex) and plugs the view into the@expo/uimodifier system. - A view struct, which contains the actual SwiftUI view and rendering logic
- An
EventDispatcher, which allows the native view to call back to JavaScript
The view doesn't host itself, the JavaScript-side <Host> component from @expo/ui/swift-ui does. The view registers itself with ExpoUIView(...) (from the ExpoUI Swift module) instead of the plain View(...), which is what wires the modifier registry through to the native side.
Open the podspec and add ExpoUI as a dependency. This is what makes the ExpoUI symbols (UIBaseViewProps, ExpoUIView) available to the module.
File: modules/expo-audio-route/ios/ExpoAudioRoute.podspec
s.dependency 'ExpoModulesCore'
+ s.dependency 'ExpoUI'Create a new file for the SwiftUI view:
touch modules/expo-audio-route/ios/ExpoAudioRouteView.swiftSwift (iOS)
File: modules/expo-audio-route/ios/ExpoAudioRouteView.swift
import ExpoModulesCore
import ExpoUI
import SwiftUI
// Props class for the SwiftUI view. Extending `UIBaseViewProps` wires the
// view into the `@expo/ui` modifier system on the JavaScript side.
final class ExpoAudioRouteViewProps: UIBaseViewProps {
// We use the @Field property wrapper here to expose props that can be set from React Native
@Field var options: [String] = []
@Field var selectedIndex: Int?
// An EventDispatcher is how we associate callbacks from React Native
var onOptionChange = EventDispatcher()
}
// Our SwiftUI view conforms to `ExpoSwiftUI.View`. Hosting is provided by the
// JavaScript-side `<Host>` wrapper from `@expo/ui`.
struct ExpoAudioRouteView: ExpoSwiftUI.View {
@ObservedObject var props: ExpoAudioRouteViewProps
init(props: ExpoAudioRouteViewProps) {
self.props = props
}
var body: some View {
// The first parameter to `Picker` is a label, as an exercise you could try and expose this as a prop
// The second parameter binds the selected option prop to this component with a getter and setter
Picker("", selection: Binding(
get: { props.selectedIndex ?? 0 },
set: { newValue in
props.onOptionChange([
"index": newValue,
"value": props.options[newValue],
])
}
)) {
ForEach(Array(props.options.enumerated()), id: \.0) { index, option in
Text(option).tag(index)
}
}
.pickerStyle(.segmented)
}
}Now let's update the Swift module definition to include our view. We import ExpoUI and use ExpoUIView(...) so the native view is registered through the @expo/ui modifier registry.
Swift (iOS)
File: modules/expo-audio-route/ios/ExpoAudioRouteModule.swift
import AVFoundation
import ExpoModulesCore
+import ExpoUI
public class ExpoAudioRouteModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoAudioRoute")
Events("onAudioRouteChange")
AsyncFunction("getCurrentRouteAsync") {
return self.currentRoute()
}
OnStartObserving("onAudioRouteChange") {
self.sendEvent("onAudioRouteChange", ["route": self.currentRoute()])
self.startObservingRouteChanges()
}
OnStopObserving("onAudioRouteChange") {
self.stopObservingRouteChanges()
}
+ // Registers `ExpoAudioRouteView` as a native `@expo/ui` view component provided by this module.
+ // `ExpoUIView(...)` plugs the view into the `@expo/ui` modifier registry so the `modifiers`
+ // prop works on the JavaScript side. Because we used the `@Field` property in the SwiftUI
+ // view, we don't need to declare any props here.
+ ExpoUIView(ExpoAudioRouteView.self)
}
}Now that we've got a successfully built and running app, let's update the App.tsx file to display our new native view. <Host> from @expo/ui is what gives our bridged native view its size and position. It picks the right host (SwiftUI on iOS, Jetpack Compose on Android) for you based on the platform.
File: App.tsx
+import { Host } from '@expo/ui';
import { StatusBar } from 'expo-status-bar';
import { Button, StyleSheet, Text, View } from 'react-native';
-import ExpoAudioRoute, { AudioRoute, useAudioRouteChangedEvent } from './modules/expo-audio-route';
+import { AudioRoute, ExpoAudioRouteView, useAudioRouteChangedEvent } from './modules/expo-audio-route';
const initialRoute: AudioRoute = 'unknown';
export default function App() {
const { route } = useAudioRouteChangedEvent();
+ const possibleRoutes = ['wiredHeadset', 'bluetooth', 'speaker', 'unknown'];
return (
<View style={styles.container}>
<StatusBar style="auto" />
<Text>Current Route: {route}</Text>
- <Button
- title="Get Audio Route"
- onPress={async () => {
- const route = await ExpoAudioRoute.getCurrentRouteAsync();
- console.log(route);
- }}
- />
+ <Host style={styles.audioRoute}>
+ <ExpoAudioRouteView
+ options={possibleRoutes}
+ onOptionChange={({ nativeEvent: { index, value } }) => {
+ console.log({
+ index,
+ value,
+ });
+ }}
+ />
+ </Host>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
+ audioRoute: {
+ width: '100%',
+ paddingHorizontal: 20,
+ paddingVertical: 20,
+ }
});After making the changes to App.tsx, we should now see our new Native View being rendered:
Note
π Try it: Build the app with npx expo run:ios --device. A successful build of the app should look like the screenshot below:
If you tap on a segment, you should see a log in your terminal showing the index and value of that segment.
Jetpack Compose is Android's modern toolkit for building native UI. Like SwiftUI, it also uses a declarative approach to building UI.
Note
π We strongly recommend using Android Studio for this section, as it will surface any potential build/compile issues early
Similar to how we bridged SwiftUI, Expo Modules provides ComposeProps and a functional DSL to bridge Jetpack Compose views to React Native. A bridged Jetpack Compose view will typically make use of:
- A props data class annotated with
@OptimizedComposeProps, which defines properties that can be set from React (likeoptionsandselectedIndex) and amodifiers: ModifierListfield that plugs into the@expo/uimodifier system. - A
Recordtype for any structured event payload (such as ourOptionChangeEvent) - A composable function defined on
FunctionalComposableScope, which contains the actual Jetpack Compose UI and applies the JavaScript-side modifiers viaModifierRegistry.applyModifiers(...). - The module's
ExpoUIView<Props>(name) { Content { ... } }block (from@expo/ui), which wires props, events (declared withby Event<T>()), and theContent { ... }body together.
As on iOS, hosting is provided by the JavaScript-side <Host> component from @expo/ui.
First, we need to make a few changes to our module's build.gradle so that we can use Jetpack Compose.
Unlike SwiftUI which comes with iOS, Jetpack Compose is a separate library that needs to be explicitly added as a dependency. We'll need to:
- Pull in the Kotlin Compose compiler plugin classpath and apply the plugin.
- Switch from the
plugins {}block toapply plugin:calls (so thebuildscript {}classpath is in scope when the Compose plugin is applied). - Enable Compose in the Android
buildFeatures. - Add the
expo-uiand Compose library dependencies.
Groovy
File: modules/expo-audio-route/android/build.gradle
-plugins {
- id 'com.android.library'
- id 'expo-module-gradle-plugin'
-}
+// Pull in the Kotlin Compose compiler plugin classpath.
+buildscript {
+ repositories {
+ mavenCentral()
+ }
+ dependencies {
+ classpath("org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:${kotlinVersion}")
+ }
+}
+
+apply plugin: 'com.android.library'
+apply plugin: 'expo-module-gradle-plugin'
+apply plugin: 'org.jetbrains.kotlin.plugin.compose' // Apply the Compose compiler plugin.
group = 'expo.modules.audioroute'
version = '0.1.0'
android {
namespace "expo.modules.audioroute"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
+ buildFeatures {
+ compose true
+ }
}
+// Depend on `expo-ui` plus the Compose libraries you use.
+dependencies {
+ if (findProject(':expo-ui') != null) {
+ implementation project(':expo-ui')
+ } else {
+ implementation 'expo.modules.ui:expo.modules.ui:+'
+ }
+ implementation 'androidx.compose.foundation:foundation-android:1.10.6'
+ implementation 'androidx.compose.ui:ui-android:1.10.6'
+ implementation 'androidx.compose.material3:material3:1.5.0-alpha17'
+}Next, let's create a new file for the view:
touch modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteView.ktKotlin (Android)
File: modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteView.kt
package expo.modules.audioroute
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import expo.modules.kotlin.views.ComposeProps
import expo.modules.kotlin.views.FunctionalComposableScope
import expo.modules.kotlin.views.OptimizedComposeProps
import expo.modules.ui.ModifierList
import expo.modules.ui.ModifierRegistry
// Props class for the Jetpack Compose view. SDK 56 uses plain (non-`MutableState`) fields and
// relies on the `@OptimizedComposeProps` annotation to generate efficient prop bindings at build time.
// The `modifiers: ModifierList` field is what plugs us into the `@expo/ui` modifier system,
// it's the prop that carries the modifier configuration sent from JavaScript.
@OptimizedComposeProps
data class ExpoAudioRouteViewProps(
val options: Array<String> = emptyArray(),
val selectedIndex: Int? = null,
val modifiers: ModifierList = emptyList(),
) : ComposeProps
// Payload type for the `onOptionChange` event. `@Field`-annotated `Record` properties are how we
// declare a structured payload that can cross the JavaScript boundary.
data class OptionChangeEvent(
@Field val index: Int = 0,
@Field val value: String = "",
) : Record
// Composable body for our bridged view. We extend `FunctionalComposableScope` so the surrounding
// builder in the module definition can wire up state, events, and hosting context, including
// the `appContext`, `composableScope`, and `globalEventDispatcher` we hand to `ModifierRegistry`.
// This function is invoked from
// `ExpoUIView<ExpoAudioRouteViewProps>("ExpoAudioRouteView") { Content { props -> ... } }`.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FunctionalComposableScope.ExpoAudioRouteContent(
props: ExpoAudioRouteViewProps,
onOptionChange: (OptionChangeEvent) -> Unit,
) {
SingleChoiceSegmentedButtonRow(
modifier = ModifierRegistry.applyModifiers(
props.modifiers,
appContext,
composableScope,
globalEventDispatcher,
),
) {
props.options.forEachIndexed { index, value ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index = index, count = props.options.size),
onClick = { onOptionChange(OptionChangeEvent(index = index, value = value)) },
selected = index == props.selectedIndex,
label = { Text(value) },
)
}
}
}Now let's update the module definition to include our view. We import ExpoUIView from expo.modules.ui and use the ExpoUIView<Props>(name) { Content { ... } } DSL, which is what wires the @expo/ui modifier registry through to the native side.
Kotlin (Android)
File: modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt
package expo.modules.audioroute
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
+import expo.modules.ui.ExpoUIView
import android.content.Context
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.media.AudioDeviceCallback
class ExpoAudioRouteModule : Module() {
private var audioManager: AudioManager? = null
private var deviceCallback: AudioDeviceCallback? = null
override fun definition() = ModuleDefinition {
Name("ExpoAudioRoute")
Events("onAudioRouteChange")
OnCreate {
audioManager = appContext.reactContext?.getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
AsyncFunction("getCurrentRouteAsync") {
currentRoute()
}
OnStartObserving("onAudioRouteChange") {
sendEvent("onAudioRouteChange", mapOf("route" to currentRoute()))
startObservingRouteChanges()
}
OnStopObserving("onAudioRouteChange") {
stopObservingRouteChanges()
}
+ // Registers `ExpoAudioRouteView` as a native `@expo/ui` view component provided by this module.
+ // The `ExpoUIView<Props>(name) { Content { props -> ... } }` DSL plugs the view into the
+ // `@expo/ui` modifier registry, so the `modifiers` prop works on the JavaScript side.
+ // Events are declared with the `by Event<T>()` property delegate, and the JavaScript-visible
+ // event name is the val name.
+ ExpoUIView<ExpoAudioRouteViewProps>("ExpoAudioRouteView") {
+ val onOptionChange by Event<OptionChangeEvent>()
+ Content { props ->
+ ExpoAudioRouteContent(props) { event ->
+ onOptionChange(event)
+ }
+ }
+ }
}
}Note
π Try it: Build the app with npx expo run:android --device. A successful build of the app should look like the screenshot below:
Now that we've got our Native View successfully built and working for both platforms, let's have it update when the current audio route changes.
Now let's wire up App.tsx to display the native view component. We'll use the value of route from useAudioRouteChangedEvent to ensure that the correct segment in our native view is selected. Whenever route changes, the selected segment will also change.
File: App.tsx
import { Host } from '@expo/ui';
import { StatusBar } from 'expo-status-bar';
import { Button, StyleSheet, Text, View } from 'react-native';
import { AudioRoute, ExpoAudioRouteView, useAudioRouteChangedEvent } from './modules/expo-audio-route';
const initialRoute: AudioRoute = 'unknown';
export default function App() {
const { route } = useAudioRouteChangedEvent();
const possibleRoutes = ['wiredHeadset', 'bluetooth', 'speaker', 'unknown'];
+ const index = possibleRoutes.indexOf(route);
return (
<View style={styles.container}>
<StatusBar style="auto" />
<Text>Current Route: {route}</Text>
<Host style={styles.audioRoute}>
<ExpoAudioRouteView
+ selectedIndex={index}
options={possibleRoutes}
onOptionChange={({ nativeEvent: { index, value } }) => {
console.log({
index,
value,
});
}}
/>
</Host>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
audioRoute: {
width: '100%',
paddingHorizontal: 20,
paddingVertical: 20,
}
});With the changes above, we should now see our view displaying the currently active audio route.
Note
π Try it: Build the app and run it on your device. A successful build of the app should look like the screenshot below:
With the app running on your device:
- Observe the initial audio route display
- Connect Bluetooth headphones and watch the view update automatically
- Disconnect Bluetooth and plug in wired headphones to see the view change again
- Disconnect all devices to see the view update to the speaker route once again
Note
π Try it: Build the app and run it on your device. Changing the audio route should look like the video below:
chapter-5-iphone-vid.mp4
chapter-5-android-vid.mp4
The examples used in this chapter are simplified versions of the <Picker /> component from @expo/ui/swift-ui and @expo/ui/jetpack-compose in SDK 56. We strongly recommend checking out their source code to learn more.