Skip to content

Latest commit

 

History

History
376 lines (274 loc) · 12.9 KB

File metadata and controls

376 lines (274 loc) · 12.9 KB

Chapter 7

In this chapter, we'll add a Shared Object to our expo-audio-route module: a VolumeMonitor that reports the device's current audio output volume and emits an event whenever the volume changes.

A shared object lets you expose a native class to JavaScript. Unlike a Function or AsyncFunction, which is a single call, a shared object is a stateful instance: you create it with new, read and write properties, call methods, and listen to events the native side emits back.

Goals

  • Understand what a shared object is and how it differs from a module-level function.
  • Create a SharedObject subclass in Swift and Kotlin, and register it with the Class DSL.
  • Construct the shared object from React and subscribe to its events with useEvent.

Tasks

  • Declare the shared object on the TypeScript side.
  • Implement the VolumeMonitor class in Swift and/or Kotlin.
  • Register the class in the module definition with Class, Constructor, and Property.
  • Construct an instance in App.tsx, render the level in a <Text>, and listen for live updates.

Exercises

Exercise 0: Plan and define the JavaScript API

Background: What is a Shared Object?

Up to now, our module has exposed plain functions (getCurrentRouteAsync), events (onAudioRouteChange), and a native view. A shared object exposes a native class. Each new VolumeMonitor() allocates one Swift or Kotlin instance, and that instance stays alive as long as JavaScript holds a reference to it. Property reads and method calls are forwarded to the native instance, and the native instance can emit events back to JavaScript.

Because SharedObject already extends Expo's EventEmitter, the JS instance gets addListener and removeListener for free, and the useEvent hook from Chapter 4 works without any extra wiring.

Tasks

1. Declare the shared object on the JS side

File: modules/expo-audio-route/src/ExpoAudioRouteModule.ts

Import SharedObject, declare a VolumeMonitor class with one event and one read-only property, and attach the class to the module declaration so it can be accessed as ExpoAudioRoute.VolumeMonitor.

-import { NativeModule, requireNativeModule, useEvent } from 'expo';
+import { NativeModule, requireNativeModule, SharedObject, useEvent } from 'expo';
import { AudioRoute, ExpoAudioRouteModuleEvents } from './ExpoAudioRoute.types';

+type VolumeMonitorEvents = {
+  onChange: (event: { level: number }) => void;
+};
+
+declare class VolumeMonitor extends SharedObject<VolumeMonitorEvents> {
+  readonly level: number;
+}
+
declare class ExpoAudioRouteModule extends NativeModule<ExpoAudioRouteModuleEvents> {
  getCurrentRouteAsync(): Promise<AudioRoute>;
+ VolumeMonitor: typeof VolumeMonitor;
}

Exercise 1: Implement the SharedObject in Swift and/or Kotlin

The class extends SharedObject and watches the system audio volume so it can emit an onChange event whenever the volume changes. On iOS we use Key-Value Observation (KVO), Apple's API for getting notified when a property's value changes. On Android we register a ContentObserver on the system settings.

Tasks

1. Create a new file for the shared object

We'll keep the class in its own file, just like we did with ExpoAudioRouteView in Chapter 5.

touch modules/expo-audio-route/ios/VolumeMonitor.swift
touch modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/VolumeMonitor.kt

2. Implement the class

Swift (iOS)

File: modules/expo-audio-route/ios/VolumeMonitor.swift

import AVFoundation
import ExpoModulesCore

final class VolumeMonitor: SharedObject {
  private var observation: NSKeyValueObservation?

  override init() {
    super.init()
    let session = AVAudioSession.sharedInstance()
    try? session.setActive(true)
    observation = session.observe(\.outputVolume) { session, _ in
      self.emit(event: "onChange", payload: ["level": session.outputVolume])
    }
  }

  var level: Float {
    return AVAudioSession.sharedInstance().outputVolume
  }
}

A few things to call out:

  • AVAudioSession.outputVolume returns a Float between 0 and 1, so we use it directly.
  • \.outputVolume is Swift key path syntax. We keep the returned token in observation so the subscription isn't dropped.
  • setActive(true) is required for KVO on outputVolume to fire. Errors are ignored with try? to keep the example short.
