Expo Native View with Jetpack Compose

Andrei Khavkunov
11 min readMay 20, 2024

--

In this article we’ll take a look at how we can create native views for Android part of React Native Expo projects using declarative native framework Jetpack Compose.

This article will be handy for beginners React Native developers who are interested in writing native modules and for developers with some brief experience in native mobile development or for developers who wants to implement their native Android development experience in React Native projects.

In current tutorial we will create simple form on the native side, which will have input field and a button. This example barely covers any real-project business requirements, but will serve as a good guide to understand how to pass props and events to the native side and apply Jetpack Compose UI elements.

All described steps has links to the files source code. Each link refers to the actual code for each particular step.
Source code of this whole project can be found 👉 here 👈

Initializing and preparing project

For initializing demo project, we will use create-expo-module cli tool, which provides the most convenient experience in creating native modules in the whole React Native ecosystem. Unlike bare React Native modules, expo modules are using JSI and are renderer-agnostic, so it does not matter whether you use new architecture or not.

Run following command in the terminal to create the expo module:

npx create-expo-module expo-view-declarative

Terminal will ask you some basic questions about the module you are creating, it does not matter what you write there if you are not going to publish the module, so you can just press enter to skip each of them.

Open the created project in VS Code or whatever IDE we use. In this project, we are only will be interested in android, example and src folders during this tutorial.

In src folder contained JS wrappers for native code, web implementation of the module and types. For current tutorial we can clean this folder and only keep index.ts and ExpoViewDeclarativeView.tsx and file, which is responsible for binding React component code with native view code.

Let’s modify ExpoViewDeclarativeView.tsx a bit, to inherit ViewProps, it will be useful for us in future:

import { requireNativeViewManager } from 'expo-modules-core';
import * as React from 'react';
import { ViewProps } from "react-native";


interface ExpoViewDeclarativeViewProps extends ViewProps {}

const NativeView: React.ComponentType<ExpoViewDeclarativeViewProps> =
requireNativeViewManager('ExpoViewDeclarative');

export default function ExpoViewDeclarativeView(props: ExpoViewDeclarativeViewProps) {
return <NativeView {...props} />;
}

Let index.ts only export everything form the current module:

import ExpoViewDeclarativeView from './ExpoViewDeclarativeView';
export { ExpoViewDeclarativeView };

In example folder there is a simple Expo Project for testing your module.
Open App.tsx file and modify its content to display our native view draft:

import { StyleSheet, View } from 'react-native';

import { ExpoViewDeclarativeView } from 'expo-view-declarative';

export default function App() {
return (
<View style={styles.container}>
<ExpoViewDeclarativeView style={{ flex: 1, width: '100%' }} />
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});

❗️Pay attention to the styles property that is passed to ExpoViewDeclarativeView. Out of box, our component can’t automatically take layout heights of it’s native content, so we have to set it explicitly.

Now you can run the project from the example directory, and you will only see a blank screen. In the next step we will display some content there.

Step 1: Create and display Composable view

Let’s take a look at the android folder in the root of our project. This folder is responsible for the android native part of the module. If you dive deeper to android/src/main/java/expo/modules/viewdeclarative catalog, you will see two Kotlin (.kt) files.

ExpoViewDeclarativeModule.kt is responsible for defining your native module and allows access to constants native values, functions and events from React side.

ExpoViewDeclarativeView.kt is used for representing our native view on React side. Currently it’s almost empty and we gonna solve it next.

❗️I strongly recommend edit and view these files using Android Studio.

Lets open example project in Android Studio. You can do it manually from Android studio (open exactly example/android), or run npm command which is presented in the root package.json file:

npm run open:android  

Also run our expo example development server:

npm start --prefix example

In Android Studio project you will see the following structure.
expo-view-declarative is the android module that contained in android folder we viewed above.

Android Studio project structure

In order to use Jetpack Compose in our module, we have to add a dependencies to the build.gradle file of expo-view-declarative android module.

Location of build.gradle file of our module in android project
build.gradle location in android project structure

Add following dependencies to the bottom of the file:

dependencies {
// Importing Jetpack Compose
implementation(platform("androidx.compose:compose-bom:2024.05.00"))
// and ui library
implementation("androidx.compose.material3:material3")

// OPTIONAL - Android Studio Preview support
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
}

And add following configs to android section of the build.gradle file:


android {
...

// Enables Compose functionality
buildFeatures {
compose true
}
// Kotlin compiler version must be tied to Kotlin version
// according: https://developer.android.com/jetpack/androidx/releases/compose-kotlin
composeOptions {
kotlinCompilerExtensionVersion = "1.5.10"
}
...
}

And sync gradle changes by pressing corresponding button:

Sync gradle changes image
Don’t forget to sync gradle changes before you launch the app

❗️You might encounter the following error, trying to launch the application. Just set kotlinCompilerExtensionVersion value corresponding to the Kotlin version which is used in module (it may vary depending on expo cli version). In current case, kotlinCompilerExtensionVersion must be 1.5.10 which corresponds to Kotlin version 1.9.24. Compatibility map can be found 👉 here 👈.

After this point, we are ready to configure our native view 🎉

