Skip to content

fix: report gesture-driven zoom changes back to the Zoom property - #24

Merged
michaelstonis merged 3 commits into
mainfrom
fix/issue-21-zoom-writeback
Aug 25, 2026
Merged

fix: report gesture-driven zoom changes back to the Zoom property#24
michaelstonis merged 3 commits into
mainfrom
fix/issue-21-zoom-writeback

Conversation

@michaelstonis

Copy link
Copy Markdown
Collaborator

Fixes #21.

The bug

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 getter was stale for the same reason — PdfView.Zoom returned 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:

CONTROL (set Zoom=2.0 from MAUI): vm=2.000 -> OK     ← MAUI→native worked
native ScaleFactor 1.633 -> 3.266                     ← native zoom really changed
AFTER   vm=1.000  PdfView.Zoom=1.000  expected=2.000
RESULT: FAIL - bound property is 1.000, expected 2.000

The fix

The two platforms need different signals.

iOS has one. PdfKit posts PDFViewScaleChangedNotification whenever ScaleFactor moves — the only hook available, since 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, 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 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.
  • 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.

New: ZoomChanged event

Consistent with the existing event surface (PageChanged, DocumentLoaded, Rendered, …), for callers who would rather not bind:

private void OnZoomChanged(object sender, ZoomChangedEventArgs e)
    => ZoomLabel.Text = $"{e.Zoom:P0}";

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 to 100%, clamped by MaxZoom="4.0":

after pinch out VM Zoom: 386%
after pinch in 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:

native Zoom 1.000 -> 2.000
VM ZoomLevel <- 1.078 … 1.438 … 1.826 … 1.993 … 2.000
SETTLE  vm=2.000 (was 2.000), writes since=0
RESULT: PASS

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 shown 1.0x.


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. The Release build of the library itself succeeds on both TFMs.

🤖 Generated with Claude Code

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>
Copilot AI lite review requested due to automatic review settings August 25, 2026 16:29

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 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 PDFViewScaleChangedNotification and publish zoom changes (guarded during refit windows) back to MAUI.
  • Android: use an IOnDrawListener hook to detect post-gesture zoom changes and publish them back to MAUI with a small noise threshold.
  • Public API: add ZoomChanged event + 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.

Comment on lines 270 to 274
{
_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";
}

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.

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>
Copilot AI review requested due to automatic review settings August 25, 2026 17:33

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 10 out of 10 changed files in this pull request and generated 1 comment.

Comment on lines 65 to +69
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>
Copilot AI review requested due to automatic review settings August 25, 2026 18:06

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 10 out of 10 changed files in this pull request and generated 1 comment.

Comment on lines 500 to +518

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));
}
}
@michaelstonis
michaelstonis merged commit 53a40a3 into main Aug 25, 2026
1 check passed
@michaelstonis
michaelstonis deleted the fix/issue-21-zoom-writeback branch August 25, 2026 19:20
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.

Binding control Zoom level does not update property when zoom happens via control interaction

2 participants