Brownfield React Native: Integrating navigation with an existing Objective-C/Swift app

Salvatore Randazzo
Life at Paperless Post
5 min readNov 12, 2019
Map flowing into place

Integrating React Native into an existing iOS app can be challenging, especially when it comes to navigation. While there are many frameworks for new apps, a custom solution might be your best bet for getting existing native apps to seamlessly navigate between React Native screens and UIViewControllers.

An example app is available on GitHub: https://github.com/paperlesspost/react-native-brownfield-navigation

A simplified view of how navigation works between native and React Native.

Three years ago we began work on Flyer, a fun and casual way to invite others to your parties and events. Flyer is meant to compliment our existing “cards” product, which while similar in utility, is a different enough product that warrants rewriting almost everything from the ground up. We took this opportunity to explore new technologies across our entire backend, frontend, and mobile stack.

On our backend, we chose Go. For our frontend, we went with React. As you can probably guess by the title, we chose React Native for iOS. We originally built Flyer to be a standalone app, but later decided (for product reasons) it made more sense to integrate it into our existing Paperless Post app.

Fortunately, this is a first class use case of React Native, and adding it into our existing Xcode project was pretty straight forward. With little effort we were able to get our React Native “screens” rendering inside their own ViewControllers in the app. Getting those screens to integrate into our existing navigation stack however was a challenge. React Native does not have a standard navigation library. Frameworks like react-navigation work great, but are fully JS driven and don’t integrate with iOS’s UINavigationController (what our existing app uses). Wix’s react-native-navigation offers “100% native platform navigation” via UINavigationController, and was our preferred choice, but unfortunately it does not support brownfield apps yet. This left us with one option: create our own.

Our most basic use case is this: Any UIViewController should be able to “push” a React-Native-Based-ViewController. Conversely, any React Native screen should be able to push a native ViewController.

We chose to mirror the API for Wix’s react-native-navigation: navigator.push(…) navigator.dismiss(), and were able to re-use parts of code from their repository.

In order to get our ReactNative screens to render in a UIViewController, there are a few basic things we will need to do:

  1. Create a shared/global instance of an RCTBridge in our native code. This is an important step if we want to have the same JS context/runtime between ViewController instances.
//  ReactBridge.swift
// PaperlessPost

import Foundation

class ReactBridge: NSObject {
@objc static let shared = ReactBridge()

@objc public lazy var bridge: RCTBridge = {
guard let bridge = RCTBridge(delegate: self, launchOptions: nil) else {
fatalError("Unable to instantiate RCTBridge")
}
return bridge
}()
}

extension ReactBridge: RCTBridgeDelegate {

func sourceURL(for bridge: RCTBridge!) -> URL! {
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index", fallbackResource: nil)
}

func viewForModule(_ moduleName: String, initialProperties: [String : Any]?) -> RCTRootView {
return RCTRootView(bridge: self.bridge, moduleName: moduleName, initialProperties: initialProperties)
}
}

2. We need to expose our React Native screen (a Component) to the native side of the app. To do this, we can use AppRegistry.registerComponent and pass in an id as well as the component.

// screens.js
import { AppRegistry } from 'react-native';
import HomeScreen from ‘./screens/HomeScreen’;

AppRegistry.registerComponent(‘paperlesspost.HomeScreen’, HomeScreen)

3. From our native code, we can now initialize a ViewController and display our HomeScreen in it. For this, we created ReactViewController, a UIViewController subclass that can be initialized with the id of the component.

//  ReactViewController.swift
// PaperlessPost

import UIKit

