Skip to content

fix: make CurrentPage and PageCount bindable - #23

Merged
michaelstonis merged 2 commits into
mainfrom
fix/issue-22-bindable-current-page
Aug 25, 2026
Merged

fix: make CurrentPage and PageCount bindable#23
michaelstonis merged 2 commits into
mainfrom
fix/issue-22-bindable-current-page

Conversation

@michaelstonis

Copy link
Copy Markdown
Collaborator

Fixes #22.

The bug

CurrentPage and PageCount were plain CLR auto-properties with an internal setter, not BindablePropertys. MAUI can only bind to a BindableProperty, so binding either of them failed:

error MAUIX2002: No accessible property, BindableProperty, or event found for
  "CurrentPage", or mismatching type between value and property.

Two things made this easy to miss:

  • It only errors in Release. Debug uses the runtime XAML inflator, which defers the same failure to runtime. That is why the sample app never caught it.
  • The README contradicted itself — the property table said "readonly" while the MVVM example bound CurrentPage and PageCount.

There was nothing wrong with the binding machinery or the handler plumbing. The properties simply were not bindable and never raised change notifications, so no binding of any mode could observe them.

The fix

CurrentPage → read-write BindableProperty, TwoWay by default.

Assigning it navigates, so a view model can drive the document instead of routing through GoToPage, and it is written back whenever the visible page changes so the binding tracks the document both ways.

<pdf:PdfView CurrentPage="{Binding PageNumber}" />

A page assigned before the document has loaded is honoured once it loads, matching how Zoom already behaves — on iOS via a pending-page field applied at load, on Android by recording it as the page LoadDocument restores. It takes precedence over DefaultPage, being the more specific instruction. Out-of-range values are ignored.

PageCount → read-only BindableProperty. It describes the loaded document, so it binds as a source:

<pdf:PdfView PageCount="{Binding TotalPages, Mode=OneWayToSource}" />

Feedback loop. Broken where it is cheapest to reason about: MapCurrentPage only pushes to the native control when the values actually differ, so a page change reported by the control writes the virtual view and stops there.

Verification

A harness page ran the same four assertions on the iOS simulator and an Android emulator, both all-green:

Check iOS Android
PageCount reaches the view model (OneWayToSource) ✅ 15 ✅ 15
Assigning the bound property navigates
Navigating by another route writes back
Value settles rather than oscillating

The reporter's exact XAML now compiles under Release/SourceGen, where it previously produced MAUIX2002.

Also in this PR

  • The sample's page indicator and prev/next buttons now go through these bindings rather than GoToPage, so the sample exercises what the README documents.
  • README: corrected the property table, fixed the MVVM example's PageCount binding mode, and added a section on binding both properties.

Note for implementers

IPdfView.CurrentPage gains a setter. This is a source break for any external implementation of that interface.


