# Sizing a WebView Inside a SwiftUI ScrollView
Table of Contents
In Readeck the reader is a WebView, and I want it to feel like a normal SwiftUI screen. Hero image, title, article body, all scrolling as one, with native toolbars, safe areas and a reading progress bar riding along.
The catch is who gets to do the scrolling.
If the WebView scrolls itself, SwiftUI sees one fixed box with a scrollable thing inside, and everything above and below it stops behaving.
So I take scrolling away from the WebView and give it to a SwiftUI ScrollView instead.
This sounds trivial. It is not.
The whole screen is one ScrollViewLink to heading
The setup I want is a single scroll container with the WebView sitting in it like any other view:
@State private var webViewHeight: Double = 300
ScrollView { VStack(spacing: 0) { HeroImage(...) TitleView(...)
WebKit.WebView(webPage) .scrollDisabled(true) .frame(height: webViewHeight) }}Two lines do the heavy lifting here.
.scrollDisabled(true) hands scrolling to the outer ScrollView, and .frame(height:) pins the WebView to a fixed height so the layout has something to work with.
Which is exactly the problem.
A ScrollView can only lay out a child if it knows how tall that child is, and I just promised it a number in webViewHeight.
Right now that number is 300, a guess, and the WebView renders HTML of whatever height it likes and keeps the real one to itself.
Only the page knows how tall it isLink to heading
The one thing that actually knows the rendered height is the page, through document.body.scrollHeight.
So the height has to travel out of JavaScript, into Swift, and into that webViewHeight state.
That trip runs over the same Swift-pulls-from-the-page channel I wrote about in the last post, so here I will just use it.
On iOS 26 the cleanest moment to pull is when loading finishes, since WebPage.isLoading is observable:
.onChange(of: webPage.isLoading) { _, isLoading in if !isLoading { Task { await updateContentHeight() } }}
private func updateContentHeight() async { let result = try? await webPage.callJavaScript("return document.body.scrollHeight") if let height = result as? Double { webViewHeight = height }}That last line is the whole trick.
Writing webViewHeight updates the .frame(height:) from the first snippet, SwiftUI relays out the ScrollView, and the WebView snaps to exactly its content height.
The loop closes: page measures, Swift stores, layout follows.
Load finishes, you ask the page for its height, the screen fits. Done.
Except it is not done.
One measurement is a lieLink to heading
The height at “loaded” is a snapshot of a page that is still moving. Images decode and push everything down, web fonts swap in and reflow the text, the user bumps the font size or rotates the phone. Measure once and you lock in a number that was wrong a tenth of a second later.
So I measure a few more times, with growing delays, and I ignore anything that barely moved:
let delays = [0.1, 0.2, 0.5, 1.0, 1.5, 2.0]var lastHeight: Double = 0
for delay in delays { try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) guard let height = try? await webPage.callJavaScript("return document.body.scrollHeight") as? Double, height > 0 else { continue } if lastHeight == 0 || abs(height - lastHeight) > 5 { webViewHeight = height lastHeight = height }}Two small decisions carry this:
- Growing delays. Early checks catch fast changes like fonts, later ones catch the slow stuff like big images, without polling forever.
- A five point threshold. Sub-pixel reflows fire constantly, and forwarding every one makes the whole layout twitch. Below five points, I do not care.
And when JavaScript never returns a usable height, a malformed article or a stalled page, I do not collapse the reader to a blank rectangle. I estimate a height from the plain text length, clamp it to something sane, and move on. A roughly right reader beats an empty one.
None of this is the cleanest code I have ever written, and I am fine with that. The app has to feel good and be fun to use, and that comes first. The technique is just the means to get there, so I stay pragmatic and let the experience win.