# Scroll Position and Reading Progress in a SwiftUI ScrollView with a WebView

Ilyas Hallak 4 min read
Table of Contents

In Readeck the reader is a WebView living inside a SwiftUI ScrollView, which is a story of its own. Once it sits there, the interesting question is not whether it scrolls smoothly. It is: how far did I get in this article, and can you drop me back exactly where I stopped.

That one feature, resume reading, is the thing I actually use every day. The rest of this is just what it took to make it work.

Knowing how far you’ve readLink to heading

My first attempt was the obvious one: just read the scroll position directly, partly by asking JavaScript inside the WebView. It was slow, and worse, reading the position kept triggering a re-render, which re-ran the measurement, which triggered another render. I had walked straight into a loop I could not climb out of, where the act of measuring was itself causing the thing I was measuring to change. It took me longer than I would like to admit to see that the fix was to stop pulling and start listening.

The content is in a SwiftUI ScrollView, so SwiftUI already knows where the end of the article is. So instead of forcing a read, I drop an invisible marker at the bottom and let SwiftUI report its position through a preference key:

Color.clear
.frame(height: 1)
.background(GeometryReader { endGeo in
Color.clear.preference(
key: ContentHeightPreferenceKey.self,
value: endGeo.frame(in: .named("scrollView")).maxY
)
})

No JavaScript, no manual polling, and no feedback loop. The position arrives as part of SwiftUI’s normal layout pass, instead of me forcing a read that forces a render that forces another read. A small pure ScrollTracker turns that stream of positions into a 0...1 progress value. It is import Foundation and nothing else, no SwiftUI, which is why it can carry a pile of unit tests while the view carries none.

Then I make that number visible. A thin bar at the top of the reader shows progress in the article you are in:

ProgressView(value: readingProgress)
.progressViewStyle(.linear)
.frame(height: 3)

And in the bookmark list, each article wears a little circular ring with its percentage, so “half read” and “almost done” are visible before you even open it. You should not have to open an article to remember whether you already finished it.

Take me back to where I wasLink to heading

Progress is only useful if it survives you closing the app. So on every meaningful change I push it out, debounced by a second so scrolling does not hammer the server:

readProgressSubject
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
.sink { id, progress, anchor in
// sync to the server
}

When you reopen the article, I load the saved progress and keep the higher of local and server, so the furthest point you reached always wins:

readProgress = max(readProgress, serverProgress)

Now the actual payoff. If you are somewhere between 0 and 100 percent, a button appears offering to take you there:

// button visible only mid-article
showJumpToProgressButton = progress > 0 && progress < 100
// on tap: map the saved percentage onto real scroll offset
let maxOffset = webViewHeight - containerHeight
let offset = maxOffset * (Double(readProgress) / 100.0)
scrollPosition = ScrollPosition(y: offset)

I made it a button on purpose, not an automatic jump. Auto-scrolling someone the instant a page opens is disorienting, and sometimes you do want to start from the top. So I show “Jump to last read position (63%)” and let you decide.

Keep it calm, and let people switch it offLink to heading

Because all of this reacts to scrolling, it reacts constantly, and naive versions feel nervous. The ScrollTracker smooths that with a couple of thresholds: report progress only after it moves about three percent, and hide or show the toolbar with hysteresis so it does not flicker when your thumb wobbles.

let progressThreshold = 0.03 // ignore sub-3% noise
var scrollUpThresholdRatio: CGFloat = 0.12 // show toolbar again
var scrollDownThresholdRatio: CGFloat = 0.06 // hide it

But smooth or not, not everyone wants a progress bar staring at them. So the whole thing is optional. The reader settings let you hide the progress bar, the word count, the hero image and the summary independently:

if !(settings.hideProgressBar ?? false) {
ProgressView(value: readingProgress).frame(height: 3)
}

That is the part I care about most. The feature that lets me pick up where I left off is the same one someone else finds cluttered, and both of us should get the reader we want. When it works, none of this engineering is visible anyway: it just quietly puts you back on the sentence you stopped at.

My avatar

Thanks for reading my blog post! Feel free to check out my other posts or contact me via the social links in the footer.


More Posts