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.
- Understand what a shared object is and how it differs from a module-level function.
- Create a
SharedObjectsubclass in Swift and Kotlin, and register it with theClassDSL. - Construct the shared object from React and subscribe to its events with
useEvent.
- Declare the shared object on the TypeScript side.
- Implement the
VolumeMonitorclass in Swift and/or Kotlin. - Register the class in the module definition with
Class,Constructor, andProperty. - Construct an instance in
App.tsx, render the level in a<Text>, and listen for live updates.
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.
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;
}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.
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.ktSwift (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.outputVolumereturns aFloatbetween 0 and 1, so we use it directly.\.outputVolumeis Swift key path syntax. We keep the returned token inobservationso the subscription isn't dropped.setActive(true)is required for KVO onoutputVolumeto fire. Errors are ignored withtry?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 anInt(a stream-specific volume index, not a 0..1 value), so we convert toFloatand divide bygetStreamMaxVolume()to normalize.- Android's
AudioManagerdoesn't expose a volume listener, so we observeSettings.System.CONTENT_URIinstead. It fires for any system-settings change, including volume. Handler(Looper.getMainLooper())is Android's "deliver this callback on the main thread" boilerplate.
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.
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
Constructordeclares what happens natively when JavaScript callsnew ExpoAudioRoute.VolumeMonitor().Propertyexposes a getter to JavaScript. The same DSL also supportsProperty("x").get { ... }.set { ... }for read/write properties, plusFunction(...)andAsyncFunction(...)inside a class.- We don't need to declare event names here. Since
SharedObjectalready extendsEventEmitter,addListener,removeListener, anduseEventall work out of the box.
Now we'll consume the shared object in App.tsx, render the volume in a <Text>, and watch it update live.
Since we changed native code, we need to rebuild:
npx expo run:ios --device
# or
npx expo run:android --deviceCreate 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.levelis read once, to seed the initial render. That hits the native getter through theProperty("level")block we registered in Exercise 2.- After mount, every update comes from the
onChangeevent payload, which the native side emits whenever the system volume changes. The.levelproperty 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.
Just like we did in Chapter 4 with useAudioRouteChangedEvent, we can hide the useEvent boilerplate behind a useVolumeLevel hook. Try it on your own.
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;File: modules/expo-audio-route/index.ts
-export { useAudioRoute, useAudioRouteChangedEvent } from './src/ExpoAudioRouteModule';
+export { useAudioRoute, useAudioRouteChangedEvent, useVolumeLevel } from './src/ExpoAudioRouteModule';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();
...
}- Shared objects in the Expo Modules docs.
- The
ClassDSL accepts the same building blocks you've used at the module level:Function,AsyncFunction,Property(with.get/.set), plusConstructorandStaticFunction. - Real-world examples worth reading:
expo-video'sVideoPlayer,expo-sqlite's database and statement handles, andexpo-image's decoded image refs.