DEV Community

Alan Allard for Eyevinn Video Dev-Team Blog

Posted on

Building React Native UI Components and Modules in 2021

Intro

React Native has been around for some time now. And as many of us know, one of the advantages of the platform are the many ready-made components available for use. Another is of course, the other side of that same coin - that you can build your own components for those more specific needs that so often arise in a project.

In a recent assignment, I had occasion to build a wrapper around two separate proprietary video players for iOS and Android respectively. The players in question were mature and well-built but the interfaces had led their own lives, so to speak, and were quite different to each other - due mostly to the platform-specific differences. Also, the team that were intending to use the component in their React Native project had specific needs of their own, not necessarily met by the existing components, these having been developed previously for other apps with different requirements.

This is a fairly common scenario when building a React Native component wrapper, where the development needs to occur across several different teams - in this case, the app team, the iOS team and the android team. Also, while the official React Native documentation is ever-improving in this area, there are still large gaps that you will often come across as soon as you dig deeper into component development. In this article, based on my recent work in the area, I will discuss some useful tools and processes as well as some of the undocumented techniques that you will often want to use when wrapping platform-specific components.

I will also mention some interesting and radical changes in the codebase that will become the new way to develop React Native components in future.

Creating the project

A typical React Native component project will benefit from having a simple example project embedded into the repo. This can simplify development of the component as well as provide a decent demo of what it can do when it's ready for publishing. In our case, we also wanted typescript set up and ready to go. There is usually a fair amount of boilerplate code needed on both the native sides and in javascript. Given all of this, I chose to generate the component project with a tool by Callstack, called create-react-native-library. This is a CLI that will create a project for you that meets all the above demands (and more): image

There is of course the usual caveat that, as with any wizard-type tool, you will want to at least have some idea of what it's doing behind the scenes. And most likely you will need to tweak your typescript and linting setup according to what you need. As you can see from the above GIF though, another plus is that you also have the opportunity to choose which languages to set up your boilerplate for. As Android has both Java and Kotlin available for React Native development and iOS has both Objective-C and Swift available, you can really save some time here and fit smoothly in to whatever project you're building for.

To get up and running with create-react-native-library is as simple as:

npx create-react-native-library your-new-project
Enter fullscreen mode Exit fullscreen mode

This will give you iOS and Android modules in the languages of your choice and a fully functional embedded example project, so you can just focus on building out what you need.

Integrating your platform-specific packages

The next step is to get your project building with the packages you intend to provide a wrapper around. Perhaps you are only wrapping a component for a specific platform, or two platform-specific components from the same vendor or perhaps - as in the case of my most recent project - two separate platform-specific components that do not even share the same common interface, beyond a more general use-case. If you are developing for both platforms here, there will be some focus later on on developing a common interface.

This step is where you will need to work in the platform-specific part of the generated project - in XCode or Android Studio typically. As these components are designed specifically for integration into other projects, you will likely simply need to follow the iOS or Android guides for the component in question. In my case, it was necessary to collect some information from the respective development teams, as there were new use-cases planned for the React Native component (for example, videos were going to be expected to play in a newsfeed-style list instead of fullscreen). The example project can be very useful at this stage, to show that each integration is working properly in its most basic form. In my case, this meant showing and autoplaying on focus a list of videos in both platforms.

Module or UI Component

You may need to build one or both of the two possible types of component in React Native - a module or a UI component. Modules are good wrappers for API components, while components or packages with a user interface need of course to be UI Components.

Here, I set out to build a UI Component for each platform with the understanding that certain parts of the project may benefit from being separated from the UI as an API module. In fact, while this may be planned for the future, what actually happened was that it turned out that the only way to get the functionality we were after in Android was to provide both a module part and a UI component part in the same package.

View controller vs view

When wrapping a UI component in iOS, you will quite often find that the component you are wrapping makes use of a view controller. In a React UI Component the RCTViewManager (and more often its SimpleViewManager descendent) is responsible for all instances of the component in your app. The thing is the view manager manages views (obviously) and not view controllers. To deal with this, you need to attach the view controller's view to the component's view (as a subview). That is done in the following way:

In the view you will be providing to the view manager, add a variable of the type of the view controller you want to use:

final class RNPlayerView: UIView {
    var viewController: RNPlayerViewController
    ...(rest of class here)...

Enter fullscreen mode Exit fullscreen mode

Init that view controller var in your view and add the view controller's view as a subview:

