» » Jetpack Compose in React Native Projects: Pros, Cons, and Integration

Jetpack Compose in React Native Projects: Pros, Cons, and Integration

Today I will tell you why Jetpack Compose is needed in React Native projects and share my experience of integrating the framework into our applications. In the end, using the example of a simple component, we will analyze the entire development process on Jetpack Compose.

Jetpack Compose - Android UI Framework

Jetpack Compose is a set of tools for developing UI in Android applications using the Kotlin programming language. It speeds up and simplifies user interface creation with its declarative approach.

The declarative paradigm allows you to move implementation details into separate functions and reuse them for other purposes.

Jetpack Compose - fresh UI toolkit: Google released the first stable version 1.0 in July 2021. The current version at the time of this writing is Jetpack Compose 1.1 , it was released on January 26, 2022.

Why do I need Jetpack Compose in a React-Native project

Jetpack Compose reduces the amount of code and allows you to be independent of the version of Android. You can describe the UI directly in Kotlin code - without xml. In addition, Compose:

  1. Opens access to third-party UI libraries . Now there are not many of them, but more and more large companies are rewriting their open-source solutions on Compose. Up-to-date third-party libraries are collected in the Jetpack Compose Awesome repository .

  2. Allows you to create your own components for React Native . React Native is a rather limited framework. For example, it is problematic to implement a waterfall layout with self-aligning cells of different heights in React Native. It is easier to write such a component using the layout system built into Jetpack Compose and integrate it into the main React-Native project.

  3. Simplifies migration to native stack . If the application is only for Android, but written in React Native, it is more convenient to use the native stack. You can get to it using the standard Android View, but it's easier with Jetpack Compose, which is conceptually closer to React Native. This is especially true if there are only RN developers in the team.

Jetpack Compose is a young toolkit. Perhaps the tools needed by a particular team have not yet been implemented. A list of prebuilt classgroups is available on the Android for Developers site . There's also a Jetpack Compose roadmap .

Jetpack Compose and RN: functional components

Jetpack Compose and React Native use a declarative approach, so they have similar concepts. This makes Jetpack Compose clear and easy for React Native developers.

The first similar concept is functional components, which allow you to break the interface into self-sufficient components. In Jetpack Compose, such components can be created using composable functions.

Let's write a functional component in React Native and implement it with a composable function in Jetpack Compose.

Functional component in React Native

Let's define the Container functional component and pass the children property to it. This is a required property, which is almost always present.

In React Native, component properties are wrapped in an object and passed to a function. Therefore, we use composition in JSX to nest children one inside the other and pass the container to the component.

function Container(props) {
  return <div>{props.children}</div>;
}

<Container>
  <span>Hello world!</span>
</Container>

Functional Component on Jetpack Compose

The same container on Jetpack Compose can be written as a composable function with similar logic. We also stack into each other, only now not the children, but the components themselves: internal to external.

And if in React Native the properties needed to be wrapped in an object, then here we pass the components directly as arguments to the internal composable function.

@Composable
fun Container(children: @Composable () -> Unit) {
  Box {
    children()
  }
}

Container {
  Text("Hello world"!)
}

Jetpack Compose and RN: Hooks

The second similar concept is hooks. In React Native, using hooks, the logic associated with its life cycle is taken out of a component in order to be used in other components. In Jetpack Composable, the same can be done using the same composable functions.

Hook on React Native

Let's define a useFriendStatus hook that will report the status of a friend with the friendID identifier: online or offline.

To return status information from a hook, we will use useState with a default value of null. We have an isOnline getter and a setIsOnline setter for it.

Pay attention to how useEffect works. The first time it is executed during the mount. If one of the dependencies, which is passed in the array as the second argument, changes, useEffect is executed again. That is, after changing each dependency and when unmounting, a callback will be called, which we return for cleaning.

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);
  
  useEffect(() -> {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    
    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () -> {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  }, [friendID]);
   
  return isOnline;
}

This hook can be reused in several components at once. However, they will not know anything about the internal implementation.

Hook on Jetpack Compose

In Jetpack Compose, the same hook can be implemented using the composable function. By analogy with React Native, we define a State with a default value of null. We also have an isOnline getter and a setIsOnline setter for it.

Remember UseEffect in React Native hook. DisposableEffect plays the same role here. The main difference is that in DisposableEffect, dependencies are taken over by positional arguments, and we pass a lambda as the last argument. It will be called when changing these arguments, when mounting and unmounting.

