Skip to content

Latest commit

Β 

History

History
697 lines (538 loc) Β· 25.1 KB

File metadata and controls

697 lines (538 loc) Β· 25.1 KB

Chapter 5

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.

Goals

  • 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

Tasks

  • 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

Exercises

Exercise 0: Install @expo/ui

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/ui

Exercise 1: Define the JavaScript API

Just 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.

Tasks

1. Define TypeScript types

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>;
+};

2. Create the TypeScript bindings

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.tsx

File: 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;
}

3. Export the native view

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

Exercise 2: Create a SwiftUI view for iOS

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

Background: How SwiftUI Integrates with Expo Modules

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 the ExpoUI Swift module), which defines properties that can be set from React (like options and selectedIndex) and plugs the view into the @expo/ui modifier 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.

Tasks

1. Add ExpoUI to the module's iOS dependencies

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'

2. Implement a SwiftUI View

Create a new file for the SwiftUI view:

touch modules/expo-audio-route/ios/ExpoAudioRouteView.swift
Swift (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)
    }
}

3. Expose the view through the Expo Module

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)
  }
}

4. Update App.tsx

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:

iOS device showing the newly-created native view

If you tap on a segment, you should see a log in your terminal showing the index and value of that segment.

Exercise 3: Create a Jetpack Compose view for Android

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

Background: How Jetpack Compose Integrates with Expo Modules

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 (like options and selectedIndex) and a modifiers: ModifierList field that plugs into the @expo/ui modifier system.
  • A Record type for any structured event payload (such as our OptionChangeEvent)
  • A composable function defined on FunctionalComposableScope, which contains the actual Jetpack Compose UI and applies the JavaScript-side modifiers via ModifierRegistry.applyModifiers(...).
  • The module's ExpoUIView<Props>(name) { Content { ... } } block (from @expo/ui), which wires props, events (declared with by Event<T>()), and the Content { ... } body together.

As on iOS, hosting is provided by the JavaScript-side <Host> component from @expo/ui.

Tasks

1. Update build configuration

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 to apply plugin: calls (so the buildscript {} classpath is in scope when the Compose plugin is applied).
  • Enable Compose in the Android buildFeatures.
  • Add the expo-ui and 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'
+}

2. Implement a Jetpack Compose View

Next, let's create a new file for the view:

touch modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteView.kt
Kotlin (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) },
            )
        }
    }
}

3. Expose the view through the Expo Module

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:

Android device showing the newly-created native view

Exercise 4: Connect the Native View to the audio route

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.

Tasks

1. Hook up the audio route to the Native View

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:

image

2. Test by changing audio routes

With the app running on your device:

  1. Observe the initial audio route display
  2. Connect Bluetooth headphones and watch the view update automatically
  3. Disconnect Bluetooth and plug in wired headphones to see the view change again
  4. 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

Further reading

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.