React Native at Traveloka: Native UI Components

Adrian Hartanto
Traveloka Engineering Blog
13 min readJun 18, 2021

--

Editor’s Note:

One of the challenges with managing multiplatform software development is the laborious efforts of creating & maintaining parity for every identical component constantly across platforms. Adrian would like to share with us today the journey (through examples, problems, & solutions) that he & his team took to overcome that challenge with a cross-platform interoperability implementation.

Adrian Hartanto is a React Native Software Engineer with the Multiplatform Infra Team that maintain & improve the React Native architecture for Traveloka main app & B2B. The team is also currently designing & building React Native interoperability for Android, iOS, & Web.

In this article, we will share about a Native UI Components project called Reverse Osmosis that was briefly covered previously in React Native at Traveloka: Bridging the Past and the Future.

Reverse Osmosis, the opposite of the Osmosis project that will be covered in a follow-up article, aims to provide interoperability between React Native and native developments (iOS/Android). While Osmosis allows React Native components inside native pages, Reverse Osmosis, as its prefix implies, allows native components inside React Native pages. By doing so, we can share more components between platforms and also provide flexibility to develop components.

Why Reverse Osmosis?

We have been using a hybrid approach in developing the Traveloka application as explained in React Native at Traveloka: Hybrid Development Experience. It affords us the flexibility to develop a feature using either React Native or native iOS/Android. However, when there’s a requirement to use iOS/Android components in React Native products, we need to recreate the components in React Native, which could cause a feature parity problem between platforms. Reverse Osmosis was created to address that problem.

Nevertheless, such a challenge allowed us to learn how to create native UI components, how they work, and how to integrate them with React Native. Actually, this is not a new concept since React Native already has guides (iOS/Android) to create custom native UI components by wrapping existing native components and integrating them with React Native.

Another issue that Reverse Osmosis would solve was the constant repetition of defining view’s or component’s size manually in order to avoid a blank screen. Not to mention, native components need to manage their own layout too.

In summary, here were the challenges that we wanted to solve:

  1. Modifying native components just to be interoperable with React Native. In Traveloka, each team maintains their own code. So, the fewer constraints coming from React Native’s needs, the better.
  2. Manual (re)sizing. Native components require less user intervention over its lifetime in React Native since any modification including UI-related, doesn’t need update on our part. When we use native components in React Native, it’s normally required to set their width and height either by specifying flex:1 or hard-code them. It would be powerful if we could come up with an implementation that allows components to determine their own size dynamically.

So, the goal was to make React Native be self-aware of layout updates that happen on native components so that we can have dynamic-sized native components. In this article, we are gonna share our learning experience and what worked for us to overcome those challenges.

Native Components Showcase

These are three native component examples that are being used in Traveloka application.

Booking Price

Figure 1. Booking price components on insurance’s booking detail page in Android (left) and iOS (right). Red and green outlines are native and React Native components respectively.
Figure 2. Booking price modal component on insurance’s booking detail page in Android (left) and iOS (right). Red outlines are native components.

Those are the first native components chosen as Proof of Concept for this project. Although simple components, we were able to confirm that native modal and navigation capabilities work without any modification in React Native side.

Merchandising

Figure 3. Merchandising component on a promo detail page in Android (left) and iOS (right). Red and green outlines are native and React Native components respectively.

Merchandising is a concept of Common View Language we have in Traveloka that allows us to render sets of items with predefined rules and styling from the back-end by using JSON as a data source. Common View Language expects us to adhere to its requirement of rendering identical components regardless of platforms. Initially, we built merchandising in Android, iOS, Web, & React Native. Keeping this component in sync for every iteration in all platforms made managing feature parity even more challenging. As React Native products don’t utilize merchandising as much as products running on native pages, React Native’s merchandising components tend to fall behind their native counterparts and it becomes harder over time to justify making updates for the sake of feature parity. Therefore, we explored the possibility to replace obsolete merchandising in React Native with up-to-date native implementation through Reverse Osmosis. We have two actionables here:

  1. The previously used data to render in React Native side needs to be passed to the native side so that native merchandising can render normally.
  2. React Native must dynamically allocate any size required by the components.

We managed to complete the actionables and migrated all React Native old merchandising into Reverse Osmosis implementation. Since then, any change in native sides is also applied in React Native automatically.

View Slider

Figure 4. View Slider Component on Bebas Handal Insurance Page in Android (left) and iOS (right).

View Slider is a container that receives two layouts but shows only one at a time. It can slide up & down to show the other layout. This container already exists in native platforms and our product team requested the same capability in React Native. At first, we created similar behavior using ScrollView that works well in the iOS version. Although it could also work in the Android version, the performance was not as good as we expected and the gesture handling was also not smooth enough. Hence, we opted to use Reverse Osmosis. After we were successful in Android, we found that iOS implementation was heavily coupled with too many native codes and refactoring the code would eventually kill the purpose of Reverse Osmosis, which was designed to limit modification of the actual components. At the end, we had a View Slider with Reverse Osmosis for Android only (you would need a good component abstraction in React Native to create a partial Reverse Osmosis implementation).

