Module boilerplate
Create package structure
Package boilerplate
package.json
Let's start by creating app-info-package
directory (in project's root) containing package's code.
Inside that directory, let's create a package.json
file with the following content:
{
"private": true,
"name": "app-info-package",
"version": "0.0.1",
"description": "My awesome package",
"react-native": "src",
"source": "src",
"main": "src",
"module": "src",
"files": [
"src",
"android",
"ios",
"AppInfoPackage.podspec",
"!android/build",
"!ios/build",
"!**/__tests__",
"!**/__fixtures__",
"!**/__mocks__"
],
"repository": "<repository-url>",
"author": "<author>",
"license": "MIT",
"homepage": "<homepage-url>",
"peerDependencies": {
"react": "*",
"react-native": "*"
},
"codegenConfig": {
"name": "AppInfoPackage",
"type": "all",
"jsSrcsDir": "src",
"android": {
"javaPackageName": "com.appinfopackage"
}
}
}
For codegenConfig
, you can check out RN's Configure Codegen docs section.
In this case, we want to code-generate the package with name AppInfoPackage
with JS specs in src
directory and com.appinfopackage
Android package name.
AppInfoPackage.podspec
Next, let's create AppInfoPackage.podspec
, the "equivalent" of package.json
, but for CocoaPods packages:
# `.podspec` file is like "`package.json`" for iOS CocoaPods packages
require "json"
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
# Detect if new arch is enabled
new_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1'
Pod::Spec.new do |s|
s.name = "AppInfoPackage"
s.version = package["version"]
s.summary = package["description"]
s.description = package["description"]
s.homepage = package["homepage"]
s.license = package["license"]
s.platforms = { :ios => "13.0" }
s.author = package["author"]
s.source = { :git => package["repository"], :tag => "#{s.version}" }
# This is crucial - declare which files will be included in the package (similar to "files" field in `package.json`)
s.source_files = "ios/**/*.{h,m,mm,swift}"
if new_arch_enabled
s.pod_target_xcconfig = {
"DEFINES_MODULE" => "YES",
"SWIFT_OBJC_INTERFACE_HEADER_NAME" => "AppInfoPackage-Swift.h",
# This is handy when we want to detect if new arch is enabled in Swift code
# and can be used like:
# #if APP_INFO_PACKAGE_NEW_ARCH_ENABLED
# // do sth when new arch is enabled
# #else
# // do sth when old arch is enabled
# #endif
"OTHER_SWIFT_FLAGS" => "-DAPP_INFO_PACKAGE_NEW_ARCH_ENABLED"
}
else
s.pod_target_xcconfig = {
"DEFINES_MODULE" => "YES",
"SWIFT_OBJC_INTERFACE_HEADER_NAME" => "AppInfoPackage-Swift.h"
}
end
# Install all React Native dependencies (RN >= 0.71 must be used)
#
# check source code for more context
# https://github.com/facebook/react-native/blob/0.71-stable/scripts/react_native_pods.rb#L172#L180
install_modules_dependencies(s)
end
This will link Objective-C, Objective-C++ & Swift files from ios
directory and link iOS RN dependencies.
build.gradle
We also need similar configuration for Android. First let's create android
directory and then under that directory create build.gradle
file.
- Groovy script (build.gradle)
- Kotlin script (build.gradle.kts)
buildscript {
ext.safeExtGet = {prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
def kotlin_version = safeExtGet('kotlinVersion', '1.6.10') // Mandatory, if you will use Kotlin
repositories {
google()
gradlePluginPortal()
}
dependencies {
classpath("com.android.tools.build:gradle:7.2.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") // Mandatory, if you will use Kotlin
}
}
def isNewArchitectureEnabled() {
return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
}
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android' // Mandatory, if you will use Kotlin
if (isNewArchitectureEnabled()) {
apply plugin: "com.facebook.react"
}
android {
compileSdkVersion safeExtGet('compileSdkVersion', 33)
namespace "com.appinfopackage"
defaultConfig {
minSdkVersion safeExtGet('minSdkVersion', 21)
targetSdkVersion safeExtGet('targetSdkVersion', 33)
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
}
sourceSets {
main {
if (isNewArchitectureEnabled()) {
java.srcDirs += ['src/newarch/java', "${project.buildDir}/generated/source/codegen/java"]
} else {
java.srcDirs += ['src/oldarch/java']
}
}
}
}
repositories {
maven {
url "$projectDir/../node_modules/react-native/android"
}
mavenCentral()
google()
}
apply from: "$projectDir/react-native-helpers.gradle"
dependencies {
if (project.ext.shouldConsumeReactNativeFromMavenCentral()) {
implementation "com.facebook.react:react-android" // Set by the React Native Gradle Plugin
} else {
implementation 'com.facebook.react:react-native:+' // From node_modules
}
}
buildscript {
val safeExtGet by extra {
fun(prop: String, fallback: Any): Any? {
return when (rootProject.extra.has(prop)) {
true -> rootProject.extra.get(prop)
else -> fallback
}
}
}
val kotlin_version = safeExtGet("kotlinVersion", "1.6.10")
repositories {
google()
gradlePluginPortal()
}
dependencies {
classpath("com.android.tools.build:gradle:7.2.1")
classpath(kotlin("gradle-plugin", version = "$kotlin_version"))
}
}
fun isNewArchitectureEnabled(): Boolean {
if (!project.hasProperty("newArchEnabled")) {
return false
}
val newArchEnabled: String by project
return newArchEnabled == "true"
}
plugins {
id("com.android.library")
id("kotlin-android")
}
if (isNewArchitectureEnabled()) {
apply(plugin = "com.facebook.react")
}
android {
val safeExtGet: (prop: String, fallback: Any) -> Any? by project.extra
compileSdk = safeExtGet("compileSdkVersion", 33) as Int?
namespace = "com.appinfopackage"
defaultConfig {
minSdk = safeExtGet("minSdkVersion", 21) as Int?
targetSdk = safeExtGet("targetSdkVersion", 33) as Int?
buildConfigField("boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString())
}
sourceSets {
named("main") {
if (isNewArchitectureEnabled()) {
java.srcDirs(listOf("src/newarch/java", "${project.buildDir}/generated/source/codegen/java"))
} else {
java.srcDirs(listOf("src/oldarch/java"))
}
}
}
}
repositories {
maven(
"$projectDir/../node_modules/react-native/android"
)
mavenCentral()
google()
}
apply(from = "$projectDir/react-native-helpers.gradle.kts")
val shouldConsumeReactNativeFromMavenCentral: () -> Boolean by project.extra
dependencies {
if (shouldConsumeReactNativeFromMavenCentral()) {
implementation("com.facebook.react:react-android") // Set by the React Native Gradle Plugin
} else {
implementation("com.facebook.react:react-native:+") // From node_modules
}
}
This will link Java & Kotlin files under android/src/{main|newarch|oldarch}/java/com/<packagename>
as well as code-generated files under android/build/generated/source/codegen/java/com/<packagename>
(combined values declared under namespace
and sourceSets
).
You may have noticed, that the configuration loads sth from $projectDir/react-native-helpers.gradle
. In fact that file includes implementation for shouldConsumeReactNativeFromMavenCentral
helper. Let's create it:
- Groovy script (react-native-helpers.gradle)
- Kotlin script (react-native-helpers.gradle.kts)
def safeAppExtGet(prop, fallback) {
def appProject = rootProject.allprojects.find { it.plugins.hasPlugin('com.android.application') }
appProject?.ext?.has(prop) ? appProject.ext.get(prop) : fallback
}
// Let's detect react-native's directory, it will be used to determine RN's version
// https://github.com/software-mansion/react-native-reanimated/blob/cda4627c3337c33674f05f755b7485165c6caca9/android/build.gradle#L88
def resolveReactNativeDirectory() {
def reactNativeLocation = safeAppExtGet("REACT_NATIVE_NODE_MODULES_DIR", null)
if (reactNativeLocation != null) {
return file(reactNativeLocation)
}
// monorepo workaround
// react-native can be hoisted or in project's own node_modules
def reactNativeFromProjectNodeModules = file("${rootProject.projectDir}/../node_modules/react-native")
if (reactNativeFromProjectNodeModules.exists()) {
return reactNativeFromProjectNodeModules
}
def reactNativeFromNodeModules = file("${projectDir}/../../react-native")
if (reactNativeFromNodeModules.exists()) {
return reactNativeFromNodeModules
}
throw new GradleException(
"[app-info-package] Unable to resolve react-native location in " +
"node_modules. You should project extension property (in app/build.gradle) " +
"`REACT_NATIVE_NODE_MODULES_DIR` with path to react-native."
)
}
// https://github.com/software-mansion/react-native-reanimated/blob/cda4627c3337c33674f05f755b7485165c6caca9/android/build.gradle#L199#L205
def reactNativeRootDir = resolveReactNativeDirectory()
def reactProperties = new Properties()
file("$reactNativeRootDir/ReactAndroid/gradle.properties").withInputStream { reactProperties.load(it) }
def REACT_NATIVE_VERSION = reactProperties.getProperty("VERSION_NAME")
def REACT_NATIVE_MINOR_VERSION = REACT_NATIVE_VERSION.startsWith("0.0.0-") ? 1000 : REACT_NATIVE_VERSION.split("\\.")[1].toInteger()
project.ext.shouldConsumeReactNativeFromMavenCentral = { ->
return REACT_NATIVE_MINOR_VERSION >= 71
}
fun safeAppExtGet(prop: String, fallback: Any?): Any? {
val appProject = rootProject.allprojects.find { it.plugins.hasPlugin("com.android.application") }
return when (appProject?.extra?.has(prop)) {
true -> appProject.extra.get(prop)
else -> fallback
}
}
// Let"s detect react-native"s directory, it will be used to determine RN"s version
// https://github.com/software-mansion/react-native-reanimated/blob/cda4627c3337c33674f05f755b7485165c6caca9/android/build.gradle#L88
fun resolveReactNativeDirectory(): File {
val reactNativeLocation = safeAppExtGet("REACT_NATIVE_NODE_MODULES_DIR", null)
if (reactNativeLocation != null) {
return File(reactNativeLocation as String)
}
// monorepo workaround
// react-native can be hoisted or in project"s own node_modules
val reactNativeFromProjectNodeModules = File("${rootProject.projectDir}/../node_modules/react-native")
if (reactNativeFromProjectNodeModules.exists() == true) {
return reactNativeFromProjectNodeModules
}
val reactNativeFromNodeModules = File("${projectDir}/../../react-native")
if (reactNativeFromNodeModules.exists() == true) {
return reactNativeFromNodeModules
}
throw GradleException(
"[app-info-package] Unable to resolve react-native location in " +
"node_modules. You should project extension property (in app/build.gradle) " +
"`REACT_NATIVE_NODE_MODULES_DIR` with path to react-native."
)
}
// https://github.com/software-mansion/react-native-reanimated/blob/cda4627c3337c33674f05f755b7485165c6caca9/android/build.gradle#L199#L205
val reactNativeRootDir = resolveReactNativeDirectory()
val reactProperties = java.util.Properties()
java.io.InputStreamReader(
java.io.FileInputStream(File("$reactNativeRootDir/ReactAndroid/gradle.properties")),
Charsets.UTF_8
).use { reactProperties.load(it) }
val REACT_NATIVE_VERSION: String = reactProperties.getProperty("VERSION_NAME")
val REACT_NATIVE_MINOR_VERSION = if (REACT_NATIVE_VERSION.startsWith("0.0.0-")) {
1000
} else {
REACT_NATIVE_VERSION.split(Regex("\\."))[1].toInt()
}
val shouldConsumeReactNativeFromMavenCentral by extra {
fun(): Boolean {
return REACT_NATIVE_MINOR_VERSION >= 71
}
}
Wow, lots of code just to detect used RN version. Fortunately, you will only have to write it once.
If you are curious, this code tries to locate react-native/ReactAndroid/gradle.properties
file, where VERSION_NAME
property is saved. Then it tries to retrieve minor part and use it in shouldConsumeReactNativeFromMavenCentral
.
That function checks if minor version is greater or equal than 71 (the first version with RN's Android code published to MavenCentral - sth like npm, but for Android packages). The function is declared as project.ext
/by extra
,
which makes it possible to use that in other Gradle script file.
Source files stubs
Finally, let's create stubs for source files. For JS, create following empty files:
src/index.ts
src/NativeAppInfoModule.ts
For iOS implementation, create:
- ObjC++ & Swift
- ObjC++ only
ios/AppInfoModule.h
ios/AppInfoModule.mm
ios/AppInfoModuleImpl.swift
ios/AppInfoPackage-Bridging-Header.h
ios/AppInfoModule.h
ios/AppInfoModule.mm
ios/AppInfoModuleImpl.h
ios/AppInfoModuleImpl.mm
For Android implementation, create:
- Kotlin
- Java
android/src/main/java/com/appinfopackage/AppInfoModuleImpl.kt
android/src/main/java/com/appinfopackage/AppInfoTurboPackage.kt
android/src/newarch/java/com/appinfopackage/AppInfoModule.kt
android/src/oldarch/java/com/appinfopackage/AppInfoModule.kt
To make the codegen work on Android, the instance of TurboReactPackage
needs to be found by the React Native CLI. So let's fill the minimal boilerplate to make it possible to link the package.
package com.appinfopackage
import com.facebook.react.TurboReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
import com.facebook.react.turbomodule.core.interfaces.TurboModule
class AppInfoTurboPackage : TurboReactPackage() {
/**
* Initialize and export modules based on the name of the required module
*/
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
return null
}
/**
* Declare info about exported modules
*/
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
/**
* Here declare the array of exported modules
*/
val moduleList: Array<Class<out NativeModule?>> = arrayOf(
)
val reactModuleInfoMap: MutableMap<String, ReactModuleInfo> = HashMap()
/**
* And here just iterate on that array and produce the info provider instance
*/
for (moduleClass in moduleList) {
val reactModule = moduleClass.getAnnotation(ReactModule::class.java) ?: continue
reactModuleInfoMap[reactModule.name] =
ReactModuleInfo(
reactModule.name,
moduleClass.name,
true,
reactModule.needsEagerInit,
reactModule.hasConstants,
reactModule.isCxxModule,
TurboModule::class.java.isAssignableFrom(moduleClass)
)
}
return ReactModuleInfoProvider { reactModuleInfoMap }
}
}
android/src/main/java/com/appinfopackage/AppInfoModuleImpl.java
android/src/main/java/com/appinfopackage/AppInfoTurboPackage.java
android/src/newarch/java/com/appinfopackage/AppInfoModule.java
android/src/oldarch/java/com/appinfopackage/AppInfoModule.java
To make the codegen work on Android, the instance of TurboReactPackage
needs to be found by the React Native CLI. So let's fill the minimal boilerplate to make it possible to link the package.
package com.appinfopackage;
import androidx.annotation.Nullable;
import com.facebook.react.TurboReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.module.annotations.ReactModule;
import com.facebook.react.module.model.ReactModuleInfo;
import com.facebook.react.module.model.ReactModuleInfoProvider;
import com.facebook.react.turbomodule.core.interfaces.TurboModule;
import java.util.HashMap;
import java.util.Map;
public class AppInfoTurboPackage extends TurboReactPackage {
/**
* Initialize and export modules based on the name of the required module
*/
@Override
@Nullable
public NativeModule getModule(String name, ReactApplicationContext reactContext) {
return null;
}
/**
* Declare info about exported modules
*/
@Override
public ReactModuleInfoProvider getReactModuleInfoProvider() {
/**
* Here declare the array of exported modules
*/
Class<? extends NativeModule>[] moduleList = new Class[] {
};
final Map<String, ReactModuleInfo> reactModuleInfoMap = new HashMap<>();
/**
* And here just iterate on that array and produce the info provider instance
*/
for (Class<? extends NativeModule> moduleClass : moduleList) {
ReactModule reactModule = moduleClass.getAnnotation(ReactModule.class);
reactModuleInfoMap.put(
reactModule.name(),
new ReactModuleInfo(
reactModule.name(),
moduleClass.getName(),
true,
reactModule.needsEagerInit(),
reactModule.hasConstants(),
reactModule.isCxxModule(),
TurboModule.class.isAssignableFrom(moduleClass)
)
);
}
return new ReactModuleInfoProvider() {
@Override
public Map<String, ReactModuleInfo> getReactModuleInfos() {
return reactModuleInfoMap;
}
};
}
}
Link the package with the app
To link the package without adding it to node_modules, we will use react-native.config.js
react-native.config.js
Go to react-native.config.js
(create it, if needed) inside root directory of your app and add app-info-package
under dependencies
property: const path = require('path');
module.exports = {
dependencies: {
'app-info-package': { // <--------- Add entry for "app-info-package"
root: path.resolve(__dirname, './app-info-package'),
},
},
};
babel.config.js
Go to babel.config.js
and add app-info-package
under alias
property of babel-plugin-module-resolver
: module.exports = {
presets: [ 'module:metro-react-native-babel-preset' ],
plugins: [
[
'module-resolver',
{
root: [ './' ],
extensions: [
'.ios.js',
'.ios.ts',
'.ios.tsx',
'.android.js',
'.android.ts',
'.android.tsx',
'.js',
'.ts',
'.tsx',
'.json',
],
+ alias: {
+ 'app-info-package': './app-info-package'
+ }
},
],
],
};
tsconfig.json
Go to tsconfig.json
and add app-info-package
under compilerOptions.paths
field:{
"extends": "@tsconfig/react-native/tsconfig.json", /* Recommended React Native TSConfig base */
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Completeness */
"skipLibCheck": true, /* Skip type checking all .d.ts files. */
+ "paths": {
+ "app-info-package": ["./app-info-package"],
+ }
}
}
[iOS] Pods installation
Install Pods to link them to iOS project.
npx pod-install
Specs satisfying the AppInfoPackage (from "../app-info-package") dependency were found, but they required a higher minimum deployment target.
To fix it, go to project's Podfile at
<rootDir>/ios/Podfile
and change platform :ios, min_ios_version_supported
to platform :ios, '13.0'
Setup completed. Now, let's start coding with the JS spec.