Dynamic Height WebView from Static HTML in React Native Expo

Let’s say your app is consuming rich text content from an API. That content is returned via a get request and returns a response that is a plain-text string of HTML content. How would you display that content in your app? Rather than write a whole HTML parser, utilizing a WebView is an excellent choice but presents another problem: how can you size a WebView component dynamically to closely fit the variable content the app receives from the API. Here, I’ll outline the approach and one such solution to just this problem.

Demo: https://snack.expo.dev/@joem-rp/dynamic-height-webview

import WebView from 'react-native-webview';
import { openBrowserAsync } from "expo-web-browser";
import { Platform, useWindowDimensions } from "react-native";
import { useState } from "react";
export interface RichTextWebpartProps {
content: string;
}
export default function RichTextWebPart({ content }: RichTextWebpartProps) {
const { width } = useWindowDimensions();
const [sectionHeight, setSectionHeight] = useState(100);
const HTML_HEAD =
`<head><meta name="color-scheme" content="light"><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"><link href="https://fonts.cdnfonts.com/css/segoe-ui-4&quot; rel="stylesheet"><style type="text/css">* { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;font-size: 16px;font-weight: 400;line-height: 26px;text-align: left;color: 'black' !important; max-width: ${width}; word-wrap: break-word; white-space: normal; background-color: 'transparent' !important} h1,h2,h3,h4,h5,h6,h7 { font-size: 1.6em; font-weight: 500; line-height: 1.6em} pre { font-family: monospace;} blockquote {font-style: italic;} div {background-color: 'transparent' !important;} u, a {color: 'blue' !important} strong { font-weight: 600 } p { margin: 0 }}</style></head>`;
return (
<WebView
style={{
height: sectionHeight,
maxWidth: width,
backgroundColor: 'transparent',
}}
containerStyle={{
marginHorizontal: 16,
paddingVertical: 16,
maxWidth: width,
borderBottomWidth: 2,
}}
scalesPageToFit={Platform.OS === 'ios'}
scrollEnabled={false}
automaticallyAdjustContentInsets
injectedJavaScript={`
document.querySelectorAll('a').forEach(link => {
link.addEventListener('click', (event) => {
event.preventDefault();
window.ReactNativeWebView.postMessage(link.href);
});
});
setTimeout(function() {
window.ReactNativeWebView.postMessage(document.body.scrollHeight);
}, 100); // a slight delay seems to yield a more accurate value
`}
source={{
html: HTML_HEAD + content,
}}
onLoadProgress={() => console.log("loading…")}
onMessage={(event) => {
const data = event.nativeEvent.data;
// Handle the scrollHeight response
if (!isNaN(parseInt(data))) {
// If "data" is a number, we can use that the set the height of the webview dynamically
setSectionHeight(parseInt(data));
} else {
// Open the embedded web browser if the user clicks a link instead of reloading content within
// the webview itself
openBrowserAsync(data);
}
}}
onError={(e) => logger.debug(e, 'RichTextWebpart – Text')}
/>
);
}

The key part of this code is the injected javascript, which I’ll break down in detail:

      injectedJavaScript={`
          document.querySelectorAll('a').forEach(link => {
            link.addEventListener('click', (event) => {
              event.preventDefault();
              window.ReactNativeWebView.postMessage(link.href);
            });
          });
          setTimeout(function() { 
            window.ReactNativeWebView.postMessage(document.body.scrollHeight);
          }, 100); // a slight delay seems to yield a more accurate value
        `}

The first part of this injected javascript is a query selector to intercept and “click” events that occur on anchor tags within the embedded html, which allows us to handle these clicks outside of the actual webview later on. Following that, we include a single line of code that captures and transmits the scroll height of our little proto-webpage. For both these features, we use postMessage to hoist the result up to the parent WebView and receive those messages via the onMessage prop. Lastly, we take the value of scrollHeight and set that as the component height via a useState hook. In practice, here’s what that looks like:

Without setTimeoutWith setTimeout
A demonstration of the app code showing HTML rendering with some extra space at the end of each section

N.B – A former colleague of mine pointed out that by wrapping the onMessage call to report scrollHeight in a slight delay, we get a much more accurate value back – without the delay, there is a large block of extra space at the end of the of the formatted HTML (indicated by the line/divider in my demo). I suspect this is in part due to some additional styles within the component and injected into the WebView via my HTML_HEAD variable (an attempt to “normalize” the web content to more closely match the style of the app itself), but my attempts to mitigate that without a timeout have so far proven unsuccessful. Special thanks to Matthew Bennett for sharing a solution to this after stumbling across a Stack Overflow post.

Regardless, we now have a very close approximation of a variable height web content rendered in our app for consumption.

Thanks for reading!