This container will naturally stretch to cover the whole screen. So, size manipulation was not needed here. The learning in this implementation was to pass React Native components to native side and how to properly manage it.

Four Obstacles

The Lack of Documentation/Article About Dynamic Sized Native Component

Even though it’s common to create native components as many libraries have already done, native component implementation doesn’t manage its own size. It requires you to define the component size such as using flex:1 or hard-coded width and height. Therefore, it’s quite difficult to find documentation related to dynamic-sized native components and we need to teach ourselves how native components actually work.

Different Component API Between Platforms

Reusing native components in React Native was not as straightforward as we imagined. Many of our native components preceded our establishment of React Native in Traveloka. Long ago, we didn’t think it was possible to reuse components outside the platform. So, it’s common that some iOS and Android components have a different API because those were created without the foresight of sharing. Even for a property that has the same name, Android and iOS can expect different data types. For example, BookingPrice component has a property called entryPoint with different data types on both platforms, string on iOS and object on Android. When this happens, we need to make some adjustments in the React Native or ViewManager layer to fulfill the component’s requirements with data type conversion.

In our cases, either you find both implementations easy to tinker with or one platform is dwarfed by another in terms of difficulty.

iOS Auto Layout

During our research, we found out that Auto Layout was not working well when used inside React Native. For example, React Native couldn’t get a new layout after the component constraints were updated. React Native internally uses Yoga as its layout engine. Actually, we’ve asked Shergin, the former React Native team member about this issue on this twitter thread. Based on his answer, the problem is that React Native runs layout on a background thread while our native components that use UIKit run on the main thread. To date, there’s still no solution to this problem. Since most iOS components at Traveloka are developed using Auto Layout, we needed to find a workaround to solve this problem before we can reuse iOS native components in React Native.

Android Performance Problem

On Android, we initially planned to use Android’s layout listener such as ViewTreeObserver or View.OnLayoutChangeListener to listen to layout updates so that we don’t need to modify existing components when we want to use them in React Native.

  1. ViewTreeObserver:
    We expected that ViewTreeObserver could observe layout updates in the view tree and we could use it to notify React Native about the updates. But, as the ViewTreeObserver observes the layout of the whole view tree, ViewTreeObserver listeners can be called multiple times in any layout update. Because we depended on this observer, there’re so many notifications sent to React Native that affected the render result.
  2. View.addOnLayoutChangeListener:
    Beside ViewTreeObserver, we also tried View.addOnLayoutChangeListener to listen to layout updates. Unfortunately, this listener was only called once in component creation. Therefore, it couldn’t be used to detect layout changes.

We spent a lot of time improving this approach because we thought negating the need to modify existing components is an ideal approach. Unfortunately, this listener concept was scrapped from our final solution.

Solutions

After tinkering with several approaches for each obstacle that we have mentioned, we finally settled on a solution that focused on these two key topics:

  1. Notifying React Native for any size update on native components:
    Native UI components on React Native are managed by ViewManager that is responsible to create native components and set or update properties of the components. As we want to have dynamic sized native components, we need to set the size of the components on native side. Since ViewManager is responsible for managing native components, it’s necessary to let ViewManager be aware of any size update on the native components.
    Initially, we were using these approaches to get size update and notify ViewManager:
    a. Expose listeners on native component:
    This approach can be done by exposing listeners related to layout updates such as update component layout or api call started/finished so ViewManager can recalculate the component size every time the listener is triggered.
    While this approach is quite straightforward, it also has some caveats. You need to be aware of all possibilities that might affect layout and should expose it as listeners. This means that you also need to modify existing components if there’s no listeners provided. Beside that, its maintainability is a thing we should note, as over time, component modification might cause regression and require you to make some adjustments.
    b. KVO on iOS:
    On iOS, we can also utilize KVO to listen to size updates. ViewManager will act as an observer of the component size properties. In our case, we use KVO to get the Merchandising component’s size by observing the contentSize of UICollectionView. You can also use this to observe component’s bounds to get bounds updates but sometimes, the result is not as expected such as bounds always return zero. However, we cannot generalize the properties that should be observed. For example, not every component uses UICollectionView.
    Even though these two options were far from ideal, they can be used to fulfill our requirements and we’ve used these in our application for several application versions.
    After quite some time, we finally came into an approach that we think it’s better than the previous one. On this approach, we introduced a new component called WrapperView that will manage layouting of the native component. WrapperView will be responsible for layouting the native child component and updating React Native view hierarchy. This way, we can generalize our approach for iOS and Android and remove requirements to expose listeners and use KVO.
  2. Updating React Native view hierarchy:
    The next step is to update the React Native view hierarchy. Without it, the layout can’t be correctly rendered since the native component is still assumed to not have any size yet. Thus, it can’t provide the space the component actually needs.
    React Native view hierarchy is managed by UIManager, represented by RCTUIManager class on iOS and UIManagerModule class on Android. Lucky for us, UIManager already has a method (iOS/Android) that can be used to update the size of the native components and the view hierarchy. We just need to pass the new size to it. Then, UIManager will update the view hierarchy accordingly.