That is, in React Native we return a callback, but here we pass it to the onDispose method. The effect is the same.

@Composable
fun friendStatus(friendID: String): State<Boolean?> {
  val (isOnline, setIsOnline) = remember { mutableStateOf<Boolean?>(null) }
  
  DisposableEffect(friendID) {
    val handleStatusChange = { status: FriendStatus ->
      setIsOnline(status.isOnline)
    }
     
    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange)
    onDispose {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange)
    }
  }
  
  return isOnline
}

Hooks and functional components are not the only similarities between Compose and React Native. You can read more about similar concepts in Jetpack Compose and React in the React to Jetpack Compose RC Dictionary article .

How to integrate Jetpack Compose into a React Native project

To integrate Jetpack Compose, you need to use the ComposeView framework in your project . It provides Interop for Jetpack Compose and native View. We will use ComposeView to create the component at the end of the article.

Before integration you need:

  1. Add dependencies to build.gradle . Their list is on the official Android for Developer website .

  2. Update Gradle plugin to version 7 . Step by step instructions for updating in the Update the Android Gradle plugin guide .

  3. Patch dependencies for Gradle 7 compatibility using patch.package .

The last point is needed only if you have dependencies that are incompatible with Gradle 7. For example, we needed to patch React Native CLI and Flipper. But in Native version 0.66 and higher, they are already compatible with Gradle 7.

You can read more about integrating Jetpack Compose in the official guide Adding Jetpack Compose to your app .

What problems can arise during integration

We ran into two issues when integrating Jetpack Compose into a React Native project.

Issue 1: Transitions not working when using React Native Navigation + React Native Screens.

Solution : Strictly specify fragment version 1.2.1.

The project uses the React Native Navigation and React Native Screens libraries for screen transitions in our project . When we tried to integrate Jetpack Compose into this project, transitions stopped working on some devices. The problem was solved after strict version fragment 1.2.1 was specified in the gradle file:

implementation("androidx.fragment:fragment") {
    version {
        strictly '1.2.1'
    }
}

Problem 2: "Cannot locate windowRecomposer; View $this is not attached to a window" after navigation.reset.

Solution : override the onMeasure method inside the View wrapper for the Compose View.

When calling navigation.reset in some components, an exception occurred: "Cannot locate windowRecomposer". The problem was in the onMeasure method that ComposeView uses. Inside the ComposeView, there was an explicit check to see if it was attached to the window, and if not, an exception was thrown.

We have overridden the onMeasure method and added a separate check for composeView.isAttachedView to it. In the case of false, we set the default dimensions, and no exception is thrown.

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    if (composeView.isAttachedToWindow) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    } else {
        val width = maxOf(0, MeasureSpec.getSize(widthMeasureSpec) - paddingLeft - paddingRight)
        val height = maxOf(0, MeasureSpec.getSize(heightMeasureSpec) - paddingTop - paddingBottom)
        val child = composeView.getChildAt(0)
        if (child == null) {
            setMeasuredDimension(
                width,
                height
            )
            return
        }
        child.measure(
            MeasureSpec.makeMeasureSpec(width, MeasureSpec.getMode(widthMeasureSpec)),
            MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec)),
        )
        setMeasuredDimension(
            child.measuredWidth + paddingLeft + paddingRight,
            child.measuredHeight + paddingTop + paddingBottom
        )
    }
}

Jetpack Compose component example for React Native

Let's walk through the process of developing a simple UI component in Jetpack Compose that can be used in a React Native project. As an example, let's take a pin that is displayed on the map.

 

The pin has an animated inner white circle. When animation is enabled, the circle expands and contracts. When the animation turns off, the circle shrinks to its minimum radius and stops.

data class PinParams(
    val outerDiameter: Dp = 46.dp,
    val innerDiameterDefault: Dp = 14.dp,
    val innerDiameterExpanded: Dp = 24.dp,
    val pointDiameter: Dp = 7.dp,
    val lineWidth: Dp = 4.dp,
    val lineHeight: Dp = 15.dp
) {
    val innerRadiusDefault = innerDiameterDefault / 2
    val innerRadiusExpanded = innerDiameterExpanded / 2
    val innerRadiusDiff = innerRadiusExpanded - innerRadiusDefault
    val outerRadius = outerDiameter / 2
    val pointRadius = pointDiameter / 2
}

