Adding Native Modules to a Managed Expo Project with Custom Config Plugins - Part One
Expo is a popular toolchain for React Native which simplifies the development of mobile applications by abstracting away much of the complexity of configuring native projects.
Traditionally there have been two main workflows associated with Expo. The first is the managed worflow, whereby the developer only needs to write JavaScript and TypeScript while leaving Expo to handle the rest. The other option is the bare workflow, where the developer is responsible for the native iOS and Android workflows.
The Problem
For a long time, the downside to the managed workflow was that developers were confined to libraries included in the Expo SDK, which seriously limited the functionality that could be implemented when compared to a truly native approach.
Although the bare workflow offered a (now deprecated) potential solution by way of "ejecting" from Expo, this wasn't necessarily a simple solution for those who hadn't been exposed to much native iOS and Android development.
Fortunately, Expo provided us with a great alternative to the bare workflow with the advent of config plugins in SDK 41.
Config Plugins to the Rescue
Disclaimer The code in this article uses JavaScript to keep things accessible to a wider audience, but in a production environment I - and the Expo team - would recommend using TypeScript.
Expo config plugins are described in the documentation as a kind of bundler for native projects. Typical applications of a config plugin might be to update the AndroidManifest.xml
to add geolocation permissions, or to update the iOS Podfile
.
What makes these plugins so convenient is that they can be written in TypeScript or JavaScript as the sort of regular node module that will be instantly familiar to any JavaScript/TypeScript programmer.
A given plugin is a synchronous function which takes in an Expo Config object, makes some changes to that object, and then returns the modified object.
It is convention to give each plugin a name of with<PluginFunctionality>
, meaning that a plugin for, say, audio compression, might be called withAudioCompression
.
In pratice, a config plugin is added to an Expo project by adding it to the plugins
array in the app.json
file. This might look something like the following:
{
"plugins": ["./with-audio-compression.js"]
}
Next, we'll take a look at writing a config plugin.
Writing a Config Plugin
Let's write our own custom config plugin. To keep things simple, we can use an example of native calendar functionality straight from the React Native documentation. The documentation explains how this can be added to a non-Expo project on Android and iOS.
Our config plugin will let us add this calendar native module to an Expo project using the managed workflow.
More specifically, by the end of this walkthrough we will be able to call CalendarModule.createCalendarEvent("Birthday Party", "My House");
using JavaScript and invoke Objective-C and Java methods, all while remaining 100% within the Expo managed workflow.
Let's get started!
Note Usually I would use TypeScript for this kind of thing, but for this example I'll stick to JavaScript to keep it accessible to a wider audience.
Project Setup
First, let's create a new Expo managed project:
# Initialise project
npx create-expo-app <your-project-name>
# Move to project folder
cd <your-project-name>
# Install dependencies
yarn
# Install config plugins package
yarn add @expo/config-plugins
At this point the project should have been created, so we can start adding our custom config plugin.
Adding the Config Plugin
First, let's create a a plugins directory in the root of our project and add three files to it:
mkdir plugins
touch ./plugins/with-calendar.js
touch ./plugins/with-ios-calendar.js
touch ./plugins/with-android-calendar.js
In the files specific to android and ios, let's simply add some placeholder code that will accept the current Expo config and return it without making any changes:
// with-ios-calendar.js
const withIosCalendar = (config) => config;
module.exports = withIosCalendar;
// with-android-calendar.js
const withAndroidCalendar = (config) => config;
module.exports = withAndroidCalendar;
In with-calendar.js
, we can combine our iOS and Android plugins and export them as follows:
// with-calendar.js
const { withPlugins } = require("@expo/config-plugins");
const withAndroidCalendar = require("./with-android-calendar");
const withIosCalendar = require("./with-ios-calendar");
const withCalendar = (config) => {
return withPlugins(config, [withAndroidCalendar, withIosCalendar]);
};
module.exports = withCalendar;
We'll add proper functionality to the iOS and Android files later.
Note These examples use "module.exports" because we are working with files locally rather than plugins pulled from published NPM modules.
Finally, we can update our app.json
so that Expo can use our new config plugin while building:
// app.json
{
"expo": {
"name": "config-plugin-js-test",
"slug": "config-plugin-js-test",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"plugins": ["./plugins/with-calendar"],
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"updates": {
"fallbackToCacheTimeout": 0
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#FFFFFF"
}
},
"web": {
"favicon": "./assets/favicon.png"
}
}
}
As a sanity check, we can now run expo prebuild
just to check whether the project can be built successfully.
Configuring the iOS Calendar Module
If we want to add the calendar module to our project, we'll need to create a folder to store the native code in. We'll also need to add two new Objective-C files: a header file and an implementation file.
mkdir -p ./plugins/native/ios
touch ./plugins/native/ios/CalendarModule.h
touch ./plugins/native/ios/CalendarModule.m
Let's add the two Objective-C files here. Remember that we care more about how to connect the native code to our Expo project than the implementation of that native code, so let's just copy these straight from the React Native docs:
// CalendarModule.h
#import <React/RCTBridgeModule.h>
@interface CalendarModule : NSObject <RCTBridgeModule>
@end
// CalendarModule.m
#import "CalendarModule.h"
#import <React/RCTLog.h>
@implementation CalendarModule
RCT_EXPORT_MODULE();
RCT_EXPORT_METHOD(createCalendarEvent:(NSString *)title location:(NSString *)location callback: (RCTResponseSenderBlock)callback)
{
NSNumber *eventId = [NSNumber numberWithInt:123];
callback(@[[NSNull null], eventId]);
}
@end
This native module is very simple; it exposes a single method which accepts two string parameters and a callback, and logs a string using RCTLogInfo
.
Right, so now that we've got these two native files, let's turn our attention back to with-ios-calendar.js
.
If we look at the React Native documentation again, we can see that in a traditional native project we would need to add native Objective-C
files to XCode manually. As we're using Expo, this isn't possible, so we'll need to access the XCode project programmatically using some of the methods found in the @expo/config-plugins
package.
// with-ios-calendar.js
/* eslint-disable no-param-reassign */
const { withXcodeProject, IOSConfig } = require("@expo/config-plugins");
const fs = require("fs");
/** Relative to root of project */
const PATH_TO_IOS_CODE = "./plugins/native/ios";
const IOS_FILES = ["CalendarModule.h", "CalendarModule.m"];
/* A custom config plugin which adds a Calendar module. */
const withIosCalendar = (config) => {
config = withXcodeProject(config, async (config) => {
return addCalendarNativeModule(config);
});
return config;
};
/** This method adds files to the XCode project and tries to mimic all the linking that XCode does automatically when you add new header and source files manually through the XCode desktop UI.*/
const addCalendarNativeModule = (config) => {
const { modRequest, modResults: project } = config;
const { projectName, platformProjectRoot } = modRequest;
const signableTargets = IOSConfig.Target.findSignableTargets(project);
/** Each ObjC file is copied into the /ios directory and added to the XCode project */
IOS_FILES.forEach((fileName) => {
fs.copyFileSync(
`${PATH_TO_IOS_CODE}/${fileName}`,
`${platformProjectRoot}/${projectName}/${fileName}`
);
IOSConfig.XcodeUtils.addBuildSourceFileToGroup({
filepath: `${projectName}/${fileName}`,
groupName: projectName,
project,
targetUuid:
signableTargets[0]
?.uuid
});
});
const projectPath = `${platformProjectRoot}/${projectName}.xcodeproj/project.pbxproj`;
fs.writeFileSync(projectPath, project.writeSync());
return config;
};
module.exports = withIosCalendar;
In the file shown above, we use the withXcodeProject
method to access the XCode project programmatically and link the two Objective-C
file to it. Similarly, we use the Node.js fs
module to physically move the two files into the /ios
folder of our project.
At this point, we can rerun expo prebuild
and, assuming all goes to plan, the iOS part of this is now complete.
To verify that expo prebuild
works as expected, you can first inspect the ios
directory and check if the Objective-C files appear in the <your-project-name>
folder. Secondly, you can open the workspace file in XCode to verify that the Objective-C files also appear there.
Summary
So far, we have introduced the concept of config plugins and we have set up the iOS side of things. We will look at the Android side, as well as how to integrate the plugin with our application, in Part 2.
Related Articles
Native Modules with Expo Config Plugins: Part 2
In Part 1 of this two-part series on Expo config plugins, we set up a…
July 27th, 2022
Querying Firebase Analytics Data
When getting started with Firebase Analytics, it can be confusing to figure…
March 23rd, 2022
Comparing Next.js and Gatsby's Handling of Data
Next.js and Gatsby are modern frontend frameworks based on React. Typically…
February 8th, 2022