Kotlin (Android)

File: modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/VolumeMonitor.kt

package expo.modules.audioroute

import android.content.Context
import android.database.ContentObserver
import android.media.AudioManager
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import expo.modules.kotlin.sharedobjects.SharedObject

class VolumeMonitor(
  private val context: Context,
  private val audioManager: AudioManager,
) : SharedObject() {
  private val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
    override fun onChange(selfChange: Boolean) {
      emit("onChange", mapOf("level" to level))
    }
  }

  init {
    context.contentResolver.registerContentObserver(
      Settings.System.CONTENT_URI, true, observer
    )
  }

  val level: Float
    get() {
      val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).toFloat()
      val current = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat()
      return if (max > 0) current / max else 0f
    }
}

A few things to call out:

  • AudioManager.getStreamVolume() returns an Int (a stream-specific volume index, not a 0..1 value), so we convert to Float and divide by getStreamMaxVolume() to normalize.
  • Android's AudioManager doesn't expose a volume listener, so we observe Settings.System.CONTENT_URI instead. It fires for any system-settings change, including volume.
  • Handler(Looper.getMainLooper()) is Android's "deliver this callback on the main thread" boilerplate.

Exercise 2: Register the class in the module definition

The shared object class on its own isn't visible to JavaScript yet. We need to tell the module about it using the Class DSL.

Tasks

1. Add a Class block to the module definition

Place the new block at the bottom of your ModuleDefinition, after the ExpoUIView(...) registration from Chapter 5.

Swift (iOS)

File: modules/expo-audio-route/ios/ExpoAudioRouteModule.swift

ExpoUIView(ExpoAudioRouteView.self)

+Class("VolumeMonitor", VolumeMonitor.self) {
+  Constructor {
+    return VolumeMonitor()
+  }
+
+  Property("level") { (monitor: VolumeMonitor) in
+    return monitor.level
+  }
+}
Kotlin (Android)

File: modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt

ExpoUIView<ExpoAudioRouteViewProps>("ExpoAudioRouteView") { ... }

+Class<VolumeMonitor>("VolumeMonitor") {
+  Constructor {
+    VolumeMonitor(appContext.reactContext!!, audioManager!!)
+  }
+
+  Property("level") { monitor: VolumeMonitor ->
+    monitor.level
+  }
+}

The Kotlin constructor reuses the same audioManager we initialized in Chapter 2's OnCreate.

Note

  • Constructor declares what happens natively when JavaScript calls new ExpoAudioRoute.VolumeMonitor().
  • Property exposes a getter to JavaScript. The same DSL also supports Property("x").get { ... }.set { ... } for read/write properties, plus Function(...) and AsyncFunction(...) inside a class.
  • We don't need to declare event names here. Since SharedObject already extends EventEmitter, addListener, removeListener, and useEvent all work out of the box.

Exercise 3: Use the Shared Object from React

Now we'll consume the shared object in App.tsx, render the volume in a <Text>, and watch it update live.

Tasks

1. Build and run on your device

Since we changed native code, we need to rebuild:

npx expo run:ios --device
# or
npx expo run:android --device

2. Update App.tsx

Create the VolumeMonitor at module scope so we have a single instance for the lifetime of the app, and subscribe to its onChange event with useEvent.

File: App.tsx

import { Host } from '@expo/ui';
import { NativeModule, requireNativeModule, useEvent } from 'expo';
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';
-import { AudioRoute, ExpoAudioRouteView, useAudioRouteChangedEvent } from './modules/expo-audio-route';
+import ExpoAudioRoute, { AudioRoute, ExpoAudioRouteView, useAudioRouteChangedEvent } from './modules/expo-audio-route';

type ExpoAudioRouteInlineEvents = {
  onAudioRouteChange: (params: { route: AudioRoute }) => void;
};

declare class ExpoAudioRouteInlineModule extends NativeModule<ExpoAudioRouteInlineEvents> {
  getCurrentRouteAsync(): Promise<AudioRoute>;
}

