fix: report gesture-driven zoom changes back to the Zoom property - #24
Conversation
Zoom only ever flowed one way: MAUI -> handler -> native control. Nothing wrote back, so a TwoWay binding had nothing to fire on and a pinch or double-tap left the bound property showing whatever it was last assigned. The Zoom getter was stale for the same reason — it returned the stored MAUI value, even though both platform wrappers already read the live native level. The hypothesis that held up: the reverse path did not exist at all. Everything downstream — the handler mapper, the clamping, the fit-scale conversion — was working; nothing was there to trigger it. The two platforms need different signals: iOS has one. PdfKit posts PDFViewScaleChangedNotification whenever ScaleFactor moves, which is the only hook for it — PdfViewDelegate has no scale callback. It also posts for PdfKit's own re-fits (a new document, a layout pass, a rotation), during which the fit scale that ScaleFactor is expressed against is mid-flight and the ratio would be nonsense. _zoomNeedsApply is already set across exactly those windows, which makes it the right guard. Android has none. AhmerPdfViewer ships no zoom or scale listener of any kind, Configurator has no OnZoom, and PDFView is sealed so there is nothing to override; DragPinchManager, which handles both the pinch and the double-tap, is internal to the library and unbound. That leaves IOnDrawListener as the one callback that runs after a gesture has changed the scale — the workaround the reporter arrived at independently. It sits on the draw path, so on all but the frames where the zoom genuinely moved it costs a field read and a float compare, and it allocates only when it publishes. The cycle the reporter hit with their own OnDraw listener is closed in two places. Publishing sets Zoom on the virtual view, whose MapZoom compares against the native control before pushing anything back, so a level we just read from that control cannot bounce off it. And TryApplyZoom baselines the level it asks for, so a programmatic set is not replayed to the caller as though the user had done it. A small threshold on top stops float noise from republishing an unchanged level on every frame. Also adds a ZoomChanged event, consistent with the existing event surface. It fires continuously while a gesture is in flight rather than only at the end, matching what the platform controls report, so a bound label tracks the pinch. Verified on the iOS simulator with a real pinch: the bound property followed the gesture to 386% and back to 100%, clamped by MaxZoom. Verified on an Android emulator against ZoomWithAnimation, the same call the library's own double-tap makes: every frame of the animation reached the view model and the value then settled with no further writes. The sample's zoom button now follows the control instead of the last value assigned to it. Fixes #21 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a long-standing TwoWay binding gap where gesture-driven zoom changes (pinch/double-tap) modified the native control but did not propagate back to the MAUI PdfView.Zoom property, leaving bindings and the Zoom getter stale. It introduces a cross-platform ZoomChanged event and updates platform handlers to publish native zoom changes back to the virtual view.
Changes:
- iOS: observe
PDFViewScaleChangedNotificationand publish zoom changes (guarded during refit windows) back to MAUI. - Android: use an
IOnDrawListenerhook to detect post-gesture zoom changes and publish them back to MAUI with a small noise threshold. - Public API: add
ZoomChangedevent +ZoomChangedEventArgs, and update docs/sample to demonstrate usage.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/MauiNativePdfView/Platforms/iOS/PdfViewiOS.cs | Adds ScaleFactor change observation and publishes zoom changes via ZoomChanged. |
| src/MauiNativePdfView/Platforms/iOS/PdfViewHandler.cs | Wires platform ZoomChanged to PdfView.RaiseZoomChanged. |
| src/MauiNativePdfView/Platforms/Android/PdfViewHandler.cs | Wires platform ZoomChanged to PdfView.RaiseZoomChanged. |
| src/MauiNativePdfView/Platforms/Android/PdfViewAndroid.cs | Adds per-draw zoom reporting and ZoomChanged event emission. |
| src/MauiNativePdfView/PdfView.cs | Updates MAUI control to apply native zoom back into Zoom and raise ZoomChanged. |
| src/MauiNativePdfView/Abstractions/IPdfView.cs | Extends abstraction with ZoomChanged event. |
| src/MauiNativePdfView/Abstractions/EventArgs.cs | Introduces ZoomChangedEventArgs. |
| samples/MauiPdfViewerSample/PdfTestPage.xaml.cs | Updates sample to display zoom changes via ZoomChanged. |
| samples/MauiPdfViewerSample/PdfTestPage.xaml | Hooks ZoomChanged event in XAML. |
| README.md | Documents the new ZoomChanged event and TwoWay zoom behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| { | ||
| _zoomIndex = (_zoomIndex + 1) % _zoomLevels.Length; | ||
| PdfViewer.Zoom = _zoomLevels[_zoomIndex]; | ||
| ToggleZoomButton.Text = $"{_zoomLevels[_zoomIndex]:0.0}x"; | ||
| StatusLabel.Text = $"Zoom: {_zoomLevels[_zoomIndex]:0.0}x — now tap the page"; | ||
| } |
There was a problem hiding this comment.
Good catch, and it turned out to be worse than described — the two platforms disagreed. I tested before changing anything:
iOS set Zoom=2.0 -> events=1 (label updated)
Android set Zoom=2.0 -> events=0 (label stayed "—")
Both wrappers baselined _lastReportedZoom inside TryApplyZoom. On Android that worked as intended: the baseline is set before LoadPages triggers the redraw that reports. On iOS it did not, because assigning ScaleFactor posts ScaleChanged synchronously — the report ran before the baseline assignment did, so the event escaped. So the behaviour was decided by native notification ordering, which is not a good basis for an API contract.
Fixed in ebb8142 by raising ZoomChanged from one place: a propertyChanged callback on ZoomProperty. A level the caller assigns and a level the user pinches both arrive at the property, so both report identically, and re-setting an unchanged level stays quiet because the property does not change. That also matches PageChanged, which has always fired for programmatic navigation, and matches what the docs already claimed.
With the event no longer raised from the platform report, the baselines had no job left — and on Android one was actively harmful: it suppressed the report that corrects a clamped level, so a Zoom above MaxZoom left the property holding a value the control had refused. Removing both fixes that too:
set Zoom=10 with MaxZoom=4 -> Viewer.Zoom=4.000, last event=4.000
The sample needs no extra line as a result — OnZoomChanged now fires for the toggle button, and after one click the label reads 1.5x. Verified identically on the iOS simulator and an Android emulator, and the original gesture write-back still passes on both.
Review feedback pointed out that the sample's zoom button label goes stale,
because ZoomChanged does not fire for a programmatic Zoom set. Checking it
turned up something worse than reported: the two platforms disagreed.
iOS set Zoom=2.0 -> events=1 (label updated)
Android set Zoom=2.0 -> events=0 (label stayed "—")
Both wrappers baselined _lastReportedZoom inside TryApplyZoom to suppress
reporting a level the caller had just asked for. On Android that worked: the
baseline is set before LoadPages triggers the redraw that reports. On iOS it
did not, because assigning ScaleFactor posts ScaleChanged synchronously — the
report ran before the baseline assignment did, and the event escaped. The
platform behaviour was therefore decided by native notification ordering,
which is not something to build an API contract on.
ZoomChanged now comes from one place: a propertyChanged callback on
ZoomProperty. A level the caller assigned and a level the user pinched both
arrive at the property, so both report identically, and re-setting an
unchanged level stays quiet because the property does not change. This also
matches PageChanged, which has always fired for programmatic navigation, and
matches what the docs already claimed.
With the event no longer raised from the platform report, the baselines had no
job left, and on Android one was actively harmful: it suppressed the report
that corrects a clamped level, so a Zoom above MaxZoom left the property
holding a value the control had refused. Removing both fixes that, and leaves
_lastReportedZoom with a single writer — the noise threshold in
ReportZoomIfChanged.
set Zoom=10 with MaxZoom=4 -> Viewer.Zoom=4.000, last event=4.000
Verified identically on the iOS simulator and an Android emulator: a
programmatic set raises the event, a level beyond MaxZoom settles at the
clamped value, re-setting the same level is quiet, and the value settles. The
original gesture write-back still passes on both, and the sample's zoom button
now tracks the control again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| BindableProperty.Create( | ||
| nameof(Zoom), | ||
| typeof(float), | ||
| typeof(PdfView), | ||
| 1.0f); | ||
| 1.0f, |
Brings in #22 (bindable CurrentPage/PageCount). One conflict, in the iOS wrapper's field block, where both branches had added private fields at the same spot: _pendingPage from #22, and the scale-changed observer plus the zoom reporting threshold from this branch. The two are independent, so both are kept. The rest merged textually, but the two changes share the path from a platform callback to the virtual view, so the merged behaviour was worth checking rather than trusting a clean build. Verified together on the iOS simulator and an Android emulator, both platforms identical: PageCount reaches the view model, binding CurrentPage navigates and follows the document, an out-of-range page reverts, a programmatic zoom raises ZoomChanged and updates the binding, a native zoom writes back, a level beyond MaxZoom clamps, zooming leaves the page alone, navigating leaves the zoom alone, and both values settle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
||
| internal void RaiseZoomChanged(ZoomChangedEventArgs args) | ||
| { | ||
| // Writing the bindable property is the whole point: it is what a TwoWay binding | ||
| // observes, and its property-changed callback is what raises ZoomChanged. Routing | ||
| // the event through the property rather than raising it here is what makes a level | ||
| // the caller assigned and a level the user pinched report identically. The handler's | ||
| // MapZoom compares against the native control before pushing anything back, so | ||
| // setting the level we were just told about stops here. | ||
| Zoom = args.Zoom; | ||
| } | ||
|
|
||
| private static void OnZoomPropertyChanged(BindableObject bindable, object oldValue, object newValue) | ||
| { | ||
| if (bindable is PdfView view) | ||
| { | ||
| view.ZoomChanged?.Invoke(view, new ZoomChangedEventArgs((float)newValue)); | ||
| } | ||
| } |
Fixes #21.
The bug
Zoomonly ever flowed one way: MAUI → handler → native control. Nothing wrote back, so aTwoWaybinding had nothing to fire on and a pinch or double-tap left the bound property showing whatever it was last assigned. The getter was stale for the same reason —PdfView.Zoomreturned the stored MAUI value, even though both platform wrappers already read the live native level.Nothing downstream was broken. The handler mapper, the clamping, the fit-scale conversion all worked; there was simply nothing to trigger them.
Reproduced on the iOS simulator by driving the native control the way PdfKit's own gesture handlers do, with a control step proving the harness was sound:
The fix
The two platforms need different signals.
iOS has one. PdfKit posts
PDFViewScaleChangedNotificationwheneverScaleFactormoves — the only hook available, sincePdfViewDelegatehas no scale callback. It also posts for PdfKit's own re-fits (a new document, a layout pass, a rotation), during which the fit scale thatScaleFactoris expressed against is mid-flight and the ratio would be nonsense._zoomNeedsApplyis already set across exactly those windows, which makes it the right guard.Android has none. AhmerPdfViewer ships no zoom or scale listener of any kind,
Configuratorhas noOnZoom, andPDFViewissealedso there is nothing to override;DragPinchManager— which handles both the pinch and the double-tap — is internal to the library and unbound. That leavesIOnDrawListeneras the one callback that runs after a gesture has changed the scale, which is the workaround @bengavin arrived at independently. It sits on the draw path, so on all but the frames where the zoom genuinely moved it costs a field read and a float compare, and it allocates only when it publishes.The cycle @bengavin hit with their own
OnDrawlistener is closed in two places:Zoomon the virtual view, whoseMapZoomcompares against the native control before pushing anything back — so a level we just read from that control cannot bounce off it.TryApplyZoombaselines the level it asks for, so a programmatic set is not replayed to the caller as though the user had done it.A small threshold on top stops float noise from republishing an unchanged level on every frame.
New:
ZoomChangedeventConsistent with the existing event surface (
PageChanged,DocumentLoaded,Rendered, …), for callers who would rather not bind:Note it fires continuously while a gesture is in flight, not just when it ends — matching what the platform controls report, so a bound label tracks the pinch rather than jumping at the end. Keep handlers cheap for that reason; this is documented in the README.
Verification
iOS — real pinch gesture on the simulator. The bound property followed the gesture live to
386%and back to100%, clamped byMaxZoom="4.0":VM Zoom: 386%VM Zoom: 100%Android — emulator, driven through
ZoomWithAnimation, the same call the library's own double-tap makes. Every frame of the animation reached the view model, and the value then settled:The settle check is deliberate — it is what would catch a write-back that re-enters the native control and oscillates.
The sample: its zoom button now follows the control instead of the last value assigned to it. After a pinch it reads
2.3x; before this change it would still have shown1.0x.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. The Release build of the library itself succeeds on both TFMs.🤖 Generated with Claude Code