Expo Native View with SwiftUI

Andrei Khavkunov
11 min readMay 20, 2024

--

Expo handshakes SwiftUI

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

This is the second part of articles on how to create native views in Expo using native declarative frameworks. Check out my previous article we investigated how we can create native view for Android part of React Native Expo projects using 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 iOS development experience in React Native Expo 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 SwiftUI elements on the native side in React Native Expo project.

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

❗️If you went through my previous article about configuring native view with Jetpack Compose, please, skip this part and jump right to the Step 1.

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 ios, 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 SwiftUI view

Lets take a look into ios folder of our module’s project. This folder is responsible for ios native part of the module. There are three files.

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

ExpoViewDeclarativeView.swift is responsible for configuring and displaying native view on the React side.

ExpoViewDeclarative.podspec is analog of package.json, which is also responsible for ios module configs, such as dependencies, description and so on.

❗️I strongly recommend to use Xcode for interaction with view these files.

Lets open example project in Xcode. You can do it manually by opening example/ios/expoviewdeclarativeexample.xcworkspace or run a npm command from the root package.json file :

npm run open:ios

Also run our expo example development server:

npm start --prefix example

In Xcode project you will se the following structure. ExpoViewDeclarative is located in “Pods/Development Pods” directory and contains that exactly files from ios folder that we reviewed above.

Xcode example project structure
Xcode example project structure

First of all, lets clean ExpoViewDeclarativeModule.swift file from everything that we won’t need during this tutorial. For our purposes, we only need to keep definitions of the module name and the view.

import ExpoModulesCore

public class ExpoViewDeclarativeModule: Module {
public func definition() -> ModuleDefinition {

Name("ExpoViewDeclarative")

View(ExpoViewDeclarativeView.self) {
Prop("name") { (view: ExpoViewDeclarativeView, prop: String) in
print(prop)
}
}
}
}

In the same directory add new file TestForm.swift, this will be our root SwiftUI file:

import SwiftUI

struct TestForm: View {
var body: some View {
VStack {
VStack{
TextField(
"Input",
text: .constant("Value") // temporary mockup
)
.textFieldStyle(.roundedBorder)

Spacer()
.frame(height: 25)

Button(action: {
// TODO
}) {
Text("Click me")
.foregroundColor(.white)
.padding(10)
.background(Color.blue)
.cornerRadius(10)
}

}
.padding()
.background(Color.gray.opacity(0.5))
.cornerRadius(10)

}
.padding()
}}

In order to display our TestForm in the example app, we have to fill ExpoViewDeclarativeView.swift with the following code:

import ExpoModulesCore
import SwiftUI
import UIKit

class ExpoViewDeclarativeView: ExpoView {

private let contentView: UIHostingController<TestForm>

required init(appContext: AppContext? = nil) {
contentView = UIHostingController(rootView: TestForm())

super.init(appContext: appContext)

clipsToBounds = true
addSubview(contentView.view)
}

override func layoutSubviews() {
contentView.view.frame = bounds
}
}

With the help of the UIHostingController we were able to programmatically display SwiftUI element in UIView.

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

SwiftUI element 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 Xcode 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 Xcode 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 a key part of SwiftUI’s reactive design approach — ObservableObject as a ViewModel. It’s a a protocol that allows an object to notify views about changes to its properties.

Update TestForm.swift file with the following changes:

import SwiftUI


class TestFormViewModel : ObservableObject { // <-- add this
@Published var inputText = "Initial Value" // <-- add this
@Published var btnText = "" // <-- add this
}

struct TestForm: View {

@StateObject var viewModel: TestFormViewModel // <-- add this

var body: some View {
VStack {
VStack{
TextField(
"Input",
text: $viewModel.inputText // <-- add this
)
.textFieldStyle(.roundedBorder)

Spacer()
.frame(height: 25)

Button(action: {
// TODO
}) {
Text(viewModel.btnText). // <-- update this
.foregroundColor(.white)
.padding(10)
.background(Color.blue)
.cornerRadius(10)
}

}
.padding()
.background(Color.gray.opacity(0.5))
.cornerRadius(10)

}
.padding()
}

}

You might encounter the following error in Xcode. To solve this, we will update supported iOS version to 14.0. It’s quite old version, and covers the most variety of the Apple devices.
❗️ If you need to support earlier iOS versions, try out this 👉 implementation of StateObject 👈.

Update s.platforms property in ios/ExpoViewDeclarative.podspec file

...