We’ll be moving on to the implementation of our research. The implementation explanation will start from the React Native side so it will be easier to get the bigger picture of what we are trying to achieve.

React Native Implementation

Figure 5. Reverse Osmosis Component Example.

We need to create React Native module that imports the iOS/Android ViewManager that has been created. This module will act as an interface layer between iOS/Android and React Native.

In this module, you need to call requireNativeComponent to access the iOS/Android native views. This method takes the name of native view that is registered on ViewManager, getName on Android or RCT_EXPORT_MODULE on iOS. As explained in the docs, React Native views is a subclass of UIView. Therefore, you can style native components like any React Native component.

Since we already handled layout updates of native component on native side, there’s no need to use flex:1 or define width/height of the native component.

iOS Implementation

Exposing native components on iOS requires you to do these five steps as described on the official guide:

  1. Subclass RCTViewManager class that will be responsible as manager for the component.
  2. Add RCT_EXPORT_MODULE macro to register component to the bridge.
  3. Override — (UIView *)view method to return the view.
  4. Expose view property using RCT_CUSTOM_VIEW_PROPERTY macro.
  5. Implement JavaScript module.

In most cases, you can follow this guide to create native UI components and define component size to make it work. Since we want to have a dynamic-sized native component, we need to make some adjustments on how we create the component.

Native component on iOS generally consist of two parts:

  1. View
    iOS view that will be exported as native component in React Native.
  2. ViewManager
    ViewManager is represented by the RCTViewManager class. It is responsible to manage the component, such as manage props, send/receive events, etc.
Figure 6. iOS file structures.

Create WrapperView

WrapperView will act as a parent of the native component. This will be responsible for handling layouting of the native component and notifying size updates to ViewManager.

Native components will be added as a subview and fill the parent, WrapperView. In the layoutSubviews lifecycle, we can get the subview of the WrapperView, which is the native component. This way, we can get the updated size of the native component and update the React Native view hierarchy by calling the setSize method on UIManager.

Running Module Methods on Main Thread

We can tell ViewManager to run on the main thread by overriding methodQueue since our component will interact with UIKit. By doing this, methods in our component will be executed on the main thread.

Wrap Native Component with WrapperView

Instead of returning native component on the view method, we need to wrap it inside WrapperView first. As mentioned earlier, WrapperView will handle layouting of the native component.

Android Implementation

Moving on to Android implementation, React Native also has official guide to create native components:

  1. Create a ViewManager subclass, it can be either SimpleViewManager or ViewGroupManager.
  2. Override createViewInstance method to return the view.
  3. Expose view property using @ReactProp annotation.
  4. Register ViewManager in thecreateViewManagers method of the application package.
  5. Implement a JavaScript module.

This implementation also works in most cases. But, you still need to define component size when using it in React Native.

Similar with iOS, Native component on Android also consist of two parts:

  1. View
    Android View that will be exported as native component in React Native.
  2. ViewManager
    ViewManager on Android is represented by SimpleViewManager/ViewGroupManager. It is responsible to manage the component, such as manage props, send/receive events, etc.
Figure 7. Android file structures

Create WrapperView

On Android, we also need to create a WrapperView that’s responsible for handling layouting native components. We need to override onMeasure to manually calculate the size of the native component and update the React Native view hierarchy. This can be done by calling updateNodeSize from the UIManagerModule class.

Wrap Native Component with WrapperView

Similar to the iOS implementation, we need to wrap native components with WrapperView to handle the layouting process.

Bonus: Pass React Native Component to Native Component

Besides primitive data types that can be passed as props to native components, you can also pass React Native components to native components. This approach was inspired from react-native-maps implementation and being used for View Slider native component that can receive React Native components. You can pass React Native component as a child of native component and override addView method to get the component as a child.

Working example of this implementation can be accessed here.

Summary

This sums up our learning about native UI components and how we use it to reuse existing native components in React Native. This can be used as an alternative to develop components in React Native. We also managed to have dynamic-sized native components so that they can be used in our application.

We’re still gathering more use cases and improving our solution to have dynamic-sized native components when React Native releases their new architecture in the future. For now, this approach works well for us and also fulfills our existing requirements.

It was indeed a challenging research project to have dynamic-sized native components. This will be useful if you want to reuse existing native components in your React Native application. This also gives you flexibility to develop components, whether using React Native or using native components.

If this kind of topic is something of an interest to you, please take a look at our career page. We are always excited to meet new people, who share the same passion to join with us.

--

--