Edit this page
A tutorial on creating a native view that renders a WebView with Expo Modules API.
Edit this page
In this tutorial, we are going to build a module with a native view that will render a WebView. We will be using the WebView component for Android and WKWebView for iOS. It is possible to implement web support using iframe
, but we'll leave that as an exercise for the reader.
First, we'll create a new module. On this page, we will use the name expo-web-view
/ExpoWebView
. You can name it whatever you like, just adjust the instructions accordingly:
-Â
npx create-expo-module expo-web-view
Tip: Since you aren't going to actually ship this library, you can hit return for all the prompts to accept the default values.
Now let's clean up the default module a little bit so we have more of a clean slate and delete the code that we won't use in this guide.
-Â
cd expo-web-view
-Â
rm src/ExpoWebView.types.ts src/ExpoWebViewModule.ts
-Â
rm src/ExpoWebView.web.tsx src/ExpoWebViewModule.web.ts
Find the following files and replace them with the provided minimal boilerplate:
import ExpoModulesCore
public class ExpoWebViewModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoWebView")
View(ExpoWebView.self) {}
}
}
package expo.modules.webview
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoWebViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoWebView")
View(ExpoWebView::class) {}
}
}
export { default as WebView, Props as WebViewProps } from './ExpoWebView';
import { ViewProps } from 'react-native';
import { requireNativeViewManager } from 'expo-modules-core';
import * as React from 'react';
export type Props = ViewProps;
const NativeView: React.ComponentType<Props> = requireNativeViewManager('ExpoWebView');
export default function ExpoWebView(props: Props) {
return <NativeView {...props} />;
}
import { WebView } from 'expo-web-view';
export default function App() {
return <WebView style={{ flex: 1, backgroundColor: 'purple' }} />;
}
Now let's run the example project to make sure everything is working. We'll need to start the TypeScript compiler to watch for changes and rebuild the module JavaScript, and separately in another terminal window we'll compile and run the example app.
# Run this in the root of the project to start the TypeScript compiler
-Â
npm run build
-Â
cd example
# Run the example app on iOS
-Â
npx expo run:ios
# Run the example app on Android
-Â
npx expo run:android
We should now see a blank purple screen. That's not very exciting. However, it's a good start. Let's make it a WebView now.
Now we are going to add the system WebView with a hardcoded URL as a subview of our ExpoWebView. Our ExpoWebView
class extends ExpoView
, which extends RCTView
from React Native, which finally extends UIView
on iOS and View
on Android. We need to ensure that the WebView subview has the same layout as ExpoWebView, whose layout will be calculated by React Native's layout engine.
On iOS, we set clipsToBounds
to true
and set the frame
of the WebView to the bounds of the ExpoWebView in layoutSubviews
to match the layout. init
is called when the view is created, and layoutSubviews
is called when the layout changes.
import ExpoModulesCore
import WebKit
class ExpoWebView: ExpoView {
let webView = WKWebView()
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
clipsToBounds = true
addSubview(webView)
let url = URL(string:"https://docs.expo.dev/modules/")!
let urlRequest = URLRequest(url:url)
webView.load(urlRequest)
}
override func layoutSubviews() {
webView.frame = bounds
}
}
On Android we use LayoutParams
to set the layout of the WebView to match the layout of the ExpoWebView. We can do this when we instantiate the WebView.
package expo.modules.webview
import android.content.Context
import android.webkit.WebView
import android.webkit.WebViewClient
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView
class ExpoWebView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
internal val webView = WebView(context).also {
it.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
it.webViewClient = object : WebViewClient() {}
addView(it)
it.loadUrl("https://docs.expo.dev/modules/")
}
}
No changes are needed, we can rebuild and run the app and you will see the Expo Modules API overview page.
To set a prop on our view, we'll need to define the prop name and setter inside of ExpoWebViewModule
. In this case we're going to reach in and access webView
property directly for convenience, but in many real world cases you will likely want to keep this logic inside of the ExpoWebView
class and minimize the knowledge that ExpoWebViewModule
has about the internals of ExpoWebView
.
We use the Prop definition component to define the prop. Within the prop setter block we can access the view and the prop. Note that we specify the url is of type URL
— the Expo modules API will take care of converting strings to the native URL
type for us.
import ExpoModulesCore
public class ExpoWebViewModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoWebView")
View(ExpoWebView.self) {
Prop("url") { (view, url: URL) in
if view.webView.url != url {
let urlRequest = URLRequest(url: url)
view.webView.load(urlRequest)
}
}
}
}
}
package expo.modules.webview
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.net.URL
class ExpoWebViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoWebView")
View(ExpoWebView::class) {
Prop("url") { view: ExpoWebView, url: URL? ->
view.webView.loadUrl(url.toString())
}
}
}
}
All we need to do here is add the url
prop to the Props
type.
import { ViewProps } from 'react-native';
import { requireNativeViewManager } from 'expo-modules-core';
import * as React from 'react';
export type Props = {
url?: string;
} & ViewProps;
const NativeView: React.ComponentType<Props> = requireNativeViewManager('ExpoWebView');
export default function ExpoWebView(props: Props) {
return <NativeView {...props} />;
}
Finally, we can pass in a URL to our WebView component in the example app.
import { WebView } from 'expo-web-view';
export default function App() {
return <WebView style={{ flex: 1 }} url="https://expo.dev" />;
}
When you rebuild and run the app, you will now see the Expo homepage.
View callbacks allow developers to listen for events on components. They are typically registered through props on the component, for example: <Image onLoad={...} />
. We can use the Events definition component to define an event for our WebView. We'll call it onLoad
as well.
On iOS, we need to implement webView(_:didFinish:)
and make ExpoWebView extend WKNavigationDelegate
. We can then call the onLoad
from that delegate method.
import ExpoModulesCore
import WebKit
class ExpoWebView: ExpoView, WKNavigationDelegate {
let webView = WKWebView()
let onLoad = EventDispatcher()
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
clipsToBounds = true
webView.navigationDelegate = self
addSubview(webView)
}
override func layoutSubviews() {
webView.frame = bounds
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
if let url = webView.url {
onLoad([
"url": url.absoluteString
])
}
}
}
And we need to indicate in ExpoWebViewModule that the View
has an onLoad
event.
import ExpoModulesCore
public class ExpoWebViewModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoWebView")
View(ExpoWebView.self) {
Events("onLoad")
Prop("url") { (view, url: URL) in
if view.webView.url != url {
let urlRequest = URLRequest(url: url)
view.webView.load(urlRequest)
}
}
}
}
}
On Android, we need to add override the onPageFinished
function. We can then call the onLoad
event handler that we defined in the module.
package expo.modules.webview
import android.content.Context
import android.webkit.WebView
import android.webkit.WebViewClient
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
class ExpoWebView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
private val onLoad by EventDispatcher()
internal val webView = WebView(context).also {
it.layoutParams = LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT
)
it.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
onLoad(mapOf("url" to url))
}
}
addView(it)
}
}
And we need to indicate in ExpoWebViewModule that the View
has an onLoad
event.
package expo.modules.webview
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.net.URL
class ExpoWebViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoWebView")
View(ExpoWebView::class) {
Events("onLoad")
Prop("url") { view: ExpoWebView, url: URL? ->
view.webView.loadUrl(url.toString())
}
}
}
}
Note that event payloads are included within the nativeEvent
property of the event, so to access the url
from the onLoad
event we would read event.nativeEvent.url
.
import { ViewProps } from 'react-native';
import { requireNativeViewManager } from 'expo-modules-core';
import * as React from 'react';
export type OnLoadEvent = {
url: string;
};
export type Props = {
url?: string;
onLoad?: (event: { nativeEvent: OnLoadEvent }) => void;
} & ViewProps;
const NativeView: React.ComponentType<Props> = requireNativeViewManager('ExpoWebView');
export default function ExpoWebView(props: Props) {
return <NativeView {...props} />;
}
Now we can update the example app to show an alert when the page has loaded. Copy in the following code, then rebuild and run your app, and you should see the alert!
import { WebView } from 'expo-web-view';
export default function App() {
return (
<WebView
style={{ flex: 1 }}
url="https://expo.dev"
onLoad={event => alert(`loaded ${event.nativeEvent.url}`)}
/>
);
}
Now that we have a web view, we can build a web browser UI around it. Have some fun trying to rebuild a browser UI, and maybe even add new native capabilities as needed (for example, to support a back or reload buttons). If you'd like some inspiration, there's a simple example below.
import { useState } from 'react';
import { ActivityIndicator, Platform, Text, TextInput, View } from 'react-native';
import { WebView } from 'expo-web-view';
import { StatusBar } from 'expo-status-bar';
export default function App() {
const [inputUrl, setInputUrl] = useState('https://docs.expo.dev/modules/');
const [url, setUrl] = useState(inputUrl);
const [isLoading, setIsLoading] = useState(true);
return (
<View style={{ flex: 1, paddingTop: Platform.OS === 'ios' ? 80 : 30 }}>
<TextInput
value={inputUrl}
onChangeText={setInputUrl}
returnKeyType="go"
autoCapitalize="none"
onSubmitEditing={() => {
if (inputUrl !== url) {
setUrl(inputUrl);
setIsLoading(true);
}
}}
keyboardType="url"
style={{
color: '#fff',
backgroundColor: '#000',
borderRadius: 10,
marginHorizontal: 10,
paddingHorizontal: 20,
height: 60,
}}
/>
<WebView
url={url.startsWith('https://') || url.startsWith('http://') ? url : `https://${url}`}
onLoad={() => setIsLoading(false)}
style={{ flex: 1, marginTop: 20 }}
/>
<LoadingView isLoading={isLoading} />
<StatusBar style="auto" />
</View>
);
}
function LoadingView({ isLoading }: { isLoading: boolean }) {
if (!isLoading) {
return null;
}
return (
<View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 80,
backgroundColor: 'rgba(0,0,0,0.5)',
paddingBottom: 10,
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
}}>
<ActivityIndicator animating={isLoading} color="#fff" style={{ marginRight: 10 }} />
<Text style={{ color: '#fff' }}>Loading...</Text>
</View>
);
}
Congratulations, you have created your first simple yet non-trivial Expo module with a native view for Android and iOS! Learn more about the API in the Expo Module API reference.
If you enjoyed this tutorial and haven't done the native module tutorial, see creating a native module.