Pod::Spec.new do |s|
...
s.homepage = package['homepage']
s.platforms = { :ios => '14.0', :tvos => '14.0' } # <-- update this row
s.swift_version = '5.4'
...
end

Also, update root Podfile, it also has to support ios 14 as a minimal target:

...
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']

platform :ios, podfile_properties['ios.deploymentTarget'] || '14.0' # <-- update this row
install! 'cocoapods',
:deterministic_uuids => false
...

And set Minimum Deployment target in example to iOS 14 in order to be able to test everything we’ve done.

Set up minimum deployment ios version

After all, open example/ios directory in terminal and run following command to update Pods in order to apply the recent changes:

pod install

Now we can follow up in our development process 🤗

Update ExpoViewDeclarativeView.swift file, to initialize TestFormViewModel:

...

class ExpoViewDeclarativeView: ExpoView {

private let contentView: UIHostingController<TestForm>
let viewModel = TestFormViewModel() // <-- add this

required init(appContext: AppContext? = nil) {
contentView = UIHostingController(
rootView: TestForm(viewModel: self.viewModel) // <--- update this
)

...
}

...
}

❗️ It’s important to declare viewModel as a class public property, because we will need to have access to this property when we will have to update in ExpoViewDeclarativeModule.swift as we receive new property values from the React side.

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

import ExpoModulesCore

public class ExpoViewDeclarativeModule: Module {

public func definition() -> ModuleDefinition {
...
View(ExpoViewDeclarativeView.self) {

Prop("btnText") { (view: ExpoViewDeclarativeView, prop: String) in // <-- update
view.viewModel.btnText = 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 SwiftUI component 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 Xcode 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.swift:

import ExpoModulesCore

public class ExpoViewDeclarativeModule: Module {

public func definition() -> ModuleDefinition {

Name("ExpoViewDeclarative")

View(ExpoViewDeclarativeView.self) {

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

Prop("btnText") { (view: ExpoViewDeclarativeView, prop: String) in
view.viewModel.btnText = prop
}
}
}
}

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

import SwiftUI


class TestFormViewModel : ObservableObject {
@Published var inputText = "Initial Value"
@Published var btnText = ""
}

struct TestForm: View {

@StateObject var viewModel: TestFormViewModel

var onSubmit: (String) -> Void // <-- add this


var body: some View {
VStack {
VStack{
TextField(
"Input",
text: $viewModel.inputText
)
.textFieldStyle(.roundedBorder)

Spacer()
.frame(height: 25)

Button(action: {
onSubmit(viewModel.inputText) // <-- add this
}) {
Text(viewModel.btnText)
.foregroundColor(.white)
.padding(10)
.background(Color.blue)
.cornerRadius(10)
}

}
.padding()
.background(Color.gray.opacity(0.5))
.cornerRadius(10)

}
.padding()
}}

In ExpoViewDeclarativeView.swift we have to tie everything together, by initializing corresponding EventDispatcher. Ensure that the instance has the same name as passed event property (onSubmit, in our case):

...
class ExpoViewDeclarativeView: ExpoView {

...
let onSubmit: EventDispatcher // <-- add this

required init(appContext: AppContext? = nil) {
onSubmit = EventDispatcher() // <-- add this

var handleSubmit: ((String) -> Void)? // <-- add this

contentView = UIHostingController(
rootView: TestForm(
viewModel: self.viewModel,
onSubmit: { inputText in. // <-- add this
handleSubmit?(inputText). // <-- add this
} // <-- add this
)
)

super.init(appContext: appContext)

handleSubmit = { inputText in // <-- add this
self.onSubmit(["inputText": inputText]) // <-- add this
} // <-- add this

clipsToBounds = true
addSubview(contentView.view)
}
...
}

❗️In this code handleSubmit is declared as nullable and declared before super.init(appContext: appContext) and defined after. It is done in order to avoid following Swift error. It is quite common error in Swift, as closeures can’t have an access to the self instance before super class constructor was called. And we can not move contentView initialization after super.init because it has to be defined before super.init. Thats why we have to apply such workaround 🤷‍♂️. If you have a better solution, post it in the comment:

‘self’ captured by a closure before all members were initialized
Error if we define handleSubmit callback before super.init()

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

Passed event to SwiftUE element 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 try to apply our new skills in Jetpack Compose and SwiftUI with bare React Native projects.
And don’t forget to check my previous article about creating Expo Native View with Jetpack Compose.

--

--

Andrei Khavkunov
Andrei Khavkunov

Written by Andrei Khavkunov

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

Responses (1)