Building great Expo native modules for iOS and Android. Views, APIs, Marshalling, Shared Objects, Expo Documentation, Verifying Expo modules.
std:kv-storage, clipboard.capture(options: { isHighQuality: boolean }), use capture(options: { quality: 'high' | 'medium' | 'low' }).isAvailable functions or constants. eg snapshot.capture?.() instead of snapshot.isAvailable && snapshot.capture().Example of a GREAT Expo module:
import { NativeModule } from "expo";
declare class AppClipModule extends NativeModule<{}> {
prompt(): void;
isAppClip?: boolean;
}
// This call loads the native module object from the JSI.
const AppClipNative =
typeof expo !== "undefined"
? (expo.modules.AppClip as AppClipModule) ?? {}
: {};
if (AppClipNative?.isAppClip) {
navigator.appClip = {
prompt: AppClipNative.prompt,
};
}
// Add types for the global `navigator.appClip` object.
declare global {
interface Navigator {
/**
* Only available in an App Clip context.
* @expo
*/
appClip?: {
/** Open the SKOverlay */
prompt: () => void;
};
}
}
export {};
isAvailable methods.Example of a POOR Expo module:
import { NativeModulesProxy } from "expo-modules-core";
const { ExpoAppClip } = NativeModulesProxy;
export default {
promptAppClip() {
return ExpoAppClip.promptAppClip();
},
isAppClipAvailable() {
return ExpoAppClip.isAppClipAvailable();
},
};
isAvailable(), explain why it exists in the docs. Research cases where it may return false such as in a simulator or particular OS version.import * as MediaLibrary from 'expo-media-library'; instead of import { MediaLibrary } from 'expo/media';Take API inspiration from great web component libraries like BaseUI and Radix.
Consider if you're building a control or a display component. Controls should have more interactive APIs, while display components should be more declarative.
Prefer functions on views instead of useImperativeHandle + findNodeHandle.
AsyncFunction("capture") { (view, options: Options) -> Ref in
return try capture(self.appContext, view)
}
Remember to export views in the module:
import ExpoModulesCore
public class ExpoWebViewModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoWebView")
View(ExpoWebView.self) {}
}
}
Consider this example https://github.com/EvanBacon/expo-shared-objects-haptics-example/blob/be90e92f8dba9b0807009502ab25c423c57e640d/modules/my-module/ios/MyModule.swift#L1C1-L178C2
Using @retroactive Convertible and AnyArgument to convert between Swift types and dictionaries enables passing complex data structures across the boundary without writing custom serialization code for each type.
extension CHHapticEventParameter: @retroactive Convertible, AnyArgument {
public static func convert(from value: Any?, appContext: AppContext) throws -> Self {
guard let dict = value as? [String: Any],
let parameterIDRaw = dict["parameterID"] as? String,
let value = dict["value"] as? Double else {
throw NotADictionaryException()
}
return Self(parameterID: CHHapticEvent.ParameterID(rawValue: parameterIDRaw), value: Float(value))
}
}
extension CHHapticEvent: @retroactive Convertible, AnyArgument {
public static func convert(from value: Any?, appContext: AppContext) throws -> Self {
guard let dict = value as? [String: Any],
let eventTypeRaw = dict["eventType"] as? String,
let relativeTime = dict["relativeTime"] as? Double else {
throw NotADictionaryException()
}
let eventType = CHHapticEvent.EventType(rawValue: eventTypeRaw)
let parameters = (dict["parameters"] as? [[String: Any]])?.compactMap { paramDict -> CHHapticEventParameter? in
try? CHHapticEventParameter.convert(from: paramDict, appContext: appContext)
} ?? []
return Self(eventType: eventType, parameters: parameters, relativeTime: relativeTime)
}
}
extension CHHapticDynamicParameter: @retroactive Convertible, AnyArgument {
public static func convert(from value: Any?, appContext: AppContext) throws -> Self {
guard let dict = value as? [String: Any],
let parameterIDRaw = dict["parameterID"] as? String,
let value = dict["value"] as? Double,
let relativeTime = dict["relativeTime"] as? Double else {
throw NotADictionaryException()
}
return Self(parameterID: CHHapticDynamicParameter.ID(rawValue: parameterIDRaw), value: Float(value), relativeTime: relativeTime)
}
}
extension CHHapticPattern: @retroactive Convertible, AnyArgument {
public static func convert(from value: Any?, appContext: AppContext) throws -> Self {
guard let dict = value as? [String: Any],
let eventsArray = dict["events"] as? [[String: Any]] else {
throw NotADictionaryException()
}
let events = try eventsArray.map { eventDict -> CHHapticEvent in
try CHHapticEvent.convert(from: eventDict, appContext: appContext)
}
let parameters = (dict["parameters"] as? [[String: Any]])?.compactMap { paramDict -> CHHapticDynamicParameter? in
return try? CHHapticDynamicParameter.convert(from: paramDict, appContext: appContext)
} ?? []
return try Self(events: events, parameters: parameters)
}
}
internal final class NotAnArrayException: Exception {
override var reason: String {
"Given value is not an array"
}
}
internal final class IncorrectArraySizeException: GenericException<(expected: Int, actual: Int)> {
override var reason: String {
"Given array has unexpected number of elements: \(param.actual), expected: \(param.expected)"
}
}
internal final class NotADictionaryException: Exception {
override var reason: String {
"Given value is not a dictionary"
}
}
Later this can be used to implement methods that accept complex data structures as arguments.
Function("playPattern") { (pattern: CHHapticPattern) in
let player = try hapticEngine.makePlayer(with: pattern)
try player.start(atTime: 0)
}
Use shorthand where possible, especially when the JS value matches the Swift value:
Property("__typename") { $0.__typename }
Shared objects are long-lived native instances that are shared to JS. They can be used to keep heavy state objects, such as a decoded bitmap, alive across React components, rather than spinning up a new native instance every time a component mounts.
To interact with HealthKit, the module may need to respond to app lifecycle events. This can be done by implementing the ExpoAppDelegateSubscriber protocol.
import ExpoModulesCore
public class ExpoHeadAppDelegateSubscriber: ExpoAppDelegateSubscriber {
// Any AppDelegate methods you want to implement
public func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
launchedActivity = userActivity
// ...
return false
}
}
Then add the subscriber to the expo-module.config.json:
{
"platforms": ["apple", "android", "web"],
"apple": {
"modules": ["ExpoHeadModule", ...],
"appDelegateSubscribers": ["ExpoHeadAppDelegateSubscriber"]
}
}
expo-quick-actions which has a expo-quick-actions/router import for automatic deep linking. Other good examples are Expo notifications (open settings, redirect notifications), widgets, siri shortcuts.
yarn expo run:ios --no-bundler in an Expo app to headlessly compile the module and verify there are no compilation errors.