Lets firstly, remove everything useless from ExpoViewDeclarativeModule.kt file. For our purposes, we only need to keep definitions of the module name and the view.

package expo.modules.viewdeclarative

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class ExpoViewDeclarativeModule : Module() {

override fun definition() = ModuleDefinition {
Name("ExpoViewDeclarative")

View(ExpoViewDeclarativeView::class) {
Prop("name") { view: ExpoViewDeclarativeView, prop: String ->
println(prop)
}
}
}
}

In the same package, add new file, called TestForm.kt. This file will contain out composable with its logic.

package expo.modules.viewdeclarative

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
fun TestForm() {
Column (
Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {

Card() {
Column (
Modifier.padding(vertical = 15.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {

TextField(
value = "Value",
onValueChange = { /*TODO*/ },
)

Spacer(
modifier = Modifier.height(15.dp)
)

Button(
onClick = { /*TODO*/ }
) {
Text(text = "Click me!")
}

}
}
}
}


// OPTIONAL - display component preview in Android Studio
@Preview
@Composable
fun TestFormPreview() {
TestForm()
}

So, we only have to display our Composable in ExpoViewDeclarativeView.kt with the following code:

package expo.modules.viewdeclarative

import android.content.Context
import androidx.compose.ui.platform.ComposeView
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView

class ExpoViewDeclarativeView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
internal val composeView = ComposeView(context).also {

it.layoutParams = LayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT
)

it.setContent {
TestForm()
}


addView(it)
}
}

Here we programmatically added TestForm as a content to the composeView that we created and added as a subview into ExpoViewDeclarativeView.

If you run the app, you’ll be able to see the result.

Composable in React Native Expo app

Currently it does nothing but presenting our non-interactable component. If you don’t need to pass any props or events to the view, you can stop here. In further steps, we’ll take a look on how to pass properties to the native view.

Step 2: Pass props to the native view

Let’s leave Android Studio for a while, and return to our module in VS Code or whatever IDE you use. We need to enhance properties which our component can accept. For example, lets change button text via props.

In src/ExpoViewDeclarativeView.ts enhance ExpoViewDeclarativeViewProps interface like in the following code:

...

interface ExpoViewDeclarativeViewProps extends ViewProps {
btnText: string;
}

...

For example, we want button text to be something like “Submit”. In order to achieve that, in example/App.tsx pass btnText prop to ExpoViewDeclarativeView like in the following code:

export default function App() {
return (
<View style={styles.container}>
<ExpoViewDeclarativeView
style={{ flex: 1, width: '100%' }}
btnText="Submit" // <--- Add this
/>
</View>
);
}

Let’s go back to Android Studio and work on a native code a bit.

Every time our component is updated on the React side, it also has to update props on the native side. In order to control state of composable we will use ViewModel — class that is used to store and manage UI-related data in a lifecycle-aware way.

For that, in build.gradle, add new dependency (❗️don’t forget to sync gradle after):

dependencies {
...
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0")
...
}

Create a new file TestFormModel.kt near with TestForm.kt:

package expo.modules.viewdeclarative

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

class TestFormModel : ViewModel() {
private val _inputText = MutableStateFlow("Initial Input")
private val _btnText = MutableStateFlow("")

val inputText: StateFlow<String> get() = _inputText
val btnText: StateFlow<String> get() = _btnText


fun updateInputText(newValue: String) {
_inputText.value = newValue
}

fun updateBtnText(newValue: String) {
_btnText.value = newValue
}
}

This view model will be responsible for storing TestForm state, containing such values as inputText (used to control text input) and btnText (which will represent prop value from React side).

Update TestForm.kt with following code, to present view model values.

package expo.modules.viewdeclarative
...
import androidx.compose.runtime.collectAsState // <-- add this
import androidx.compose.runtime.getValue // <-- add this

@Composable
fun TestForm(viewModel: TestFormModel) {

val inputText: String by viewModel.inputText.collectAsState() // <-- add this
val btnText: String by viewModel.btnText.collectAsState() // <-- add this


Column (
Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {

Card() {
Column (
Modifier.padding(vertical = 15.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {

TextField(
value = inputText, // <-- update
onValueChange = { viewModel.updateInputText(it) } // <-- update
)

Spacer(
modifier = Modifier.height(15.dp)
)

Button(
onClick = { /*TODO*/ }
) {
Text(text = btnText) // <-- update
}

}
}
}


// OPTIONAL - display component preview in Android Studio
@Preview
@Composable
fun TestFormPreview() {
TestForm(viewModel = TestFormModel()) // <-- add this
}

In ExpoViewDeclarativeView.kt create view model instance as a class property and pass it to TestForm:

package expo.modules.viewdeclarative
...

class ExpoViewDeclarativeView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
val viewModel = TestFormModel() // <-- add this

internal val composeView = ComposeView(context).also {
...
it.setContent {
TestForm(viewModel = viewModel) // <-- update
}
...
}
}

❗️ It’s important to declare viewModel as a class public property, because we will need to have access to it in other files in order to update it’s state as we receive new property values from the React side.

Update ExpoViewDeclarativeModule.kt to receive btnText prop and update view model.

package expo.modules.viewdeclarative
...
class ExpoViewDeclarativeModule : Module() {

override fun definition() = ModuleDefinition {
...
View(ExpoViewDeclarativeView::class) {
Prop("btnText") { view: ExpoViewDeclarativeView, prop: String -> // <-- update
view.viewModel.updateBtnText(prop) // <-- add this
}
}
}
}

So, at this point property must be passed from the React side and displayed in button text. Also, value in the text field can be changed. Re-run the application to see the result:

Passed property to Composable in React Native Expo app

At this point, button does nothing, and we can’t reach input field value in the React side. In the following step, we’ll focus on catching events and receiving event data from the native side.

If in your project, you only need representational native component, you can stop here. If not, take a little rest and we’ll continue in the next step 🤗

Step 3: Pass event to the native view

Again, let’s leave Android Studio for a while, and return to our module in VS Code or whatever IDE we use. We need to enhance properties which our component can accept. This time we will add onSubmit property.

Update src/ExpoViewDeclarativeView.tsx with the following code:

...

interface SubmitEvent {
nativeEvent: {
inputText: string;
};
}

interface ExpoViewDeclarativeViewProps extends ViewProps {
btnText: string;
onSubmit(event: SubmitEvent): void;
}
...

For example, each time user taps the button, we want to see alert with inputText value which was typed to the text field in the composable on the native side. Add onSubmit event in props of ExpoViewDeclarativeView in example/App.tsx like in the code below:

...

export default function App() {
return (
<View style={styles.container}>
<ExpoViewDeclarativeView
style={{ flex: 1, width: "100%" }}
btnText="Submit"
onSubmit={(event) => {
Alert.alert("NATIVE EVENT", event.nativeEvent.inputText);
}}
/>
</View>
);
}

...

Go back to Android studio, and let’s change some code there.

First of all, we have to declare, that our native view, from now on, can also accept onSubmit event. In order to do that, add one single row to ExpoViewDeclarativeModule.kt:

package expo.modules.viewdeclarative
...

class ExpoViewDeclarativeModule : Module() {
override fun definition() = ModuleDefinition {
sible from `requireNativeModule('ExpoViewDeclarative')` in JavaScript.
Name("ExpoViewDeclarative")

View(ExpoViewDeclarativeView::class) {

Events("onSubmit") // <---------- Add this

Prop("btnText") { view: ExpoViewDeclarativeView, prop: String ->
view.viewModel.updateBtnText(prop)
}
}
}
}

Update TestForm.kt so it could accept onSubmit event and call it whenever button is pressed. Also, inputText value should be passed as an argument to this event.

package expo.modules.viewdeclarative
...

@Composable
fun TestForm(
viewModel: TestFormModel,
onSubmit: (String) -> Unit // <-- add an argument here
) {


val inputText: String by viewModel.inputText.collectAsState()
val btnText: String by viewModel.btnText.collectAsState()


Column (
Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {

Card() {
Column (
Modifier.padding(vertical = 15.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {

TextField(
value = inputText,
onValueChange = { viewModel.updateInputText(it) }
)

Spacer(
modifier = Modifier.height(15.dp)
)

Button(
onClick = { onSubmit(viewModel.inputText.value) } // <-- add this
) {
Text(text = btnText)
}

}
}
}
}


// OPTIONAL - display component preview in Android Studio
@Preview
@Composable
fun TestFormPreview() {
TestForm(viewModel = TestFormModel(), onSubmit = {}) // <-- update
}

In ExpoViewDeclarativeView.kt we have to create EventDispatcher instance and call this event each as onSubmit event. Ensure that the instance has the same name as passed event property (onSubmit, in our case):

package expo.modules.viewdeclarative
...
import expo.modules.kotlin.viewevent.EventDispatcher //<-- add this
...

class ExpoViewDeclarativeView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
val viewModel = TestFormModel()

private val onSubmit by EventDispatcher() // <-- add this

internal val composeView = ComposeView(context).also {

...
it.setContent {
TestForm(
viewModel = viewModel,
onSubmit = { onSubmit(mapOf("inputText" to it)) } // <-- add this
)
}
...
}
}

Re-run the example application, type something to the text field, press the button and see the result 👀:

Passed event to Composable in React Native Expo app

Thats all, we’ve covered the most used cases in creating native view modules and interacting with it.

Conclusion

Now we have native module ready to be published or to be used locally in your projects. Publishing modules to the public repositories is a bit out of the scope of this article. But in a few simple steps, you can import your module to your personal project.

  1. Copy all content of the module (except node_modules and example folders) to the module/expo-view-declarative directory of your expo project.
  2. Run following command to add your module as a dependency to package.json
npm install ./module/expo-view-declarative

3. Import and use your module as a package 📦

import { ExpoViewDeclarativeView } from 'expo-view-declarative'

Thats all for now 🤗
Stay tuned for the next articles, in which we will also try to use Jetpack Compose and SwiftUI with bare React Native projects.

--

--

Andrei Khavkunov

React Native developer with a web development background. Passionate about crafting native mobile experience. Staying tuned with React Native community trends.