spike(epub): native column paging — fix paginated reflowable foreground resume drift - #5
Closed
raphi011 wants to merge 3 commits into
Closed
spike(epub): native column paging — fix paginated reflowable foreground resume drift#5raphi011 wants to merge 3 commits into
raphi011 wants to merge 3 commits into
Conversation
Disable `UIScrollView.isPagingEnabled` for paginated reflowable spreads and snap manually in `scrollViewWillEndDragging`. A paging scroll view keeps its own page state and re-snaps `contentOffset` to it on layout changes — including when iOS restores the view on a background→foreground transition. Because the page position is driven by JavaScript (`window.scrollBy`) here, not UIKit, that re-snap can land one column off (the reported "resume one page back" drift after foregrounding). Owning the snap removes UIKit's page state from the equation; the column index becomes the source of truth in the follow-up commit. Also adds overridable column-scroll delegate hooks on EPUBSpreadView, matching the existing scrollViewDidScroll pattern. Spike for the foreground-resume-drift investigation; behaviour not yet verified on device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…yout Make the column index the source of truth for the horizontal position in paginated reflowable mode. The scroll offset is re-derived from it after a layout pass whose page width is unchanged (`layoutSubviews`), so an external re-snap — e.g. iOS restoring the scroll view on a background→foreground transition — can no longer leave the reader one column off. A width change (rotation / font size / Split View) re-baselines instead, deferring to the navigator's existing locator re-pin. The column index is captured only from genuine intent (user drag end, programmatic `go`), never from a passive scroll report, so a stray re-snap can't corrupt the truth before the re-assert corrects it. Spike for the foreground-resume-drift investigation; behaviour not yet verified on device. The JS-scroll / re-assert interaction (the readium#737 surface) needs on-device validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t + flash) The native-column-paging spike owned the column index but re-asserted the derived offset only in `layoutSubviews`, which does not fire on a plain background→foreground (no relayout): WebKit silently re-clamps the purged web view's `contentOffset.x` one column back, and nothing corrects it. Two fixes make the re-assert actually run, and run early enough to avoid the flash: 1. Observe `UIApplication.didBecomeActiveNotification` and re-assert there. `didBecomeActive` fires while iOS is still showing the app snapshot, before the live web view is revealed, so the corrected offset is what gets composited — no visible one-column flash. (Removed by the base-class deinit.) 2. `captureCurrentColumn()` now records `lastReconciledWidth` alongside the column. Previously it was only set in `layoutSubviews`, so a spread that loaded, scrolled to position and sat idle kept `lastReconciledWidth == 0`, sending the re-assert's same-grid guard down the "width changed" branch and blocking the correction. The shared re-assert is extracted into `reassertColumnIfDrifted()` (called from both `layoutSubviews` and the foreground hook). Measured on iPhone 17 Pro sim (iOS 26.3), forcing the WebKit purge via Simulate Memory Warning while backgrounded: - baseline (no fix): 5/8 cycles drift one column back - spike (this branch's parent): 7/16 drift - with this commit: 8/16 cycles still drift at didBecomeActive, ALL 8 caught and corrected before the reveal; 0/16 persistent drift, 0 drifted locator emissions (flash-free signature). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner
Author
|
Closing — the spike's premise didn't survive measurement. The foreground resume drift turned out to be app-side, not a WebKit offset re-clamp: a color-scheme flip during the backgrounded snapshot pass triggered a theme-only preference update, whose reflow re-anchor ( |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Spike (draft, fork-only — not for upstream yet) that fixes the paginated-reflowable "resume one column back, with a brief visible flash" bug on a background→foreground transition, by having the navigator own the column paging instead of delegating it to
UIScrollView.isPagingEnabled. Opened against the fork's integration branch so the diff footprint is reviewable before deciding whether to pitch it toreadium/swift-toolkit.This PR is self-contained; everything needed to review it is below.
The bug
Open a paginated reflowable EPUB, read a few pages, send the app to the background (Home / app switcher), wait ~2 s, foreground it again — no rotation, no settings change. The reader is now showing the previous page (one column back). A second, quieter symptom rides along: that stale position is treated as linear reading and can be persisted over the correct one. The wrong column is also briefly visible on the way back (the "flash").
Reproduced reliably on iPhone simulator (single-column paginated reflowable).
Root cause (measured)
Instrumented the four layers across the
scenePhase .activetransition (SwiftUI container size, container/navigator bounds, and the spreadWKWebView'sscrollView.contentOffset.x/contentSize.width). On a representative run (single column, page width 402):contentOffset.xcontentSize.width924610854884410854contentSize.widthis identical (10854) → no relayout; the column grid is not recomputed.viewDidLayoutSubviewsnever fires → the app-side hierarchy is innocent.contentOffset.x:9246 → 8844, exactly −402 = one column, and it moves while backgrounded (already drifted at the first return event, before any foreground layout).23×402,22×402).That last point is the tell. An exact one-page step with no relayout is the signature of a
UIScrollViewpaging re-snap:isPagingEnabledkeeps its own notion of the current page, but reflowable column navigation here is driven from JavaScript (window.scrollByfor page turns; progression read fromwindow.scrollX), not from UIKit. The two notions desync, and when iOS restores the paging scroll view on resume it re-snapscontentOffsetto its believed page — one column back. On foreground the navigator reads the moved offset and emits a driftedLocator, and the live web view is revealed at the wrong column before any client signal can correct it (the flash).EPUBReflowableSpreadViewalready scrolls page turns via JS specifically to avoidsetContentOffsetglitches (see the comment referencing readium#737), which is exactly why UIKit's paging state and the real column position drift apart.The fix — 2 commits
1. Own the column paging instead of
UIScrollView.isPagingEnabled.Disable
isPagingEnabledfor paginated reflowable spreads, setdecelerationRate = .fast, and snap a free-scrolling drag to the nearest column boundary inscrollViewWillEndDragging(a clear fling advances one column). With UIKit no longer owning a page index, there is nothing for it to re-snap on resume. Adds overridable column-scroll delegate hooks onEPUBSpreadView, mirroring the existingscrollViewDidScrolloverride pattern.2. Make the column index the source of truth and re-assert it across a same-width relayout.
Track
currentColumn, captured only from genuine intent (user drag end, programmaticgo), never from a passive scroll report. After alayoutSubviewspass whose page width is unchanged, re-derivecontentOffset.xfromcurrentColumn, so any external re-snap (e.g. an OS scroll-view restore on foreground) is corrected deterministically. A width change (rotation / font size / Split View) re-baselines instead and defers to the navigator's existing locator re-pin — the column index is meaningless once the grid reflows.Design principle: in a paginated column layout the page index is the durable position; the pixel
contentOffsetis a derived value that must be recomputed for the current geometry, never trusted across a layout event.Status
xcodebuild -scheme Readium-Package -destination 'generic/platform=iOS Simulator', clean derived data,** BUILD SUCCEEDED **.Reviewer checklist (on device / simulator)
isPagingEnabled = falseis sufficient, commit 2's re-assert can be dropped (≈50-line change, much lower risk). The commits are split precisely to test this.decelerationRatefeel.layoutSubviewseven fires on the foreground transition (if not, commit 1 must be the load-bearing fix).viewModel.scroll) is untouched — guards bail out early; confirm no regression.Open questions for an eventual upstream pitch
Files changed
Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift(+126)Sources/Navigator/EPUB/EPUBSpreadView.swift(+16)