React Native

React Native’s New Architecture: The Tricky Parts (3/4)

Igor FurgalaNov 6, 202512 min read

In the first part of our series on the tricky parts of the React Native New Architecture, we explored, among other things, how to define custom shadow nodes. In the second part, we dived into what state they have, how to use them, and even how to give them a specific size.

Today, we’ll take a closer look at how we can handle component measurement on Android, and how to exchange information about component size between the shadow node and the native layer. This can be used to implement components with their size dependent on the platform, including auto-grow mechanism.

For the purposes of this article, let’s assume that you’re working on an application that allows users to evaluate a service or another user. Examples of such applications are ride-hailing apps, where you can rate the driver, or food delivery apps, where you rate the restaurant and the delivery person. Rating is usually done by selecting the number of stars on one of the screens. You can, of course, build such a component entirely on the JavaScript side. However, Android provides the necessary component — AppCompatRatingBar — out of the box. It’s consistent with the platform UI, supports gestures, and allows you to avoid implementing all of this from scratch.

Component definition

Let’s start by creating a Fabric view component, it can inherit from ViewProps, so that some of the functionality, such as positioning and margins, will work out of the box. We can define one additional prop, numStars, so the number of rendered stars is fully customizable:

// AppRatingBarViewNativeComponent.ts

import { codegenNativeComponent, type ViewProps } from 'react-native';
import { Int32 } from "react-native/Libraries/Types/CodegenTypes";

interface NativeProps extends ViewProps {
 numStars: Int32;
}

export default codegenNativeComponent<NativeProps>('AppRatingBarView');

Once that’s done, we can move on to implementing the component on the native side. I won’t go into details here, it’s just basic AppCompatRatingBar, with customized colors and step size set to 0.5, so you can use half values:

// AppRatingBarView.kt

package com.appratingbar

import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatRatingBar
import androidx.core.graphics.toColorInt

class AppRatingBarView : AppCompatRatingBar {
 constructor(context: Context) : super(context) {
   prepareComponent()
 }

 constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
   prepareComponent()
 }

 constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
   context,
   attrs,
   defStyleAttr
 ) {
   prepareComponent()
 }

 private fun prepareComponent() {
   stepSize = 0.5f
   progressTintList = android.content.res.ColorStateList.valueOf("#FFD700".toColorInt())
   secondaryProgressTintList = android.content.res.ColorStateList.valueOf("#FFE082".toColorInt())
   progressBackgroundTintList = android.content.res.ColorStateList.valueOf("#DDDDDD".toColorInt())
 }
}

Now, we can edit AppRatingBarViewManager to add support for the numStars prop. It’s enough to set this property directly on the view instance:

// AppRatingBarViewManager.kt

package com.appratingbar

import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.SimpleViewManager
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.uimanager.annotations.ReactProp
import com.facebook.react.viewmanagers.AppRatingBarViewManagerInterface
import com.facebook.react.viewmanagers.AppRatingBarViewManagerDelegate

@ReactModule(name = AppRatingBarViewManager.NAME)
class AppRatingBarViewManager : SimpleViewManager<AppRatingBarView>(),
 AppRatingBarViewManagerInterface<AppRatingBarView> {
 private val mDelegate: ViewManagerDelegate<AppRatingBarView>

 init {
   mDelegate = AppRatingBarViewManagerDelegate(this)
 }

 override fun getDelegate(): ViewManagerDelegate<AppRatingBarView>? {
   return mDelegate
 }

 override fun getName(): String {
   return NAME
 }

 public override fun createViewInstance(context: ThemedReactContext): AppRatingBarView {
   return AppRatingBarView(context)
 }

 @ReactProp(name = "numStars")
 override fun setNumStars(view: AppRatingBarView?, num: Int) {
   view?.numStars = num
 }

 companion object {
   const val NAME = "AppRatingBarView"
 }
}

It seems that all the necessary steps have been completed. We can launch the application and add our component to the screen. You may notice that it’s not visible at all. Why?

