Shoehorning Native View Controllers Into React Native Components

Sometimes, React Native just doesn’t cut it. Building an app in React Native while having strict requirements on the design side of things can be rather interesting. It might not do quite do what you need it to do, or in this particular case, you need to show a third party library in your app’s UI somewhere where you would traditionally be mucking with UIViewController instances and child view controller containment. It may be uncommon to do that with third party libraries, but you may sometimes need to embed a view controller inside a component. We use PSPDFKit to handle PDFs in our app, and this is our technique for doing so that was patterned after figuring out how to do so with other view controllers like AVPlayerViewControllers. It wouldn’t just do to show the PSPDFViewController as a modal, but it had to be inline with other components. PSPDFKit is awesome and has some support for React Native, but we needed to extend that further. This is what our UI in this case looks like:

embedded pspdfkit

As you can see, there’s essentially a header, and then the content view which is an embedded PSPDFViewController, with a swipeable drawer that can be expanded up even more or dismissed downwards. The drawer is done in React Native, as is the header. So, we needed to figure out how to embed a view controller inside another view controller (I mentioned in a prior blog post we stuck with true native navigation) all the while having the sizing and whatnot controlled by React Native.

This is how we did it:

First, we need to define a component that we can use in other components:

// @flow

import React, { Component, PropTypes } from 'react'
import { requireNativeComponent } from 'react-native'

type Props = {
  config: { documentURL: string },
  style?: Object,
}

export default class PDFView extends Component<any, Props, any> {
  render () {
    return <PSPDFView style={this.props.style} {...this.props} />
  }
}

PDFView.propTypes = {
  config: PropTypes.shape({ documentURL: PropTypes.string }).isRequired,
}

const PSPDFView = requireNativeComponent('PSPDFView', PDFView)

This is pretty bare bones. The takeaway from this is that we defined a PDFView component that wraps a native component which is defined elsewhere as a PSPDFView, and pass along the props. For now the props will only be a simple map with a documentURL property. You can take this much further if you want, but this is really all you need to get documents showing.

Now let’s hop over to the native side of things, where the real stuff happens.

First, we need to handle the bridging by creating an Objective-C .m file with the following:

#import <UIKit/UIKit.h>
#import <React/RCTUIManager.h>

@interface PSPDFViewManager : RCTViewManager
@end

@implementation PSPDFViewManager

RCT_EXPORT_MODULE()

- (UIView *)view {
    return [PSPDFView new];
}

RCT_EXPORT_VIEW_PROPERTY(config, NSDictionary *)

@end

This too is rather simple. First, we create a subclass of RCTViewManager and inside the implementation of the class, call the macro RCT_EXPORT_MODULE(). This allows for React to see this class as something that can be imported. We also call the macro RCT_EXPORT_VIEW_PROPERTY with two parameters: the name of the property on the actual view that we will be rendering, and it’s type. The actual view that we render is returned from the -[RCTViewManager view] method which we override - where we simply return an instance of a PSPDFView class.

Next, we need to define our PSPDFView class. Perhaps we shouldn’t use the same prefix the vendor themselves use, but this is for demo purposes. 😛

class PSPDFView: UIView {
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    required init?(coder aDecoder: NSCoder) { fatalError("nope") }
}

And then, we need to add on a couple properties as well as a computed property to make our lives easier:

weak var pdfViewController: PSPDFViewController?

var config: NSDictionary = [:] {
    didSet {
        setNeedsLayout()
    }
}

var documentURL: URL? {
    if let url = config["documentURL"] as? String {
        return URL(fileURLWithPath: url)
    }
    return nil
}

React will be the one to be controlling the sizing of the component and it’s backing view, and will somewhat do so on it’s own terms. So, to get in where we need, we override -[UIView layoutSubviews]:

override func layoutSubviews() {
    super.layoutSubviews()
    
    if pdfViewController == nil {
        embed()
    } else {
        pdfViewController?.view.frame = bounds
    }
}

It may or may not be the correct thing to handle the embed purely off the non-existence of pdfViewController in layoutSubviews, but init was too soon and this was what we got working. ¯\_(ツ)_/¯ Anyways, here’s what embed looks like:

private func embed() {
    guard
        let parentVC = parentViewController,
        let documentURL = documentURL else {
        return
    }
    
    let doc = PSPDFDocument(url: documentURL)
    let vc = PSPDFViewController(document: doc)
    parentVC.addChildViewController(vc)
    addSubview(vc.view)
    vc.view.frame = bounds
    vc.didMove(toParentViewController: parentVC)
    self.pdfViewController = vc
}

Here we grab this view’s parentViewController (which I’ll explain in a bit), and make sure we have a url to a document that we want to show. Then, we create our PSPDFViewController instance, and use view controller containment to embed the view controller. That is all.

Finding the parentViewController of a UIView is somewhat interesting:

extension UIView {
    var parentViewController: UIViewController? {
        var parentResponder: UIResponder? = self
        while parentResponder != nil {
            parentResponder = parentResponder!.next
            if let viewController = parentResponder as? UIViewController {
                return viewController
            }
        }
        return nil
    }
}

There we traverse the responder chain looking for the view controller, and bail when we find it. This was compliments of something we disovered on StackOverflow.

All toghether our PSPDFView class looks like this:

class PSPDFView: UIView {
    
    weak var pdfViewController: PSPDFViewController?
    
    var config: NSDictionary = [:] {
        didSet {
            setNeedsLayout()
        }
    }
    
    var documentURL: URL? {
        if let url = config["documentURL"] as? String {
            return URL(fileURLWithPath: url)
        }
        return nil
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    required init?(coder aDecoder: NSCoder) { fatalError("nope") }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        if pdfViewController == nil {
            embed()
        } else {
            pdfViewController?.view.frame = bounds
        }
    }
    
    private func embed() {
        guard
            let parentVC = parentViewController,
            let documentURL = documentURL else {
            return
        }
        
        let doc = PSPDFDocument(url: documentURL)
        let vc = PSPDFViewController(document: doc)
        parentVC.addChildViewController(vc)
        addSubview(vc.view)
        vc.view.frame = bounds
        vc.didMove(toParentViewController: parentVC)
        self.pdfViewController = vc
    }
}
extension UIView {
    var parentViewController: UIViewController? {
        var parentResponder: UIResponder? = self
        while parentResponder != nil {
            parentResponder = parentResponder!.next
            if let viewController = parentResponder as? UIViewController {
                return viewController
            }
        }
        return nil
    }
}

Well, that’s the gist of it. I’ll probably wrap this all up into a nice pull request for PSPDFKit’s React Native wrapper, unless of course the awesome guys at PSPDFKit see this, and get ‘er done before I do. Again, this technique can be used for more than just displaying PSPDFViewControllers inside a component - we also use the same technique to display AVPlayerViewControllers that are wrapped in React components as well. You can do so with any type of UIViewController that needs to be embeded in a component as long as you are using a native navigation approach. It’s a useful trick that can come in handy sometimes. Have at it.