const inlineModule = requireNativeModule<ExpoAudioRouteInlineModule>(
  'ExpoAudioRouteInline'
);

+const volumeMonitor = new ExpoAudioRoute.VolumeMonitor();

export default function App() {
  const { route } = useAudioRouteChangedEvent();
  const { route: inlineRoute } = useEvent(inlineModule, 'onAudioRouteChange', {
    route: 'unknown' as AudioRoute,
  });
+ const { level } = useEvent(volumeMonitor, 'onChange', {
+   level: volumeMonitor.level,
+ });
  const possibleRoutes = ['wiredHeadset', 'bluetooth', 'speaker', 'unknown'];
  const index = possibleRoutes.indexOf(route);

  return (
    <View style={styles.container}>
      <StatusBar style="auto" />
      <Text style={styles.heading}>Local module</Text>
      <Text>Current Route: {route}</Text>
      <Host style={styles.audioRoute}>
        <ExpoAudioRouteView
          selectedIndex={index}
          options={possibleRoutes}
          onOptionChange={({ nativeEvent: { index, value } }) => {
            console.log({ index, value });
          }}
        />
      </Host>
+
+     <Text style={styles.heading}>Volume</Text>
+     <Text>{level.toFixed(2)}</Text>

      <View style={styles.divider} />

      <Text style={styles.heading}>Inline module</Text>
      <Text>Current Route: {inlineRoute}</Text>
    </View>
  );
}

Two things are wired up here:

  • volumeMonitor.level is read once, to seed the initial render. That hits the native getter through the Property("level") block we registered in Exercise 2.
  • After mount, every update comes from the onChange event payload, which the native side emits whenever the system volume changes. The .level property isn't read again.

If you removed the initial volumeMonitor.level read and passed { level: 0 } instead, the UI would show 0 until the user moved the volume slider for the first time.

Note

👀 Try it: Press the physical volume up/down buttons on your device. The number under "Volume" should update live, without touching the app.

Bonus Exercise: Wrap the Shared Object in a custom hook

Just like we did in Chapter 4 with useAudioRouteChangedEvent, we can hide the useEvent boilerplate behind a useVolumeLevel hook. Try it on your own.

Tasks

1. Add the hook

File: modules/expo-audio-route/src/ExpoAudioRouteModule.ts

const nativeModule = requireNativeModule<ExpoAudioRouteModule>('ExpoAudioRoute');

...

export function useAudioRouteChangedEvent() {
  return useEvent(nativeModule, 'onAudioRouteChange', {
    route: initialRoute,
  });
}

+const volumeMonitor = new nativeModule.VolumeMonitor();
+
+export function useVolumeLevel() {
+  return useEvent(volumeMonitor, 'onChange', {
+    level: volumeMonitor.level,
+  });
+}

export default nativeModule;

2. Re-export the hook

File: modules/expo-audio-route/index.ts

-export { useAudioRoute, useAudioRouteChangedEvent } from './src/ExpoAudioRouteModule';
+export { useAudioRoute, useAudioRouteChangedEvent, useVolumeLevel } from './src/ExpoAudioRouteModule';

3. Use it in App.tsx

Drop the module-scope volumeMonitor and the inline useEvent call, then read from the hook instead:

-import ExpoAudioRoute, { AudioRoute, ExpoAudioRouteView, useAudioRouteChangedEvent } from './modules/expo-audio-route';
+import { AudioRoute, ExpoAudioRouteView, useAudioRouteChangedEvent, useVolumeLevel } from './modules/expo-audio-route';

-const volumeMonitor = new ExpoAudioRoute.VolumeMonitor();

export default function App() {
  ...
- const { level } = useEvent(volumeMonitor, 'onChange', {
-   level: volumeMonitor.level,
- });
+ const { level } = useVolumeLevel();
  ...
}

Further reading

  • Shared objects in the Expo Modules docs.
  • The Class DSL accepts the same building blocks you've used at the module level: Function, AsyncFunction, Property (with .get / .set), plus Constructor and StaticFunction.
  • Real-world examples worth reading: expo-video's VideoPlayer, expo-sqlite's database and statement handles, and expo-image's decoded image refs.