Let’s debug this. Since it inherits from ViewProps, we can try to give it dimensions manually by setting the appropriate style. I set the height to 32 pixels and the width to 100%. Now I can see the component, but it’s still not displayed correctly:

AppCompatRatingBar with fixed height and width

First of all, stars are cut off. We could probably fix this by setting a different height for the component, but how do we determine the right value — and will it be the same across all devices? Secondly, even though I change numStars, the number of stars displayed is still the same. These problems arise from the fact that Yoga doesn’t know what dimensions our component should have.

Custom shadow node

Let’s start by briefly defining a basic custom shadow node and outlining a list of steps to follow. Each of them has been discussed in the first part of our series, so if anything is unclear, check it out. In addition, you can see the finished implementation in the GitHub repository.

  1. Adjust the codegen component definition by specifying interfaceOnly: true.
  2. Create an empty implementation for a custom shadow node.
  3. Create a custom component descriptor, which uses a newly created shadow node.
  4. Link component descriptor by modifying react-native.config.js.
  5. Create a CMakeLists.txt file which includes files that you just created.

There is one thing I would like to draw particular attention to. A recent React Native change means third-party libraries should use target_compile_reactnative_options in their CMake configuration so that the RN_SERIALIZABLE_STATE variable is propagated correctly. Without it, the application won’t function properly and may even crash at runtime.

target_compile_reactnative_options(react_codegen_AppRatingBarViewSpec PRIVATE)

At this stage, you should be using a custom shadow node. If you restart the application, you will notice that nothing has actually changed. However, we now have a setup that will allow us to provide measurements for the AppCompatRatingBar component.

Component measurement

We want to measure the component on the native side, so we don’t have to wonder if its size depends on the type of device and how exactly it’s built. We then want to pass the measured value to Yoga, so that React Native layout knows the dimensions of the component, enabling its correct rendering.

Let’s take a look at our plan:

  1. Creating AppRatingBarMeasurementManager — a class responsible for exchanging data regarding component size between the native layer and the shadow node.
  2. Overriding the measureContent method on the shadow node. This is where we want to handle custom component measurements.
  3. Providing access to AppRatingBarMeasurementManager from the shadow node level.
  4. Conducting current measurements of the component on the native side.

Our first step is to create an AppRatingBarMeasurementManager. This class will be responsible for communication between Yoga and the native layer regarding component dimensions. We’ll use FabricUIManager to exchange this information.

We can access it on the shadow node side via contextContainer, which is defined for each component descriptor. FabricUIManager has a measure method that accepts several parameters. In our case, it’s crucial to pass the correct surfaceId and componentName. This way, FabricUIManager will correctly recognize the component type on the native side. Each call to the measure method triggers a call to the view manager’s measure method. Using that approach, the dimensions calculated on the native side will be passed to the shadow node:

// AppRatingBarMeasurementManager.h

#pragma once

#include "ComponentDescriptors.h"
#include <react/utils/ContextContainer.h>
#include <react/renderer/core/LayoutConstraints.h>

namespace facebook::react {

   class AppRatingBarMeasurementManager {
   public:
       AppRatingBarMeasurementManager(
               const std::shared_ptr<const ContextContainer>& contextContainer)
               : contextContainer_(contextContainer) {}

       Size measure(SurfaceId surfaceId, LayoutConstraints layoutConstraints) const;

   private:
       const std::shared_ptr<const ContextContainer> contextContainer_;
   };

} // namespace facebook::react
// AppRatingBarMeasurementManager.cpp

#include "AppRatingBarMeasurementManager.h"

#include <fbjni/fbjni.h>
#include <react/jni/ReadableNativeMap.h>
#include <react/renderer/core/conversions.h>

using namespace facebook::jni;

namespace facebook::react {

