Skip to content

spike(epub): native column paging — fix paginated reflowable foreground resume drift - #5

Closed
raphi011 wants to merge 3 commits into
feat/buildmenu-custom-actionsfrom
spike/native-column-paging
Closed

spike(epub): native column paging — fix paginated reflowable foreground resume drift#5
raphi011 wants to merge 3 commits into
feat/buildmenu-custom-actionsfrom
spike/native-column-paging

Conversation

@raphi011

@raphi011 raphi011 commented Jun 30, 2026

Copy link
Copy Markdown
Owner

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 to readium/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 .active transition (SwiftUI container size, container/navigator bounds, and the spread WKWebView's scrollView.contentOffset.x / contentSize.width). On a representative run (single column, page width 402):

moment active spread contentOffset.x column contentSize.width
background 9246 23 / 27 10854
returning 8844 22 / 27 10854
  • contentSize.width is identical (10854) → no relayout; the column grid is not recomputed.
  • The SwiftUI-measured container size is constant and viewDidLayoutSubviews never fires → the app-side hierarchy is innocent.
  • The only thing that moves is contentOffset.x: 9246 → 8844, exactly −402 = one column, and it moves while backgrounded (already drifted at the first return event, before any foreground layout).
  • Both offsets are exact page multiples (23×402, 22×402).

That last point is the tell. An exact one-page step with no relayout is the signature of a UIScrollView paging re-snap: isPagingEnabled keeps its own notion of the current page, but reflowable column navigation here is driven from JavaScript (window.scrollBy for page turns; progression read from window.scrollX), not from UIKit. The two notions desync, and when iOS restores the paging scroll view on resume it re-snaps contentOffset to its believed page — one column back. On foreground the navigator reads the moved offset and emits a drifted Locator, and the live web view is revealed at the wrong column before any client signal can correct it (the flash).

EPUBReflowableSpreadView already scrolls page turns via JS specifically to avoid setContentOffset glitches (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 isPagingEnabled for paginated reflowable spreads, set decelerationRate = .fast, and snap a free-scrolling drag to the nearest column boundary in scrollViewWillEndDragging (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 on EPUBSpreadView, mirroring the existing scrollViewDidScroll override 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, programmatic go), never from a passive scroll report. After a layoutSubviews pass whose page width is unchanged, re-derive contentOffset.x from currentColumn, 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 contentOffset is a derived value that must be recomputed for the current geometry, never trusted across a layout event.

Status

Reviewer checklist (on device / simulator)

  • Does the drift + flash actually disappear on background→foreground?
  • Does commit 1 alone fix it? If isPagingEnabled = false is sufficient, commit 2's re-assert can be dropped (≈50-line change, much lower risk). The commits are split precisely to test this.
  • Page-turn fidelity: edge-tap, arrow keys, and swipe-drag snap feel; decelerationRate feel.
  • RTL — snap/re-assert run in non-negative UIKit column space (reading-direction agnostic in theory), but unverified.
  • Scrub / ToC jump / cross-device resume land on the right column and aren't reverted by the re-assert.
  • Whether layoutSubviews even fires on the foreground transition (if not, commit 1 must be the load-bearing fix).
  • Scroll mode (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)

raphi011 and others added 3 commits June 30, 2026 13:15
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>
@raphi011

raphi011 commented Jul 2, 2026

Copy link
Copy Markdown
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 (go(to: firstVisibleElementLocator)) lands one column back through snapOffset's floor bias. Fixed in the app by skipping the anchor restore for theme-only preference changes, so owning the paging natively isn't needed. Corrected analysis summarized on readium#839.

@raphi011 raphi011 closed this Jul 2, 2026
@raphi011
raphi011 deleted the spike/native-column-paging branch July 2, 2026 12:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant