How to Create a Content-Sized WebView in .NET MAUI Mobile: A Step-by-Step Guide
Why having a content-sized WebView?
Sometimes, an app needs to display content created on the web, perhaps through a WYSIWYG editor, and typically, we present it within a well-defined area of the screen.
There are other situations where your WebView
might be in a vertical scrollable area with other views placed below it. Even more challenging, you might need to display multiple WebViews
in a CollectionView
.
Unfortunately, the WebView
component doesn't offer a way to size itself based on the content. Therefore, we need to create our own version that can detect the content size and trigger a new layout pass as needed.
Preparing our HTML to scale with the screen density
If we don’t do anything, our HTML will render very small on our high-density screens.
A first step is to make sure our <head>
node contains the right viewport
specification.
<meta name='viewport' content='width=device-width; initial-scale=1.0; maximum-scale=1.0; user-scalable=0;'/>
When that is done, make sure to prepare your new HtmlWebViewSource { Html = myHtmlSource }
which will be set as Source
on the WebView
.
Creating our custom control
We don’t want to mess up with the standard WebView
used for “full-screen” purposes, so it’s better to create a new control which inherits from it.
public class ContentSizedWebView : WebView;
Then, we need to create a custom handler based on WebViewHandler
and register it with the app builder.
var builder = MauiApp.CreateBuilder();
builder.ConfigureMauiHandlers(handlersCollection =>
{
#if IOS || ANDROID
handlersCollection.AddHandler<ContentSizedWebView, Handlers.ContentSizedWebViewHandler>();
#endif
});
Creating a web view custom Handler for iOS
The iOS implementation of the WebView is based on UIScrollView
so observing the content size will trivial.
We just need to create an observer on the contentSize
property of the scroll view.
protected override void ConnectHandler(WKWebView platformView)
{
base.ConnectHandler(platformView);
var weakHandler = new WeakReference<ContentSizedWebViewHandler>(this);
this._contentSizeObserver = platformView.ScrollView.AddObserver(
"contentSize",
NSKeyValueObservingOptions.New,
_ =>
{
if (weakHandler.TryGetTarget(out var handler))
{
handler.Invoke(nameof(IView.InvalidateMeasure), null);
}
}
);
}
At this point, we just hop into the MAUI layout pass and customize the desired size getter to read the UISrollView.ContentSize
property.
public override Size GetDesiredSize(double widthConstraint, double heightConstraint)
{
var scrollViewContentSize = PlatformView.ScrollView.ContentSize;
double width;
double height;
if (widthConstraint <= 0 || double.IsInfinity(widthConstraint))
{
width = scrollViewContentSize.Width;
}
else
{
width = Math.Min(widthConstraint, scrollViewContentSize.Width);
}
if (heightConstraint <= 0 || double.IsInfinity(heightConstraint))
{
height = scrollViewContentSize.Height;
}
else
{
height = Math.Min(heightConstraint, scrollViewContentSize.Height);
}
return new Size(width, height);
}
Creating a web view custom Handler for Android
This is where it gets complicated: the web view here is not based on a scroll view so there’s no easy way to get the content size.
The only way to achieve that is through JavaScript code ContentSizeObserverBridge.InitializerScript
injected into the page once it loads.
public class ContentSizedWebViewClient : MauiWebViewClient
{
public override void OnPageFinished(WebView? view, string? url)
{
base.OnPageFinished(view, url);
view.EvaluateJavascript(ContentSizeObserverBridge.InitializerScript, null);
}
Unfortunately on a mobile web view we cannot trust any of the body.*Height
properties because they’ll simply match the viewport size.
What we have to do is using the ResizeObserver
on the body and then loop through the children and analyze the boundingClientRect
together with eventual margins.
(function() {
var lastHeight = -1;
var lastWidth = -1;
function getContentSize() {
var body = document.body;
var contentSize = Array
.from(body.children)
.map(e => e.getBoundingClientRect())
.reduce((a, e) => ({
width: Math.max(a.width, e.width + e.x),
height: Math.max(a.height, e.height + e.y)}), { width: 0, height: 0 });
var bodyStyle = getComputedStyle(body);
contentSize.width += parseInt(bodyStyle.marginRight);
contentSize.height += parseInt(bodyStyle.marginBottom);
if (bodyStyle.boxSizing !== 'border-box') {
contentSize.width += parseInt(bodyStyle.borderRightWidth) + parseInt(bodyStyle.paddingRight);
contentSize.height += parseInt(bodyStyle.borderBottomWidth) + parseInt(bodyStyle.paddingBottom);
}
return contentSize;
}
function contentSizedWebViewCheckSize() {
var contentSize = getContentSize();
var newHeight = contentSize.height;
var newWidth = contentSize.width;
if (newHeight !== lastHeight || newWidth !== lastWidth) {
lastHeight = newHeight;
lastWidth = newWidth;
window.ContentSizedWebViewHandler.contentSizeChanged(newWidth + '|' + newHeight);
}
}
var observer = new ResizeObserver((entries) => contentSizedWebViewCheckSize());
observer.observe(document.body, { box: "content-box" });
contentSizedWebViewCheckSize();
})();
Our script will observe the content size from inside and report any change via JavaScript bridge interface window.ContentSizedWebViewHandler.contentSizeChanged
.
public class ContentSizedWebViewHandler : WebViewHandler
{
protected override WebView CreatePlatformView()
{
var platformView = base.CreatePlatformView();
platformView.AddJavascriptInterface(
new ContentSizeObserverBridge(this),
nameof(ContentSizedWebViewHandler));
return platformView;
}
public void OnContentSizeChanged(Size size)
{
_contentSize = size;
Invoke(nameof(IView.InvalidateMeasure), null);
}
public class ContentSizeObserverBridge : Java.Lang.Object
{
[JavascriptInterface]
[Export("contentSizeChanged")]
public void InvokeAction(string? data)
{
if (data == null || !_weakHandler.TryGetTarget(out var handler)) return;
var sizeParts = data.Split('|');
if (sizeParts.Length != 2 ||
!double.TryParse(sizeParts[0], out var width) ||
!double.TryParse(sizeParts[1], out var height)) return;
if (width == _contentWidth && height == _contentHeight)
{
return;
}
_contentWidth = width;
_contentHeight = height;
handler.VirtualView
.Dispatcher
.Dispatch(() =>
{
handler.OnContentSizeChanged(new Size(width, height));
});
}
Now we just need to customize our WebViewClient
at mapper level.
public class ContentSizedWebViewHandler : WebViewHandler
{
public static void MapContentSizedWebChromeClient(IWebViewHandler handler, IWebView webView)
{
if (handler is ContentSizedWebViewHandler platformHandler)
{
handler.PlatformView.SetWebChromeClient(new ContentSizedWebChromeClient(platformHandler));
}
}
public static void MapContentSizedWebViewClient(IWebViewHandler handler, IWebView webView)
{
if (handler is ContentSizedWebViewHandler platformHandler)
{
handler.PlatformView.SetWebViewClient(new ContentSizedWebViewClient(platformHandler));
}
}
Finally, we can use the reported size in public override Size GetDesiredSize(double widthConstraint, double heightConstraint)
just like we do for iOS.
Show me the complete code!
You can see a working example at https://github.com/albyrock87/maui-content-sized-webview
Subscribe to my newsletter
Read articles from Alberto Aldegheri directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by