   Size AppRatingBarMeasurementManager::measure(
           SurfaceId surfaceId, LayoutConstraints layoutConstraints) const {
       const jni::global_ref<jobject>& fabricUIManager =
               contextContainer_->at<jni::global_ref<jobject>>("FabricUIManager");

       static const auto measure = facebook::jni::findClassStatic(
               "com/facebook/react/fabric/FabricUIManager")
               ->getMethod<jlong(
                       jint,
                       jstring,
                       ReadableMap::javaobject,
                       ReadableMap::javaobject,
                       ReadableMap::javaobject,
                       jfloat,
                       jfloat,
                       jfloat,
                       jfloat)>("measure");

       auto minimumSize = layoutConstraints.minimumSize;
       auto maximumSize = layoutConstraints.maximumSize;

       local_ref<JString> componentName = make_jstring("AppRatingBarView");

       auto measurement = yogaMeassureToSize(measure(
               fabricUIManager,
               surfaceId,
               componentName.get(),
               nullptr,
               nullptr,
               nullptr,
               minimumSize.width,
               maximumSize.width,
               minimumSize.height,
               maximumSize.height));

       return measurement;
   }

} // namespace facebook::react

You may notice that access to contextContainer is provided as a class member. As mentioned, it’s defined at the component descriptor level, so we have to pass it from there. The measure method accepts not only surfaceId, but also layoutConstraints containing information about the dimensions set by Yoga, based on defined styles, parent dimensions, or the layout of the entire screen. For some of the arguments, we passed null pointers. We don’t need access to shadow node props or state on the native side at this point, so we can omit them.

Now that we know how to do measurements, we need to learn when and where to do it. In our case, the best place is to override shadow node’s measureContent method. It’s called every time Yoga recognizes that the dimensions of the component may or should change, and we also have access to layout constraints there. Another advantage of calculating component size here is that we don’t have to worry about padding or border sizes. We don’t calculate the size of the entire component, but only its content. In order to enable custom measurements, we must also remember to set shadow node traits to MeasurableYogaNode and LeafYogaNode.

// AppRatingBarShadowNode.h

#pragma once

#include "AppRatingBarMeasurementManager.h"

#include <react/renderer/components/AppRatingBarViewSpec/EventEmitters.h>
#include <react/renderer/components/AppRatingBarViewSpec/Props.h>
#include <react/renderer/components/view/ConcreteViewShadowNode.h>
#include <jsi/jsi.h>

namespace facebook::react {

JSI_EXPORT extern const char AppRatingBarViewComponentName[];

class AppRatingBarShadowNode final : public ConcreteViewShadowNode<
   AppRatingBarViewComponentName,
   AppRatingBarViewProps,
   AppRatingBarViewEventEmitter> {
   public:
       using ConcreteViewShadowNode::ConcreteViewShadowNode;

       static ShadowNodeTraits BaseTraits() {
           auto traits = ConcreteViewShadowNode::BaseTraits();
           traits.set(ShadowNodeTraits::Trait::LeafYogaNode);
           traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode);
           return traits;
       }

       void setMeasurementManager(
           const std::shared_ptr<AppRatingBarMeasurementManager>&
                    measurementsManager);

       Size measureContent(
           const LayoutContext& layoutContext,
           const LayoutConstraints& layoutConstraints) const override;

   private:
       std::shared_ptr<AppRatingBarMeasurementManager> measurementsManager_;
};

} // namespace facebook::react
// AppRatingBarShadowNode.cpp

#include "AppRatingBarShadowNode.h"

#include <react/renderer/core/LayoutContext.h>

namespace facebook::react {

extern const char AppRatingBarViewComponentName[] = "AppRatingBarView";

void AppRatingBarShadowNode::setMeasurementManager(
       const std::shared_ptr<AppRatingBarMeasurementManager> &measurementsManager) {
   ensureUnsealed();
   measurementsManager_ = measurementsManager;
}

Size AppRatingBarShadowNode::measureContent(const LayoutContext &layoutContext,
                                           const LayoutConstraints &layoutConstraints) const {

   return measurementsManager_->measure(getSurfaceId(), layoutConstraints);
}

} // namespace facebook::react

In addition to overriding measureContent, we added a new method setMeasurementManager, which will be used to provide shadow node access to the measurement manager.

As mentioned earlier, access to FabricUIManager is possible through contextContainer, which is defined for each component descriptor. So, the creation of a new AppRatingBarMeasurementManager instance should take place right here.