@objc(ReactViewController)
class ReactViewController: UIViewController {

private let moduleName: String
let reactView: RCTRootView
let initialProps:[String : Any]?

@objc var reactTag: NSNumber {
return reactView.reactTag
}

@objc init(moduleName: String) {
self.moduleName = moduleName
self.reactView = ReactBridge.shared.viewForModule(moduleName, initialProperties: initialProperties)
self.initialProps = initialProperties
super.init(nibName: nil, bundle: nil)
reactView.setReactViewController(self)
}

required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
NavigationEventEmitter.globalNavigation()?.publishScreenChangeEvent(.viewWillAppear, rootTag: self.reactTag)
// Natively handle hiding showing nav bar so it is immediate.
if let navBarProps = self.initialProps?["navigationBar"] as? [String: Any], let hidden = navBarProps["hidden"] as? Bool {
let animated = navBarProps["animated"] as? Bool ?? false
self.navigationController?.setNavigationBarHidden(hidden, animated: animated)
}
}

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
NavigationEventEmitter.globalNavigation()?.publishScreenChangeEvent(.viewDidAppear, rootTag: self.reactTag)
}

override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
NavigationEventEmitter.globalNavigation()?.publishScreenChangeEvent(.viewWillDisappear, rootTag: self.reactTag)
}

override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
NavigationEventEmitter.globalNavigation()?.publishScreenChangeEvent(.viewDidDisappear, rootTag: self.reactTag)
}

override func viewDidLoad() {
super.viewDidLoad()

view.backgroundColor = .white

view.addSubview(reactView)

reactView.translatesAutoresizingMaskIntoConstraints = false
reactView.topAnchor.constraint(equalTo: topLayoutGuide.bottomAnchor).isActive = true
reactView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
reactView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
reactView.bottomAnchor.constraint(equalTo: bottomLayoutGuide.topAnchor).isActive = true
}
}

Now that we have registered our React Native screen (a component) and implemented our native ReactNativeViewController, we display our HomeScreen component by doing the following in Native code:

let homeScreenViewController = ReactNativeViewController(moduleName: “paperlesspost.HomeScreen”)

We can now take that ViewController, and either wrap it in a UINavigationController for presentation, or push it onto an existing navigation stack.

Push, Pop, Dismiss and more

Now that our React Native component is up and running in a Native UIViewController, we can start to build some more infrastructure around how it communicates with the UINavigationController.

To do this, we create the following classes:

  • Navigator.js contains a React component Navigator which wraps each screen component, and injects a reference of it self into the screen. On the screen component, the navigator is now accessible via this.props.navigator. This allows you access to the navigator API via this.props.navigator.push(...) for example. This file also exports a function registerComponent which calls AppRegistry.registerComponent with the <Navigator> wrapping up the screen component.
  • NavigatorModule.{h.m} This is a Native Module that is bound to Navigator.js. When this.props.navigator.push(...) is called, it invokes the native module method which connects to the ReactViewController's UINavigationController
  • NavigatorRoute.swift A class object containing meta info about a "route", such as navigation bar button items, navigation bar title and more.
  • NavigationRegistry.swift This class stores references to all of the active ReactViewControllers, which are stored and fetched by a unique tag id created by React.
  • NavigationManager.swift a class that manages the ReactViewController, it's properties and the route.
  • Screens.js exports an object containing "routes", which are objects consisting of an id, a component, and optionally a title and navigation buttons.

Hooking it all up

Create our Screens

// Screens.js

export const SCREENS = {
HomeScreen: {
id: 'pages.HomeScreen',
component: HomeScreen,
title: 'Home'
},
PostboxListScreen: {
id: 'pages.PostboxListScreen',
component: PostboxListScreen,
title: 'Postbox'
}
};

Register our screens with the navigator/native modules

// App.js
import { SCREENS } from './navigation/screens';

Object.keys(allScenes).forEach((key: string) => {
const screen: Route = allScenes[key];
Navigator.registerComponent(screen, store, Provider);
});

From within one of our components, we can now push another screen onto the navigator stack.

// HomeScreen.js

import { SCREENS } from './navigation/screens';

...

onNextPress() {
this.props.navigator.push(...SCREENS.PostboxListScreen)
}

This concludes our post on how we integrate React Native with our existing navigation stack. We’ve so far found it easy to extend and add new functionality. As a reminder, you can see a demo app here: https://github.com/paperlesspost/react-native-brownfield-navigation

If this or anything else React Native interests you, we’re hiring! https://www.paperlesspost.com/about/jobs/

--

--