Define pin parameters PinParams in data class . We set the values ​​of the diameters of the inner and outer circles, the diameter of the black dot on the map, the width and height of the "legs". Parameters are conveniently defined using data class - a data structure from Kotlin. The data class has an equality method that allows you to compare all parameters, and a copy method for copying objects - an analogue of the spread operator in jаvascript.

@Composable
fun Pin(
    pinParams: PinParams,
    animated: Boolean = false
) {
  
   val radiusCoef = animatedRadiusCoef(animated)
  
   AnimatedCanvasComponent(params = PinParams, radiusCoef = radiusCoef)
}

Let's define a composable function Pin . As parameters, Pin takes the pin parameters pinParams and the animated flag with information about whether animation is currently enabled.

The function draws a pin on the Canvas - for this we use a separate AnimatedCanvasComponent component. We pass the value of the radius coefficient of the animated circle to it: from 0 to 1.

It is convenient to put all the logic of determining the animation radius based on the animated flag into the animatedRadiusCoef hook:

@Composable
fun animatedRadiusCoef(
    animated: Boolean
): Float {
    val infiniteTransition = rememberInfiniteTransition()
    var isRunning by remember {
        mutableStateOf(animated)
    }
    val radiusCoef = infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = if (isRunning) 1f else 0f,
        animationSpec = infiniteRepeatable(
            animation = tween(300, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        )
    )

    val radiusIsZero = radiusCoef.value = 0f

    LaunchedEffect(animated, isRunning, radiusIsZero) {
        if (!animated && isRunning && radiusIsZero) {
            isRunning = false
        }
        if (animated && !isRunning && radiusIsZero) {
            isRunning = true
        }
    }
    
    return radiusCoef.value
}

In animatedRadiusCoef, we use the standard Kotlin rememberInfiniteTransition helper for infinite animation.

To complete the animation, we declare mutableState - isRunning. When the external animated flag changes from true to false, we need to give the component some more time so that it completes the last cycle of animation and shrinks the circle back to its original state; after that, the animation can be turned off.

class PinViewModel : ViewModel() {
 
    var uiState by mutableStateOf(false)
        private set

    fun setValue(next: Boolean) {
        uiState = next
    }
}

@SuppressLint("ViewConstructor")
class PinView(context: Context) : FrameLayout(context) {
    private val viewModel = PinViewModel()

    fun setValue(next: Boolean) {
        viewModel.setValue(next)
    }

    init {
        val composeView = ComposeView(context = context)
        composeView.setViewCompositionStrategy(
          ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
        )
        composeView.setContent {
            Pin(pinParams = PinParams(), animated = viewModel.uiState)
        }
        layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
        addView(composeView)
    }
}

Let's wrap the Pin composable function in a native View . To control the logic of the component, we create a PinViewModel class that inherits from ViewModel. Inside, we define the setValue method to throw the setter out. It will be needed later.

For Interop and native View, we create a PinView class that inherits from FrameLayout and use ComposeView in it.

We will call the Pin composable function in the setContent method.

To add a ComposeView to a FrameLayout, use the addView method.

class PinViewManager : SimpleViewManager<PinView>() {
    override fun getName() = "PinView"
    
    override fun createViewInstance(reactContext: ThemedReactContext): PinView {  
        return PinView(reactContext.currentActivity!!.applicationContext)
    }
    
    @ReactProp(name = "isAnimating")
    fun toggle(view: PinView, isAnimating: Boolean) {
        view.setValue(isAnimating)
    }
}

Let's write a View manager - the successor of SimpleViewManager in React Native. It is needed to use the PinView component written with Jetpack Compose in a React Native project. The view manager in our case will be the PinViewManager class, which inherits from SimpleViewManager.

In it, we override the getName method so that it returns a PinView. Thus, we can access the native component using the standard method from React Native - requireNativeCompontent. We also override createViewInstance, it will create an instance of PinView.

If you need to pass parameters, you need to use the ReactProp notation. In name we pass what the property will be called in the JS part. The toggle method receives an instance of PinView and the current value of isAnimating from the JS part. Using the setValue setter we defined in the PinView, we change the isAnimating state.

The Pin UI component is ready . It is written in Jetpack Compose, but can be used in a React Native project thanks to the View Manager.

Related Articles

Add Your Comment

reload, if the code cannot be seen

All comments will be moderated before being published.