We’ll do that inside the adopt method, which is called every time a new instance of shadow node is created:

// AppRatingBarComponentDescriptor.h

#pragma once

#include "AppRatingBarShadowNode.h"
#include "AppRatingBarMeasurementManager.h"

#include <react/renderer/core/ConcreteComponentDescriptor.h>

namespace facebook::react {

class AppRatingBarComponentDescriptor final
   : public ConcreteComponentDescriptor<AppRatingBarShadowNode> {
 public:
   AppRatingBarComponentDescriptor(
     const ComponentDescriptorParameters& parameters)
       : ConcreteComponentDescriptor(parameters),
           measurementsManager_(
               std::make_shared<AppRatingBarMeasurementManager>(
                       contextContainer_)) {}

   void adopt(ShadowNode &shadowNode) const override {
     ConcreteComponentDescriptor::adopt(shadowNode);

     auto& appRatingBarShadowNode = static_cast<AppRatingBarShadowNode&>(shadowNode);
     appRatingBarShadowNode.setMeasurementManager(measurementsManager_);
   }

 private:
   const std::shared_ptr<AppRatingBarMeasurementManager> measurementsManager_;
};

} // namespace facebook::react

The last step is to calculate the size of the component on the native side and pass these values to Yoga. We already know that this is done via FabricUIManager, which calls the view manager’s measure method. In order to measure a component, we can create a new instance and get its measured width and height:

// AppRatingBarViewManager.kt

override fun measure(
 context: Context,
 localData: ReadableMap?,
 props: ReadableMap?,
 state: ReadableMap?,
 width: Float,
 widthMode: YogaMeasureMode?,
 height: Float,
 heightMode: YogaMeasureMode?,
 attachmentsPositions: FloatArray?
): Long {
 val view = AppRatingBarView(context)
 val measureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
 view.measure(measureSpec, measureSpec)

 return YogaMeasureOutput.make(
   PixelUtil.toDIPFromPixel(view.measuredWidth.toFloat()),
   PixelUtil.toDIPFromPixel(view.measuredHeight.toFloat())
 )
}

Before retrieving measured dimensions, we have to explicitly call measure on component instance, which takes measureSpec as an argument. In our case, we use MeasureSpec.UNSPECIFIED, which means that the parent’s dimensions don’t affect our component. For a simple example, it’s sufficient, but other components and use cases may require different settings. Note that dimensions have been converted to proper units.

You can now run the application and see that, even without any custom styles, our component appears to be displayed correctly — but is it really?

AppCompatRatingBar after native measurment

Including props during measurement

The component is indeed rendered, its height seems correct, but if you change numStars prop, you’ll notice that it always displays five stars (default value). There is one thing we forgot: the size of AppCompatRatingBar is affected by the number of stars we want to display. We have to take it into account during measurements.

On the native side, we only need to make one change. During measurement, retrieve numStars from props and set it when creating a new component instance:

// AppRatingBarViewManager.kt

override fun measure(
 context: Context,
 localData: ReadableMap?,
 props: ReadableMap?,
 state: ReadableMap?,
 width: Float,
 widthMode: YogaMeasureMode?,
 height: Float,
 heightMode: YogaMeasureMode?,
 attachmentsPositions: FloatArray?
): Long {
+val numStars = props?.getInt("numStars") ?: 0
+if (numStars == 0) {
+ return YogaMeasureOutput.make(0, 0)
+}
-val view = AppRatingBarView(context).apply
+val view = AppRatingBarView(context).apply { this.numStars = numStars }
 val measureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
 view.measure(measureSpec, measureSpec)

 return YogaMeasureOutput.make(
   PixelUtil.toDIPFromPixel(view.measuredWidth.toFloat()),
   PixelUtil.toDIPFromPixel(view.measuredHeight.toFloat())
 )
}

Current logic will display the component only if the number of stars is defined and it’s bigger than 0.

