Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ MauiNativePdfView brings native PDF viewing capabilities to your .NET MAUI appli

- `DocumentLoaded` - Fires when PDF is loaded with page count and metadata
- `PageChanged` - Current page and total page count updates
- `ZoomChanged` - Zoom level updates, including pinch and double-tap
- `LinkTapped` - Intercept link taps before navigation (set `e.Handled = true` to prevent)
- `Tapped` - General tap events with page coordinates (requires `EnableTapGestures = true`)
- `AnnotationTapped` - Annotation tap with type, content, and bounds (iOS)
Expand Down Expand Up @@ -258,6 +259,26 @@ size. So `1.0` is the fitted document, `2.0` is twice that size, and `MinZoom="1
"never zoom out past fitted". Because the values are relative, they stay meaningful when the
view resizes or the device rotates.

#### Following the zoom the user sets

`Zoom` reports gesture-driven changes as well as ones you assign, so it binds two-way and a
pinch or double-tap updates the bound property:

```xml
<pdf:PdfView Zoom="{Binding ZoomLevel, Mode=TwoWay}" />
```

There is also a `ZoomChanged` event if you would rather not bind:

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

Both fire continuously while a gesture is in flight, not just when it ends — the same way
the underlying platform controls report scale — so a bound label tracks the pinch rather than
jumping at the end. Keep the handler cheap for that reason.

### PdfSource Types

The `PdfSource` class supports automatic string conversion via implicit operators and TypeConverter, making it easy to use in both XAML and code.
Expand Down
1 change: 1 addition & 0 deletions samples/MauiPdfViewerSample/PdfTestPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
EnableAnnotationRendering="True"
DocumentLoaded="OnDocumentLoaded"
PageChanged="OnPageChanged"
ZoomChanged="OnZoomChanged"
Error="OnError"
Tapped="OnPdfTapped"
Rendered="OnPdfRendered"
Expand Down
8 changes: 7 additions & 1 deletion samples/MauiPdfViewerSample/PdfTestPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,16 @@ private void OnToggleZoomClicked(object? sender, EventArgs e)
{
_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";
}
Comment on lines 306 to 310

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.


// The button label follows the control rather than the last value we assigned, so a
// pinch or double-tap keeps it honest.
private void OnZoomChanged(object? sender, ZoomChangedEventArgs e)
{
ToggleZoomButton.Text = $"{e.Zoom:0.0}x";
}

private void OnToggleTapGesturesClicked(object? sender, EventArgs e)
{
PdfViewer.EnableTapGestures = !PdfViewer.EnableTapGestures;
Expand Down
17 changes: 17 additions & 0 deletions src/MauiNativePdfView/Abstractions/EventArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,23 @@ public RenderedEventArgs(int pageCount)
}
}

/// <summary>
/// Event arguments for the zoom changed event.
/// </summary>
public class ZoomChangedEventArgs : EventArgs
{
/// <summary>
/// Gets the zoom level now being shown, as a multiple of the fit scale
/// (<c>1.0</c> is the fitted document).
/// </summary>
public float Zoom { get; }

public ZoomChangedEventArgs(float zoom)
{
Zoom = zoom;
}
}

/// <summary>
/// Event arguments for annotation tapped event.
/// </summary>
Expand Down
7 changes: 7 additions & 0 deletions src/MauiNativePdfView/Abstractions/IPdfView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ public interface IPdfView
/// </summary>
event EventHandler<PageChangedEventArgs>? PageChanged;

/// <summary>
/// Occurs when the zoom level being shown changes, including changes the user drives
/// with a pinch or a double-tap. <see cref="Zoom"/> already reflects the new level when
/// this is raised.
/// </summary>
event EventHandler<ZoomChangedEventArgs>? ZoomChanged;

/// <summary>
/// Occurs when an error occurs during loading or rendering.
/// </summary>
Expand Down
16 changes: 16 additions & 0 deletions src/MauiNativePdfView/PdfView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,13 @@ public PageAlignment PageAlignment
/// </summary>
public event EventHandler<RenderedEventArgs>? Rendered;

/// <summary>
/// Occurs when the zoom level being shown changes, including changes the user drives
/// with a pinch or a double-tap. <see cref="Zoom"/> already holds the new level when
/// this is raised, so a <c>TwoWay</c> binding on <see cref="Zoom"/> has already updated.
/// </summary>
public event EventHandler<ZoomChangedEventArgs>? ZoomChanged;

/// <summary>
/// Occurs when an annotation is tapped in the PDF.
/// Platform availability: iOS only. Android does not support annotation tap detection with the current library.
Expand Down Expand Up @@ -448,6 +455,15 @@ internal void RaiseRendered(RenderedEventArgs args)
Rendered?.Invoke(this, args);
}

internal void RaiseZoomChanged(ZoomChangedEventArgs args)
{
// Writing the bindable property is the whole point: it is what a TwoWay binding
// observes. 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;
ZoomChanged?.Invoke(this, args);
}
Comment on lines 500 to +518

internal void RaiseAnnotationTapped(AnnotationTappedEventArgs args)
{
AnnotationTapped?.Invoke(this, args);
Expand Down
75 changes: 75 additions & 0 deletions src/MauiNativePdfView/Platforms/Android/PdfViewAndroid.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,16 @@ public class PdfViewAndroid : IPdfView, IDisposable
private float _zoom = 1.0f;
private bool _zoomNeedsApply;
private readonly HashSet<int> _openedPages = new();
private float _lastReportedZoom = 1.0f;

/// <summary>
/// Smallest zoom movement worth publishing. A pinch redraws every frame, and without a
/// floor the last few bits of float noise would republish on every one of them.
/// </summary>
private const float ZoomReportThreshold = 0.001f;

private TapListener? _tapListener;
private DrawListener? _drawListener;

public PdfViewAndroid(Context context)
{
Expand Down Expand Up @@ -192,6 +200,9 @@ private bool TryApplyZoom(float zoom)
if (Math.Abs(_pdfView.Zoom - zoom) > float.Epsilon)
{
_pdfView.ZoomCenteredTo(zoom, new global::Android.Graphics.PointF(_pdfView.Width / 2f, _pdfView.Height / 2f));
// The redraw below reaches ReportZoomIfChanged. Baseline it here so the level we
// just asked for is not republished back at the caller as if the user had done it.
_lastReportedZoom = zoom;
_pdfView.LoadPages();
// Re-settles the page under a snapping display mode, as the animated path does.
_pdfView.PerformPageSnap();
Expand Down Expand Up @@ -495,6 +506,7 @@ private void ApplyPageAlignmentOnUiThread()
public event EventHandler<LinkTappedEventArgs>? LinkTapped;
public event EventHandler<PdfTappedEventArgs>? Tapped;
public event EventHandler<RenderedEventArgs>? Rendered;
public event EventHandler<ZoomChangedEventArgs>? ZoomChanged;

/// <summary>
/// This event is not supported on Android with the current AhmerPdfium library.
Expand Down Expand Up @@ -594,6 +606,10 @@ private void ConfigureAndLoad(PDFView.Configurator configurator, int pageToResto
.OnPageChange(new PageChangeListener(this))
.OnError(new ErrorListener(this))
.OnTap(_enableTapGestures ? _tapListener ??= new TapListener(this) : null)
// AhmerPdfViewer has no zoom listener of any kind and PDFView is sealed, so the
// per-draw callback is the only place a pinch or double-tap becomes observable.
// See ReportZoomIfChanged for why this is cheap enough to sit on the draw path.
.OnDraw(_drawListener ??= new DrawListener(this))
.OnRender(new RenderListener(this));

// Note: UseBestQuality sets rendering quality (ARGB_8888 vs RGB_565)
Expand Down Expand Up @@ -638,6 +654,36 @@ private void OnDocumentLoadedWithPageRestore(int pageCount, int pageToRestore)
DocumentLoaded?.Invoke(this, new DocumentLoadedEventArgs(pageCount));
}

/// <summary>
/// Publishes the level the control is actually showing, so a caller bound to Zoom sees a
/// pinch or double-tap.
///
/// This runs on the draw path, so it has to stay cheap: on all but the frames where the
/// zoom genuinely moved it is a field read and a float compare, and it allocates only
/// when it actually publishes.
///
/// The threshold is what keeps the round trip closed. Publishing sets Zoom on the virtual
/// view, whose handler compares against this same control before pushing anything back,
/// so the value we just read cannot bounce; the threshold additionally stops float noise
/// from republishing an unchanged level on every frame of a pinch.
/// </summary>
private void ReportZoomIfChanged()
{
// Mid-apply the control is still showing the old level, and a document that has not
// loaded has no meaningful zoom to report.
if (_disposed || _zoomNeedsApply || _pageCount == 0)
return;

var zoom = Math.Clamp(_pdfView.Zoom, _minZoom, _maxZoom);

if (Math.Abs(zoom - _lastReportedZoom) < ZoomReportThreshold)
return;

_lastReportedZoom = zoom;
_zoom = zoom;
ZoomChanged?.Invoke(this, new ZoomChangedEventArgs(zoom));
}

private void OnPageChanged(int pageIndex, int pageCount)
{
_currentPage = pageIndex;
Expand Down Expand Up @@ -799,6 +845,29 @@ public bool OnTap(MotionEvent? e)
}
}

/// <summary>
/// AhmerPdfViewer exposes no zoom or scale listener — the library has none, and PDFView
/// is sealed so there is nothing to override either. The draw callback is the one hook
/// that runs after a pinch or double-tap has changed the scale.
/// </summary>
private class DrawListener : Java.Lang.Object, IOnDrawListener
{
private readonly WeakReference<PdfViewAndroid> _viewRef;

public DrawListener(PdfViewAndroid view)
{
_viewRef = new WeakReference<PdfViewAndroid>(view);
}

public void OnLayerDrawn(global::Android.Graphics.Canvas? canvas, float pageWidth, float pageHeight, int currentPage)
{
if (_viewRef.TryGetTarget(out var view))
{
view.ReportZoomIfChanged();
}
}
}

private class RenderListener : Java.Lang.Object, IOnRenderListener
{
private readonly WeakReference<PdfViewAndroid> _viewRef;
Expand Down Expand Up @@ -835,6 +904,12 @@ public void Dispose()
_tapListener = null;
}

if (_drawListener != null)
{
_drawListener.Dispose();
_drawListener = null;
}

_pdfView?.Dispose();
}
}
7 changes: 7 additions & 0 deletions src/MauiNativePdfView/Platforms/Android/PdfViewHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ protected override PDFView CreatePlatformView()
_pdfViewWrapper.LinkTapped += OnLinkTapped;
_pdfViewWrapper.Tapped += OnTapped;
_pdfViewWrapper.Rendered += OnRendered;
_pdfViewWrapper.ZoomChanged += OnZoomChanged;

return _pdfViewWrapper.NativeView;
}
Expand Down Expand Up @@ -108,6 +109,7 @@ protected override void DisconnectHandler(PDFView platformView)
_pdfViewWrapper.LinkTapped -= OnLinkTapped;
_pdfViewWrapper.Tapped -= OnTapped;
_pdfViewWrapper.Rendered -= OnRendered;
_pdfViewWrapper.ZoomChanged -= OnZoomChanged;
_pdfViewWrapper.Dispose();
_pdfViewWrapper = null;
}
Expand Down Expand Up @@ -147,6 +149,11 @@ private void OnRendered(object? sender, RenderedEventArgs e)
VirtualView?.RaiseRendered(e);
}

private void OnZoomChanged(object? sender, ZoomChangedEventArgs e)
{
VirtualView?.RaiseZoomChanged(e);
}

#endregion

#region Property Mappers
Expand Down
7 changes: 7 additions & 0 deletions src/MauiNativePdfView/Platforms/iOS/PdfViewHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ protected override PdfKit.PdfView CreatePlatformView()
_pdfViewWrapper.LinkTapped += OnLinkTapped;
_pdfViewWrapper.Tapped += OnTapped;
_pdfViewWrapper.Rendered += OnRendered;
_pdfViewWrapper.ZoomChanged += OnZoomChanged;
_pdfViewWrapper.AnnotationTapped += OnAnnotationTapped;

return _pdfViewWrapper.NativeView;
Expand Down Expand Up @@ -103,6 +104,7 @@ protected override void DisconnectHandler(PdfKit.PdfView platformView)
_pdfViewWrapper.LinkTapped -= OnLinkTapped;
_pdfViewWrapper.Tapped -= OnTapped;
_pdfViewWrapper.Rendered -= OnRendered;
_pdfViewWrapper.ZoomChanged -= OnZoomChanged;
_pdfViewWrapper.AnnotationTapped -= OnAnnotationTapped;
_pdfViewWrapper.Dispose();
_pdfViewWrapper = null;
Expand Down Expand Up @@ -141,6 +143,11 @@ private void OnRendered(object? sender, RenderedEventArgs e)
VirtualView?.RaiseRendered(e);
}

private void OnZoomChanged(object? sender, ZoomChangedEventArgs e)
{
VirtualView?.RaiseZoomChanged(e);
}

private void OnAnnotationTapped(object? sender, AnnotationTappedEventArgs e)
{
VirtualView?.RaiseAnnotationTapped(e);
Expand Down
Loading