Skip to content

feat: PdfViewerParams.layout + SequentialPagesLayout + PdfFitMode#657

Open
enhancient wants to merge 1 commit into
espresso3389:masterfrom
enhancient:pr/1-layout-mechanism
Open

feat: PdfViewerParams.layout + SequentialPagesLayout + PdfFitMode#657
enhancient wants to merge 1 commit into
espresso3389:masterfrom
enhancient:pr/1-layout-mechanism

Conversation

@enhancient

@enhancient enhancient commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

feat: PdfViewerParams.layout — declarative page layout

Summary

Directly addressing #593, this is the first of a small series of PRs that re-propose the layout features from #589, split into small parts that are easy to review.

Adds PdfViewerParams.layout: a declarative layout object (PdfLayout) that computes page positions via
resolve({pages, viewport, params}). The viewer uses it before the existing layoutPages callback:

params.layout?.resolve(...)  →  params.layoutPages(...)  →  built-in default

It is additive and non-breaking. With layout: null and fitMode: none, behavior is unchanged.

This PR also adds the first layout, SequentialPagesLayout (continuous scroll, handles mixed page
sizes), and PdfFitMode {none, fit, fill, cover}. Each page's fit is applied by setting its
rectangle size in the layout, not by zooming the whole viewer. This per-page geometry is also what later enables
page-at-a-time (discrete) transitions as it requires each page pre-fitted in its rect, which a single
document-wide scale can't provide.

Example of PdfFitMode.fit with SequentialPagesLayout
Screenshot 2026-06-17 at 2 28 22 pm

Example of non page fitted layout using params.layoutPages(...)
Screenshot 2026-06-17 at 2 29 27 pm

Why an object instead of layoutPages

layoutPages is a function:

typedef PdfPageLayoutFunction = PdfPageLayout Function(List<PdfPage> pages, PdfViewerParams params);

A function is a new instance on almost every build, so PdfViewerParams == cannot tell when it
changed. Today, changing layoutPages does nothing until you call invalidate() by hand. Apps also
have to rewrite the same layout math (spacing, centering, mixed page sizes) each time.

PdfLayout is a value type, so:

  • It has real == / hashCode. PdfViewerParams includes it in equality and relayouts
    automatically when it changes.
  • The viewport is passed to resolve() at call time and is never stored, so it is not part of ==.
    A window resize recomputes the layout but is not treated as a config change.
  • The common layout math lives in the layout, not in every app.

It can do anything layoutPages can do, so later layoutPages can become a thin wrapper over
PdfLayout (one code path, not two). For now layoutPages is unchanged.

Why not extend the size delegate (#582)

The size delegate produces single numbers for the whole document (coverScale,
alternativeFitScale). alternativeFitScale does fit the current page (width and height), but it is
one number for the whole document: it changes depending on which page is current, and one number
cannot fit pages of different sizes at the same time.

To fit each page on its own, each page needs its own scale, stored in its rectangle. So per-page fit
must live in the layout. The size delegate keeps owning zoom limits; the layout owns page positions
and sizes. The new fitMode is passed to the delegate as a normal argument (default none) so it
can set the minimum zoom for each mode, with no change to the delegate's API.

Non-breaking

  • layout defaults to null → the existing path is unchanged.
  • layoutPages is not modified.
  • fitMode defaults to none → native page sizes, no change.
  • The size delegate's new fitMode argument has a default, so existing custom PdfViewerSizeDelegate
    code still compiles.
  • The scroll-interaction delegate is not touched.
  • The only behavior change is the scrollPhysicsScale fix: it was missing from == / hashCode /
    doChangesRequireReload, so a change to it alone was ignored. It is now wired like scrollPhysics.

What's in this PR

  • PdfLayout (the layout object) and the dispatch in the viewer.
  • SequentialPagesLayout (continuous scroll, mixed page sizes, spacing / margin / alignment).
  • PdfFitMode {none, fit, fill, cover} and the fitMode argument on the size delegate.
  • scrollPhysicsScale equality fix; useAlternativeFitScaleAsMinScale docs now point to fitMode.
  • Example in example/viewer.

Notes

  • The names params.layout and resolve(...) are open to change — please tell me your preference.
    The important parts are the value-type design and the equality.
  • One question: is this design OK as the base? If yes, I will add FacingPagesLayout, then
    discrete page transitions, then a page-range API — each a small PR using the same layout slot.
    Later, layoutPages becomes a thin wrapper over PdfLayout.

Additive, non-breaking declarative layout. An abstract value-type strategy `PdfLayout` with
`resolve({pages, viewport, params}) -> PdfPageLayout`, dispatched ahead of the existing layoutPages
closure: `params.layout?.resolve(...)` -> `layoutPages` -> built-in default. layoutPages is untouched.

Adds the first concrete strategy `SequentialPagesLayout` (continuous flow, cross-axis width
normalization for mixed page sizes, configurable spacing/margin/alignment) and `PdfFitMode
{none, fit, fill, cover}`, where each page is fitted by sizing its rect in the computed layout.

Why per-page fit lives in geometry rather than as a delegate scale: PdfViewerLayoutMetrics exposes
whole-document scalars (coverScale, alternativeFitScale). alternativeFitScale is a single scalar
derived from the current page, so it gives inconsistent fit across a mixed-size document. Fitting
each page individually needs a per-page scale a single viewer matrix can't express, so it must be
encoded per page in the rects. The size delegate keeps owning zoom bounds; layout owns geometry.

Margins and fit behave consistently on resize:
- The margin scales with the document (sized relative to the largest page, kept uniform - no
  per-page normalization), so it shrinks/grows with the viewport.
- For fit/fill the column fills the viewport width, so the delegate's cover/fit zoom resolves to ~1.
- The layout reports its effectiveMargin on PdfPageLayout; the size delegate (additive fitMode arg,
  default none), goToPage navigation, and the fit-zoom helpers all use it, so a declarative layout's
  margin is the single source of truth. params.margin governs only the built-in layout
  (effectiveMargin null) - no behavior change for existing users.

Equality invariant: PdfLayout is a value type (const base, abstract ==/hashCode forcing real value
equality on every subclass, no stored closures). The viewport enters only via resolve() and is never
stored, so resize relayouts without equality churn. `layout`, `fitMode`, and the previously-unwired
`scrollPhysicsScale` are folded into ==, hashCode, and doChangesRequireReload. Re-points the
useAlternativeFitScaleAsMinScale docs at fitMode.

Pure tests cover layout geometry per fit mode (incl. margin scaling), the delegate min-scale floor,
and params equality (incl. scrollPhysicsScale). The relayout widget test is PDFium-dependent.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@enhancient enhancient force-pushed the pr/1-layout-mechanism branch from 884a97d to 7f0b18b Compare June 18, 2026 04:19
@enhancient

Copy link
Copy Markdown
Contributor Author

@espresso3389 Can I help in any way to progress your review of this?

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