    override init(frame: CGRect) {
        self.viewController = RNPlayerViewController.init()
        super.init(frame: frame)
        self.addSubview(viewController.view)
    }
Enter fullscreen mode Exit fullscreen mode

Finally, make sure that the view controller's view matches the view's dimensions. In your view class:

    override func layoutSubviews() {
        super.layoutSubviews()
        self.viewController.view.frame = self.bounds
    }
Enter fullscreen mode Exit fullscreen mode

That is all you need.

Properties and methods in iOS

It's worth recapping that the view mentioned in the last section is where you will typically handle all of the properties that you expect your component to expose in react native. If you are using a view controller as above most, or probably all, of these properties (apart from any event callbacks you may be handling) will need forwarding to the view controller.

A colleague of mine and I came up with a fairly tidy way to handle this.

Take for example an assetId property for a video player UI component.

Provide the usual react native property setter:

    @objc func setAssetId(_ val: String) {
        assetId = val
    }
Enter fullscreen mode Exit fullscreen mode

Then respond to changes in the variable:


    var assetId: String? = nil {
        didSet {
            self._updateAsset()
        }
    }
Enter fullscreen mode Exit fullscreen mode

And finally, update the variable in your view controller:

    @objc func _updateAsset() {
        if assetId != nil {
            self.viewController.updateAssetId(assetId: assetId!)
        }
    }
Enter fullscreen mode Exit fullscreen mode

The nice thing is that this adapts well to the situation where you have several properties that need setting together in a group. This is fairly common in the components I have developed, where you might, for example, have a "setup" or "config" function in your component that takes several parameters, and if any one of those parameters changes, the function should be called anew. The above pattern allows you to group variables together before sending them off to the view controller in question:

1.

    @objc func setParam1(_ val: String) {
        param1 = val
    }

    @objc func setParam2(_ val: String) {
        param2 = val
    }
Enter fullscreen mode Exit fullscreen mode

2.

    var param1: String? = nil {
        didSet {
            self._updateParams()
        }
    }

    var param2: String? = nil {
        didSet {
            self._updateParams()
        }
    }
Enter fullscreen mode Exit fullscreen mode

3.

    @objc func _updateParams() {
        if param1 != nil && param2 != nil {
            self.viewController.updateParams(param1: param1!, param2: param2!))
        }
    }
Enter fullscreen mode Exit fullscreen mode

Simple and maintainable.

You will often want to react to events in your component on the react native side. The official docs have an objective-c example, but we prefer to operate in Swift, given the choice ;) Your view should have an obj-c property for each event you are responding to:

    @objc var onReady: RCTDirectEventBlock?
    @objc var onError: RCTDirectEventBlock?
Enter fullscreen mode Exit fullscreen mode

And a method for each:

    func sendOnReadyToRN() {
        if onReady != nil {
            onReady!(["Ready": "Ready"])
        }
    }

    func sendOnErrorToRN(error: String) {
        if onError != nil {
            onError!(["Error": error])
        }
    }
Enter fullscreen mode Exit fullscreen mode

and you will typically have some switch statement or similar in your view controller that calls back to these view methods:

    private func eventMonitoring() {
        self.uiComponent
            .onError{  [weak self] uiComponent, source, error in
                let errorMsg = "uiComponent Error : \(error.message)"
                self?.sendErrorToView(error: errorMsg)
            }

            .onReady{ [weak self] uiComponent, source in
                self?.sendOnReadyToView()
            }
    }

    private func sendErrorToView(error: String) {
        let componentView = self.view.reactSuperview() as! ComponentView
        componentView.sendErrorToRN(error: error)
    }


    private func sendOnReadyToView() {
        let componentView = self.view.reactSuperview() as! ComponentView
        componentView.sendPlaybackReadyToRN()
    }

Enter fullscreen mode Exit fullscreen mode

Finally on the iOS/tvOS side, you will want to know how to call methods on your component. In your ViewManager class:

    @objc func start(_ node: NSNumber) {
        DispatchQueue.main.async {
            let componentView = self.bridge.uiManager.view(forReactTag: node) as! ComponentView;
            componentView.viewController.component?.start();
        }
    }

    @objc func stop(_ node: NSNumber) {
        DispatchQueue.main.async {
            let componentView = self.bridge.uiManager.view(forReactTag: node) as! ComponentView;
            componentView.viewController.component?.stop();
        }
    }
