fix: make CurrentPage and PageCount bindable - #23
Conversation
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>
There was a problem hiding this comment.
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
CurrentPageto aTwoWayBindablePropertyandPageCountto a read-onlyBindableProperty(viaBindablePropertyKey). - Add handler mappings and platform support (iOS/Android) so assigning
CurrentPagenavigates 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.
| _pdfViewWrapper.DefaultPage = VirtualView.DefaultPage; | ||
| _pdfViewWrapper.CurrentPage = VirtualView.CurrentPage; | ||
| _pdfViewWrapper.EnableAntialiasing = VirtualView.EnableAntialiasing; |
There was a problem hiding this comment.
Done in 75e321f — ConnectHandler 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.
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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
PageCountis intended to be bound as a source (OneWayToSource), but the read-onlyPageCountPropertyKeyis 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 toOneWayToSourcealigns the API with the README and sample usage.
private static readonly BindablePropertyKey PageCountPropertyKey =
BindableProperty.CreateReadOnly(
nameof(PageCount),
typeof(int),
typeof(PdfView),
0);
Fixes #22.
The bug
CurrentPageandPageCountwere plain CLR auto-properties with aninternalsetter, notBindablePropertys. MAUI can only bind to aBindableProperty, so binding either of them failed:Two things made this easy to miss:
CurrentPageandPageCount.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-writeBindableProperty,TwoWayby 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.A page assigned before the document has loaded is honoured once it loads, matching how
Zoomalready behaves — on iOS via a pending-page field applied at load, on Android by recording it as the pageLoadDocumentrestores. It takes precedence overDefaultPage, being the more specific instruction. Out-of-range values are ignored.PageCount→ read-onlyBindableProperty. It describes the loaded document, so it binds as a source:Feedback loop. Broken where it is cheapest to reason about:
MapCurrentPageonly 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:
PageCountreaches the view model (OneWayToSource)The reporter's exact XAML now compiles under Release/
SourceGen, where it previously producedMAUIX2002.Also in this PR
GoToPage, so the sample exercises what the README documents.PageCountbinding mode, and added a section on binding both properties.Note for implementers
IPdfView.CurrentPagegains 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-androidfails 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 onmainunmodified.🤖 Generated with Claude Code