pdfrx icon indicating copy to clipboard operation
pdfrx copied to clipboard

Layout enhancements + discrete transitions

Open enhancient opened this issue 2 months ago • 12 comments

This PR builds on the layout enhancements PR (https://github.com/espresso3389/pdfrx/pull/504) and adds a new discrete page transitions feature where pages (or spreads of pages) transition one at a time.

Architecturally this is achieved by leveraging existing boundary behaviour of InteractiveViewer's ScrollPhysics enhancements - pdfrx provides the relevant boundaries for the current pages/spreads so that InteractiveViewer can restrict flings to these boundaries. This allows us to continue using the single InteractiveViewer instance and document layout.

New Features

Discrete Page Transition Mode

A new PageTransition.discrete mode that enables page-by-page navigation:

PdfViewer.asset(
  'assets/document.pdf',
  params: PdfViewerParams(
    pageTransition: PageTransition.discrete,
  ),
)

Key behaviors:

  • Pages transition one at a time (or one spread at a time for spread based layouts such as facing pages layouts)
  • Swipe gestures advance to next/previous page based on velocity and boundary checks
  • Drag gestures snap to nearest page based on visible page area
  • Works seamlessly with all layout types (single pages, facing pages, etc)
  • Only active at fit zoom level - free panning when zoomed in beyond fit scale, unless at a boundary
  • Smooth animations with configurable duration and curves

Automatic ScrollPhysics Configuration

Discrete mode automatically uses ClampingScrollPhysics by default to prevent overscroll into neighbouring pages if no scrollPhysics parameter is defined.

Implementation Details

New Components

PageTransition enum (pdf_viewer_params.dart):

enum PageTransition {
  /// Continuous scrolling (default)
  continuous,

  /// Discrete page-by-page transitions
  discrete,
}

Core transition logic (pdf_viewer.dart):

  • _handleDiscretePageTransition() - Main transition coordinator
  • _isAtBoundary() - Boundary detection with tolerance
  • _getTargetPageBasedOnThreshold() - Visible area calculation
  • _calcPageIntersectionArea() - Page visibility metrics
  • _getAdjacentPage() - Decide which page to transition to
  • _getSnapAnchor() - Page anchor selection based on page overflow
  • _snapToPage() - Animated page transition

Boundary management:

  • _getDiscreteBoundaryRect() - Dynamic boundary calculation per page
  • _addDiscreteSpacing() - Viewport-sized gaps between pages to prevent neighbor visibility
  • Boundary extension during active gestures (50% viewport) to enable panning to next page

https://github.com/user-attachments/assets/908520cf-a90d-4cf2-977b-c0fd0a42b9f9

https://github.com/user-attachments/assets/c39e8fc8-64f8-4843-a85e-a52ed5accd53

enhancient avatar Oct 18 '25 04:10 enhancient

So, there are confusions on _alternativeFitScale; it's not corresponding to FitMode.cover. It's "relatively near meaing" to FitMode.fill as it is intended to smoothly scrolling on the scroll axis.

But it is useful especially for PDF files that contains pages of different dimensions (in some scanning PDF files, each page has its own irregular page dimensions... But anyway, I can give up supporting of such kind of files because no one is actually understanding the meaning of this. It's a really long story and you don't have to understand the background :(

espresso3389 avatar Oct 19 '25 17:10 espresso3389

So my question is, should we need FitMode.cover? If it's not needed, I want to remove it. At least, the meaning of the word "cover" here seems different from what I expected from the word itself. My feeling is very similar to BoxFit.cover.

UPDATE: it seems that my _alternativeFitScale's meaning is really identical to the one of fit on PageTransition.discrete mode.

And, FitMode.fill sounds a little strange to me although I understood it as it literally "fills" the view by pages as if they were tiles; in my words, it is the meaning of "cover".

espresso3389 avatar Oct 19 '25 17:10 espresso3389

And, for facing pages, it does not maintain the existing facing pages requirements,

  • The cover page may be placed on left-side or right-side according to user's demand (centering can be one of the option)
  • goToPage behavior difference (but it may be accepted)

Other issues:

  • The default for page-gap and margin seems different from the existing pdfrx's ones
  • The page margins on left and right may be not not correct (at least, smaller and not compatible to the existing ones)

espresso3389 avatar Oct 19 '25 18:10 espresso3389

Of course, the existing facing pages layout can be implemented using PdfPageLayout but if the new class is named as FacingPagesLayout, users expects that it supports the existing logic with some options.

espresso3389 avatar Oct 19 '25 18:10 espresso3389

  • SinglePagesLayout is not explain the actual behavior; it may be something like SequentialPagesLayout or such. (I use sequential layout that was borrowed from ancient Acrobat Reader); So the facing-pages was called as facing sequential layout there.

"Single" page mode

The "single" page mode actually means the viewer only shows one page in the view. It can be realized by snapping logic. But even with it, we can selectively choose some of the pages from the all the PDF pages.

So, to realize this, we should change the LayoutResult.pageLayouts to Map<int, Rect> or List<Rect?> to support removing pages. Or SplayTreeMap<int, Rect> to keep the page order (of course, we may be able to sort the pages by Rect order).

espresso3389 avatar Oct 19 '25 18:10 espresso3389

I'm very sorry to write so many things once. But they're very important before doing such huge update.

espresso3389 avatar Oct 19 '25 18:10 espresso3389

So my question is, should we need FitMode.cover? If it's not needed, I want to remove it. At least, the meaning of the word "cover" here seems different from what I expected from the word itself. My feeling is very similar to BoxFit.cover.

UPDATE: it seems that my _alternativeFitScale's meaning is really identical to the one of fit on PageTransition.discrete mode.

And, FitMode.fill sounds a little strange to me although I understood it as it literally "fills" the view by pages as if they were tiles; in my words, it is the meaning of "cover".

Looking at BoxFit, you're right that the FitMode.fill looks most similar to BoxFit.cover (and FitMode.fit represents BoxFit.contain). Maybe we replace the term FitMode.fill with FitMode.cover but keep the functionality as I implemented for .fill as I think this provides mostly similar behaviour as _coverScale provided (except for a single page document).

enhancient avatar Oct 19 '25 20:10 enhancient

And, for facing pages, it does not maintain the existing facing pages requirements,

  • The cover page may be placed on left-side or right-side according to user's demand (centering can be one of the option)
  • goToPage behavior difference (but it may be accepted)

Other issues:

  • The default for page-gap and margin seems different from the existing pdfrx's ones
  • The page margins on left and right may be not not correct (at least, smaller and not compatible to the existing ones)

I have made some changes to FacingPagesLayout to address these #dbc6046.

  • The gutter between pages uses params.margin by default
  • added two new arguments:
 bool singlePagesFillAvailableWidth = true,
 bool independentPageScaling = true,

with singlePagesFillAvailableWidth == false and independentPageScaling == false, a similar behaviour to the existing facing pages layout is achieved.

singlePagesFillAvailableWidth = true independentPageScaling = true Screenshot 2025-10-20 at 11 18 47 am

singlePagesFillAvailableWidth = false independentPageScaling = true Screenshot 2025-10-20 at 11 18 31 am

singlePagesFillAvailableWidth = false independentPageScaling = false (replicates legacy facing pages layout) Screenshot 2025-10-20 at 11 18 18 am

Note that margins look different between layouts with/without independentPageScaling because with page scaling, different pages sizes result in different margins when scaling, and I've taken an approach of normalising these in _normalizePageSizes so that there is a consistent margin across page of differencing size/scaling.

enhancient avatar Oct 20 '25 01:10 enhancient

  • SinglePagesLayout is not explain the actual behavior; it may be something like SequentialPagesLayout or such. (I use sequential layout that was borrowed from ancient Acrobat Reader); So the facing-pages was called as facing sequential layout there.

I've renamed it to SequentialPagesLayout - I agree this explains the behaviour better.

"Single" page mode

The "single" page mode actually means the viewer only shows one page in the view. It can be realized by snapping logic. But even with it, we can selectively choose some of the pages from the all the PDF pages.

So, to realize this, we should change the LayoutResult.pageLayouts to Map<int, Rect> or List<Rect?> to support removing pages. Or SplayTreeMap<int, Rect> to keep the page order (of course, we may be able to sort the pages by Rect order).

By the "single" page mode, I think you're referring the PageTransition.discrete in the commit? This shows either single pages (from a PdfPageLayout) or single spreads from a PdfSpreadLayout.

The idea with PdfSpreadLayout was to address the requirement of multiple pages presented on the screen in a discrete mode, and to enable scaling of a 'spread' as a single unit like a page.

PdfSpreadLayout is based on PdfPageLayout and keeps the same pageLayouts List<Rect> but adds:

 /// List of spread bounds, indexed by spread index
 ///
 /// Each Rect represents the bounds of one spread in document coordinates.
 /// Use [getSpreadBounds] to get the spread for a specific page number.
 final List<Rect> spreadLayouts;

 /// Maps page number to spread index.
 ///
 /// - Example: `pageToSpreadIndex[0]` = spread index for page 1
 final List<int> pageToSpreadIndex;

Does this address what you mean by 'selectively choose some of the pages from all of the PDF pages'?

enhancient avatar Oct 20 '25 02:10 enhancient

@enhancient When _goToXXX is initiated by non-user action, such as _goToPage, _calcOverscroll returns value that actually cancels the move. For example, to go to the last page, I should press [End] key twice.

espresso3389 avatar Oct 27 '25 17:10 espresso3389

@enhancient When _goToXXX is initiated by non-user action, such as _goToPage, _calcOverscroll returns value that actually cancels the move. For example, to go to the last page, I should press [End] key twice.

Oh, I see the issue - in discrete pageTransition mode _calcOverscroll uses _getDiscreteBoundaryRect which looks at the current page (_pageNumber) not the target page set in the goTo - Fixed in #2c4ca34

enhancient avatar Oct 27 '25 23:10 enhancient

@espresso3389 I've had a go at merging in upstream/master and fixed a few small things in the process.

enhancient avatar Nov 12 '25 01:11 enhancient

When can this PR be merged? The features this PR adds are very useful to me.

hkhere avatar Dec 18 '25 06:12 hkhere