Enter fullscreen mode Exit fullscreen mode

See below for an idea of what the javascript side could look like.

Setup iOS/tvOS/Android

Occasionally, it's necessary to configure a component before viewing it. One way to make that possible is to add a method similar to the start/stop methods above (for iOS/tvOS), but containing several parameters, which can then be consumed in the view controller or the view as required.

In Android, you are not immediately able to call such a setup method on a view component. Instead you will need to implement a small Android native module that forwards the necessary setup info to the view component (or at least, this is one way to do it that we successfully implemented). This might look something like this (in Kotlin, also our language of choice...):


class RNComponentSetupManager(reactContext: ReactApplicationContext, viewManager: RNComponentViewManager) : ReactContextBaseJavaModule(reactContext) {

  var viewManager: RNComponentViewManager = viewManager

  override fun getName(): String {
    return "RNComponentSetupManager"
  }

  @ReactMethod
  fun setup(param1: String, param2: String) {
    viewManager.setup(param1, param2)
  }
}

Enter fullscreen mode Exit fullscreen mode

Then we can set up our ReactPackage class to initialise either the view manager or the setup manager, whichever is called first:

lass RNComponentPackage : ReactPackage {

  inner class Modules(val setupManager: RNComponentSetupManager, val viewManager: RNComponentViewManager) {
  }

  var modules: Modules? = null

  private fun getInitiatedModules(reactContext: ReactApplicationContext) : Modules {
    val currentModules = modules
    return if(currentModules != null) {
      currentModules
    } else {
      val viewManager = RNComponentViewManager(reactContext)
      val setupManager = RNComponentSetupManager(reactContext, viewManager) //<-- Send in viewManager so we can use it.
      val initializedModules = Modules(setupManager, viewManager)
      this.modules = initializedModules
      initializedModules
    }
  }

  override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
    return listOf(getInitiatedModules(reactContext).setupManager)
  }

  override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
    return listOf(getInitiatedModules(reactContext).viewManager)
  }
}

Enter fullscreen mode Exit fullscreen mode

Other properties and methods in Android

Setting properties is a little simpler in Android, where there is no view controller as such.

  @ReactProp(name = "assetId")
  fun setAssetId(view: View, assetId: String) {
    val componentView = view as ComponentView
    componentView.setAssetId(assetId)
  }
Enter fullscreen mode Exit fullscreen mode

and in your view class, handle that as necessary.

For methods on the view, you will need to override the receiveCommand method, the equivalent of the two iOS/tvOS methods above looking like this in the view manager:

  override fun receiveCommand(root: View, commandId: String?, args: ReadableArray?) {
    when (commandId) {
      "startFromManager" -> start(root)
      "stopFromManager" -> stop(root)
    }
  }

  private fun start(view: View) {
    val componentView = view as ComponentView
    componentView.start()
  }

  private fun stop(view: View) {
    val componentView = view as ComponentView
    componentView.stop()
  }
Enter fullscreen mode Exit fullscreen mode

And similarly in the view, handle those start() and stop() commands within your native component as appropriate.

Javascript side

Finally, the typescript for our theoretical component would look something like this...notice in particular the way we handle the typing of iOS/tvOS and Android setup/view managers.