On the shadow node side, we need to pass props to the native layer. Let’s start by changing the definition of the measurement manager. In addition to accepting surfaceId and layoutConstraints, it should accept props too:

// AppRatingBarMeasurementManager.h

-Size measure(SurfaceId surfaceId, LayoutConstraints layoutConstraints) const;
+Size measure(SurfaceId surfaceId, LayoutConstraints layoutConstraints, const AppRatingBarViewProps& props) const;

Now we have to pass props to the measurement manager from the shadow node during component measurement:

// AppRatingBarShadowNode.cpp

Size AppRatingBarShadowNode::measureContent(const LayoutContext &layoutContext,
                                           const LayoutConstraints &layoutConstraints) const {
-return measurementsManager_->measure(getSurfaceId(), layoutConstraints);
+return measurementsManager_->measure(getSurfaceId(), layoutConstraints, getConcreteProps());
}

Once it’s done, we can create an additional utility for serializing props. It’s especially useful when dealing with more complex components like TextInput:

// conversions.h

#pragma once

#include <folly/dynamic.h>
#include <react/renderer/components/FBReactNativeSpec/Props.h>
#include <react/renderer/core/propsConversions.h>

namespace facebook::react {

#ifdef RN_SERIALIZABLE_STATE
inline folly::dynamic toDynamic(const AppRatingBarViewProps &props)
{
 folly::dynamic serializedProps = folly::dynamic::object();
 serializedProps["numStars"] = props.numStars;
 return serializedProps;
}
#endif

} // namespace facebook::react

We’ve already changed the definition of AppRatingBarMeasurementManager, the last step is to serialize props and pass them directly to FabricUIManager, so they can be accessed on the native side:

// AppRatingBarMeasurementManager.cpp

Size AppRatingBarMeasurementManager::measure(
       SurfaceId surfaceId, LayoutConstraints
+layoutConstraints, const AppRatingBarViewProps& props) const {
   const jni::global_ref<jobject>& fabricUIManager =
           contextContainer_->at<jni::global_ref<jobject>>("FabricUIManager");

   static const auto measure = facebook::jni::findClassStatic(
           "com/facebook/react/fabric/FabricUIManager")
           ->getMethod<jlong(
                   jint,
                   jstring,
                   ReadableMap::javaobject,
                   ReadableMap::javaobject,
                   ReadableMap::javaobject,
                   jfloat,
                   jfloat,
                   jfloat,
                   jfloat)>("measure");

   auto minimumSize = layoutConstraints.minimumSize;
   auto maximumSize = layoutConstraints.maximumSize;

   local_ref<JString> componentName = make_jstring("AppRatingBarView");

+  auto serializedProps = toDynamic(props);
+  local_ref<ReadableNativeMap::javaobject> propsRNM =
+     ReadableNativeMap::newObjectCxxArgs(serializedProps);
+   local_ref<ReadableMap::javaobject> propsRM =
+          make_local(reinterpret_cast<ReadableMap::javaobject>(propsRNM.get()));

   auto measurement = yogaMeassureToSize(measure(
           fabricUIManager,
           surfaceId,
           componentName.get(),
           nullptr,
-          nullptr,
+          propsRM.get(),
           nullptr,
           minimumSize.width,
           maximumSize.width,
           minimumSize.height,
           maximumSize.height));

   return measurement;
}

That’s it! After restarting the application, you’ll notice that the component is displayed correctly. Changing numStars causes the component dimensions to change. What’s more, you can specify custom padding or border through styles prop, it also interacts well with other components and screen layout.

In the next part, we’ll go through the custom measurement process on iOS, so stay tuned to fully understand this process on both platforms. For now, check out our GitHub repository.

Having trouble with the New Architecture? As React Native core contributors and members of the React Foundation, we’re more than ready to help. There’s a good chance we’ve already fixed issues similar to yours, and if not, we guarantee that we can work through it together! Contact us at: https://swmansion.com/react-native-new-architecture!

We’re Software Mansion: React Native core contributors, New Architecture experts, community builders, multimedia experts, and software development consultants. Do you need help with your project? Hire us: [email protected].