Unrelated to this change: dotnet build -c Release -f net10.0-android fails on my machine in Android AOT (/Users/…/Library/as: No such file or directory — an unquoted path in the SDK's binutils wrapper). I confirmed this reproduces on main unmodified.

🤖 Generated with Claude Code

CurrentPage and PageCount were plain CLR auto-properties with an internal
setter, not BindableProperties. MAUI can only bind to a BindableProperty, so
binding either of them failed outright:

    error MAUIX2002: No accessible property, BindableProperty, or event found
    for "CurrentPage", or mismatching type between value and property.

That error only surfaces in Release. Debug uses the runtime XAML inflator,
which defers the same failure to runtime — which is why the sample app never
caught it, and why the README could carry an MVVM example that binds
CurrentPage without anyone noticing it could not work.

The hypothesis that held up: nothing was wrong with the binding machinery or
the handler plumbing; the properties simply were not bindable, and never
raised change notifications, so no binding of any mode could observe them.

CurrentPage becomes a read-write BindableProperty defaulting to TwoWay.
Assigning it navigates, so a view model can drive the document instead of
routing through GoToPage, and it is written back whenever the visible page
changes so the binding tracks the document both ways. A page assigned before
the document has loaded is honoured once it loads, matching how Zoom already
behaves — on iOS via a pending-page field applied at load, on Android by
recording it as the page LoadDocument restores. It takes precedence over
DefaultPage, being the more specific instruction.

PageCount becomes a read-only BindableProperty. It describes the loaded
document, so it is bound as a source with Mode=OneWayToSource.

The round trip is broken where it is cheapest to reason about: the handler's
MapCurrentPage only pushes to the native control when the values actually
differ, so a page change reported by the control writes the virtual view and
stops there.

The sample's page indicator and prev/next buttons now go through these
bindings rather than GoToPage, so the sample exercises what the README
documents.

Verified on the iOS simulator and an Android emulator: PageCount reaches the
view model, assigning the bound property navigates, navigating by any other
route writes back, and the value settles rather than oscillating.

Note for implementers: IPdfView.CurrentPage gains a setter. This is a source
break for any external implementation of that interface.

Fixes #22

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 25, 2026 16:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes issue #22 by making PdfView.CurrentPage and PdfView.PageCount bindable in .NET MAUI, enabling MVVM-friendly bindings (including TwoWay navigation for CurrentPage and OneWayToSource for the read-only PageCount) and aligning the sample + README with the supported binding behavior.

Changes:

  • Convert CurrentPage to a TwoWay BindableProperty and PageCount to a read-only BindableProperty (via BindablePropertyKey).
  • Add handler mappings and platform support (iOS/Android) so assigning CurrentPage navigates and native page changes update the virtual view.
  • Update the sample and README to demonstrate/validate the binding patterns.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/MauiNativePdfView/Platforms/iOS/PdfViewiOS.cs Adds pending-page support so CurrentPage assignments made before load are applied on load.
src/MauiNativePdfView/Platforms/iOS/PdfViewHandler.cs Adds CurrentPage property mapping and initial application during handler connect.
src/MauiNativePdfView/Platforms/Android/PdfViewHandler.cs Adds CurrentPage property mapping on Android.
src/MauiNativePdfView/Platforms/Android/PdfViewAndroid.cs Makes CurrentPage settable and persists pre-load assignments for replay on load.
src/MauiNativePdfView/PdfView.cs Introduces bindable CurrentPage and read-only bindable PageCount, updated via wrapper events.
src/MauiNativePdfView/Abstractions/IPdfView.cs Updates interface contract: CurrentPage gains a setter and documents pre-load behavior.
samples/MauiPdfViewerSample/PdfTestPage.xaml.cs Updates sample navigation/UI to go through bindings instead of GoToPage.
samples/MauiPdfViewerSample/PdfTestPage.xaml Binds CurrentPage (TwoWay) and PageCount (OneWayToSource) in the sample.
README.md Documents binding behavior and corrects the MVVM example for PageCount binding mode.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 85 to 87
_pdfViewWrapper.DefaultPage = VirtualView.DefaultPage;
_pdfViewWrapper.CurrentPage = VirtualView.CurrentPage;
_pdfViewWrapper.EnableAntialiasing = VirtualView.EnableAntialiasing;

@michaelstonis michaelstonis Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 75e321fConnectHandler now calls MapCurrentPage(this, VirtualView) instead of assigning the wrapper directly, so connect gets the same out-of-range handling as every later binding update. Android already went through the mapper; this brings iOS in line.

Comment on lines +255 to +263
public static void MapCurrentPage(PdfViewHandler handler, PdfView view)
{
// The inequality guard is what stops the round trip: a page change reported by the
// control writes CurrentPage on the virtual view, which brings us straight back here.
if (handler._pdfViewWrapper != null && handler._pdfViewWrapper.CurrentPage != view.CurrentPage)
{
handler._pdfViewWrapper.CurrentPage = view.CurrentPage;
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 75e321f. I reproduced it on both platforms first — on a 15-page document:

set 999 -> vm=999, Viewer.CurrentPage=999   (document did not move)
set  -5 -> vm=-5,  Viewer.CurrentPage=-5

MapCurrentPage now takes the control's answer back after applying a page, and the existing inequality guard ends the recursion that second write starts.

One thing worth recording, because it is what makes the read-back safe rather than a new bug: it is only sound if navigation lands before the assignment returns. If it were asynchronous, reading the wrapper immediately would snap every valid page change back to where it came from. I checked instead of assuming, and it is synchronous on both platforms — reading the native control immediately after the set already reports the new page:

IMMEDIATELY after set 4: Viewer.CurrentPage=4, native=4

Verified on the iOS simulator and an Android emulator: valid navigation still works, 999 and -5 both revert to the page being shown, the control is still navigable afterwards, and the value settles rather than ping-ponging.

Comment on lines +260 to +268
private static void MapCurrentPage(PdfViewHandler handler, PdfView view)
{
// The inequality guard is what stops the round trip: a page change reported by the
// control writes CurrentPage on the virtual view, which brings us straight back here.
if (handler._pdfViewWrapper != null && handler._pdfViewWrapper.CurrentPage != view.CurrentPage)
{
handler._pdfViewWrapper.CurrentPage = view.CurrentPage;
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix applied here in 75e321f — see the reply on the iOS mapper for the reproduction and for why the immediate read-back is safe (navigation is synchronous on both platforms, which I verified rather than assumed). Android's ConnectHandler already routed through this mapper, so it needed no change.

Review feedback: setting CurrentPage to a page outside the document left the
property — and any TwoWay binding on it — holding a value that was never
navigated to. The control ignored the page, but nothing told the virtual view
that, so the two disagreed until the next page change happened to resync them.

Confirmed on both platforms before fixing. On a 15-page document:

    set 999 -> vm=999, Viewer.CurrentPage=999   (document did not move)
    set  -5 -> vm=-5,  Viewer.CurrentPage=-5

MapCurrentPage now takes the control's answer back after applying a page, so
the property reverts to the page actually being shown. The existing inequality
guard ends the recursion that second write starts.

This is only sound because navigation lands before the assignment returns.
That was worth checking rather than assuming, since a read-back against an
asynchronous navigation would snap every valid page change straight back to
where it came from. It is synchronous on both: reading the native control
immediately after the set already reports the new page.

    IMMEDIATELY after set 4: Viewer.CurrentPage=4, native=4

iOS ConnectHandler now goes through MapCurrentPage rather than assigning the
wrapper directly, so connect gets the same handling as every later binding
update. Android already did.

Verified on the iOS simulator and an Android emulator: valid navigation still
works, 999 and -5 both revert to the page being shown, the control is still
navigable afterwards, and the value settles rather than ping-ponging between
the two writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 25, 2026 17:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

src/MauiNativePdfView/PdfView.cs:373

  • PageCount is intended to be bound as a source (OneWayToSource), but the read-only PageCountPropertyKey is currently created with the default binding mode (OneWay). That makes the “no Mode specified” experience attempt to push values into a read-only bindable property, which can fail at runtime. Setting the default binding mode to OneWayToSource aligns the API with the README and sample usage.
    private static readonly BindablePropertyKey PageCountPropertyKey =
        BindableProperty.CreateReadOnly(
            nameof(PageCount),
            typeof(int),
            typeof(PdfView),
            0);

@michaelstonis
michaelstonis merged commit 638c688 into main Aug 25, 2026
1 check passed
@michaelstonis
michaelstonis deleted the fix/issue-22-bindable-current-page branch August 25, 2026 17:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Sample to bind 'CurrentPage' doesn't work

2 participants