import {
    requireNativeComponent,
    ViewStyle,
    UIManager,
    findNodeHandle,
    Platform,
    NativeModules,
  } from 'react-native';

  import * as React from 'react';
  import {
    useRef,
    useImperativeHandle,
    forwardRef,
    SyntheticEvent,
  } from 'react';

  export interface RNComponentManagerProps {
    assetId: string;
    style: ViewStyle;
    start(): void;
    stop(): void;
    onReady(event: SyntheticEvent): void;
    onError(event: SyntheticEvent): void;
  }

  interface RNComponentProps {
    assetId: string;
    style: ViewStyle;
    onReady?(): void;
    onError?(error: String): void;
  }

  interface RNComponentSetupManagerProps {
    setup(
      param1: string,
      param2: string
    ): void;
  }

  export const RNComponentSetupManager =
    Platform.OS === 'ios'
      ? (NativeModules.RNComponentViewManager as RNComponentSetupManagerProps)
      : (NativeModules.RNComponentSetupManager as RNComponentSetupManagerProps);

  const RNComponentViewManager = requireNativeComponent<RNComponentManagerProps>(
    'RNComponentView'
  );

  const RNComponent = forwardRef((props: RNComponentProps, extRef) => {
    useImperativeHandle(extRef, () => ({
      stop,
      start
    }));
    const componentRef = useRef(null);


    const start = () => {
      var command = 'startFromManager';
      if (componentRef && componentRef.current) {
        UIManager.dispatchViewManagerCommand(
          findNodeHandle(componentRef.current),
          // @ts-ignore: Issue in RN ts defs 
          Platform.OS === 'ios'
            ? UIManager.getViewManagerConfig('RNComponentView').Commands
                .startFromManager
            : command,
          undefined
        );
      }
    };

    const stop = () => {
      var command = 'stopFromManager';
      if (componentRef && componentRef.current) {
        UIManager.dispatchViewManagerCommand(
          findNodeHandle(componentRef.current),
          // @ts-ignore: Issue in RN ts defs 
          Platform.OS === 'ios'
            ? UIManager.getViewManagerConfig('RNComponentView').Commands
                .stopFromManager
            : command,
          undefined
        );
      }
    };

    const getErrorString = (nativeEvent: any) => {
      let error = '';
      if (nativeEvent.hasOwnProperty('error')) {
        error = nativeEvent.error;
      }
      return error;
    };

    return (
      <RNComponentViewManager
        assetId={props.assetId}
        style={props.style}
        ref={componentRef}
        start={() => start()}
        stop={() => stop()}
        onReady={(_event: SyntheticEvent) =>
          props.onReady && props.onReady()
        }
        onError={(event: SyntheticEvent) =>
          props.onError && props.onError(getErrorString(event.nativeEvent))
        }
      />
    );
  });

  RNComponent.displayName = 'RNComponent';
  export default RNComponent;

Enter fullscreen mode Exit fullscreen mode

Cocoapods tips

As well as being aware of general iOS/tvOS and Android best practices it is as well to be aware of the power of Cocoapods on the iOS/tvOS side. Your iOS/tvOS project will already have a .podfile and you will occasionally have cause to make some specific changes here that can solve issues you may be having.

User defined build types

You may find yourself in the position of having to integrate several different types of third-party tools in order to get a plugin up and running. This may mean that you need to mix dynamic and/or static frameworks and/or libraries in your project. Unfortunately, there is no support for this at present in Cocopods. But, fortunately, there is a Cocopods plugin available that will allow you to do just this! This means you can write things like this in your pod file:

plugin 'cocoapods-user-defined-build-types'

enable_user_defined_build_types!

target "PlayerApp" do
  pod 'PlayerUI', :build_type => :static_framework
  pod 'Player', :build_type => :static_framework
  pod "SwiftyJSON", :build_type => :dynamic_framework
Enter fullscreen mode Exit fullscreen mode

Combining package managers

Occasionally you might end up being forced to use both Cocoapods and another package manager, such as Carthage. It's not actually that complicated but you can get some confusing error messages along the way.

To take Carthage as an example, you can simply set up your Carthage installation in the iOS root directory of the generated plugin according to the standard way of working for Carthage, but be sure to (in your .podspec):

  1. exclude certain Carthage files such as:
  s.exclude_files = "ios/Carthage", "ios/cartfile", "ios/Cartfile.resolved", "ios/carthage.sh"
Enter fullscreen mode Exit fullscreen mode
  1. Make sure that the generated frameworks are indicated in your .podspec:
s.vendored_frameworks = "ios/Frameworks/Player.xcframework"
Enter fullscreen mode Exit fullscreen mode

(Here we have copied the Carthage framework from the build directory to a Frameworks directory in our plugin).

Turbomodules

There are changes coming in React Native that will have a big impact on how to implement native modules...in fact, these changes are already in place to an extent that it is possible to build modules using this technology. At React Native EU this year, a talk was given that described a camera library that utilises the power of JSI. This gives some insight into how turbo modules will work when they are released. This promises great things for the future of React Native modules.

Alan Allard is a developer at Eyevinn Technology, the European leading independent consultancy firm specializing in video technology and media distribution.

If you need assistance in the development and implementation of this, our team of video developers are happy to help out. If you have any questions or comments just drop us a line in the comments section to this post.

Discussion (0)