Skip to content

Commit 9e3ef98

Browse files
Merge pull request #27 from bengavin/bugfix/android-reattach
Fix Android Viewer showing blank screen when target of back navigation
2 parents 7be949b + de28f5e commit 9e3ef98

3 files changed

Lines changed: 250 additions & 24 deletions

File tree

‎src/MauiNativePdfView/Abstractions/PdfSource.cs‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,145 @@ public UriPdfSource(Uri uri)
142142
/// <summary>
143143
/// PDF source from a stream.
144144
/// </summary>
145+
/// <remarks>
146+
/// The view reads the stream again whenever it reloads the document — a setting change,
147+
/// <see cref="IPdfView.Reload"/>, or on Android returning to a page another one covered.
148+
/// A seekable stream is re-read from where it stood on the first load, so keep it open for
149+
/// as long as the view shows it; a forward-only stream is buffered in memory on the first
150+
/// load instead. The view never closes the stream: disposing it stays with the caller.
151+
/// </remarks>
145152
public sealed class StreamPdfSource : PdfSource
146153
{
154+
private readonly object _gate = new();
155+
private long? _startPosition;
156+
private byte[]? _buffer;
157+
147158
public Stream Stream { get; }
148159

149160
public StreamPdfSource(Stream stream)
150161
{
151162
Stream = stream ?? throw new ArgumentNullException(nameof(stream));
152163
}
164+
165+
/// <summary>
166+
/// Opens the document from its first byte, however many times the view needs to load it.
167+
/// </summary>
168+
/// <remarks>
169+
/// Each call returns a fresh stream the caller owns and may dispose without touching
170+
/// <see cref="Stream"/>. That matters on Android: AhmerPdfViewer closes whatever stream
171+
/// it is handed once it has read it, and reads it on a background thread — so handing it
172+
/// <see cref="Stream"/> itself would dispose the caller's stream on the first load, and a
173+
/// reload could seek it out from under a read still in flight.
174+
///
175+
/// A seekable stream is shared rather than copied, so a large document isn't held in
176+
/// memory twice; only a forward-only stream, which can't be read a second time, is.
177+
/// </remarks>
178+
internal Stream OpenDocument()
179+
{
180+
lock (_gate)
181+
{
182+
if (_buffer != null)
183+
return new MemoryStream(_buffer, writable: false);
184+
185+
if (_startPosition is { } start)
186+
{
187+
// A disposed stream reports CanSeek false. Say why the reload failed, rather
188+
// than let it surface as an ObjectDisposedException from the native loader.
189+
if (!Stream.CanSeek)
190+
throw new InvalidOperationException(
191+
"The stream behind this StreamPdfSource was closed after the document first loaded, " +
192+
"so the document can't be reloaded. Keep the stream open for as long as the view shows it, " +
193+
"or use PdfSource.FromBytes.");
194+
195+
return new SharedStreamReader(Stream, start, _gate);
196+
}
197+
198+
if (Stream.CanSeek)
199+
{
200+
_startPosition = Stream.Position;
201+
return new SharedStreamReader(Stream, _startPosition.Value, _gate);
202+
}
203+
204+
using var copy = new MemoryStream();
205+
Stream.CopyTo(copy);
206+
_buffer = copy.ToArray();
207+
return new MemoryStream(_buffer, writable: false);
208+
}
209+
}
210+
211+
/// <summary>
212+
/// A read-only view onto the shared source stream that keeps its own cursor, seeking the
213+
/// source to it under the source's lock on every read. Two loads in flight at once — a
214+
/// reload racing a cancelled decode that is still reading — each see the whole document.
215+
/// Disposing it leaves the source stream open.
216+
/// </summary>
217+
private sealed class SharedStreamReader : Stream
218+
{
219+
private readonly Stream _source;
220+
private readonly long _start;
221+
private readonly object _gate;
222+
private long _position;
223+
224+
public SharedStreamReader(Stream source, long start, object gate)
225+
{
226+
_source = source;
227+
_start = start;
228+
_gate = gate;
229+
}
230+
231+
public override bool CanRead => true;
232+
public override bool CanSeek => true;
233+
public override bool CanWrite => false;
234+
235+
public override long Length
236+
{
237+
get
238+
{
239+
lock (_gate)
240+
return Math.Max(_source.Length - _start, 0);
241+
}
242+
}
243+
244+
public override long Position
245+
{
246+
get => _position;
247+
set => _position = value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(value));
248+
}
249+
250+
public override int Read(byte[] buffer, int offset, int count)
251+
=> Read(buffer.AsSpan(offset, count));
252+
253+
public override int Read(Span<byte> buffer)
254+
{
255+
lock (_gate)
256+
{
257+
_source.Position = _start + _position;
258+
int read = _source.Read(buffer);
259+
_position += read;
260+
return read;
261+
}
262+
}
263+
264+
public override long Seek(long offset, SeekOrigin origin)
265+
{
266+
Position = origin switch
267+
{
268+
SeekOrigin.Begin => offset,
269+
SeekOrigin.Current => _position + offset,
270+
SeekOrigin.End => Length + offset,
271+
_ => throw new ArgumentOutOfRangeException(nameof(origin)),
272+
};
273+
return _position;
274+
}
275+
276+
public override void Flush()
277+
{
278+
}
279+
280+
public override void SetLength(long value) => throw new NotSupportedException();
281+
282+
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
283+
}
153284
}
154285

155286
/// <summary>

‎src/MauiNativePdfView/Platforms/Android/PdfViewAndroid.cs‎

Lines changed: 117 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,12 @@ public class PdfViewAndroid : IPdfView, IDisposable
3737
private int _currentPage = 0;
3838
private int _pageCount = 0;
3939
private bool _disposed;
40+
private bool _wasDetached;
4041
private float _zoom = 1.0f;
4142
private bool _zoomNeedsApply;
43+
private float _offsetX;
44+
private float _offsetY;
45+
private bool _positionNeedsApply;
4246
private readonly HashSet<int> _openedPages = new();
4347
private float _lastReportedZoom = 1.0f;
4448

@@ -50,6 +54,7 @@ public class PdfViewAndroid : IPdfView, IDisposable
5054

5155
private TapListener? _tapListener;
5256
private DrawListener? _drawListener;
57+
private AttachStateListener? _attachStateListener;
5358

5459
public PdfViewAndroid(Context context)
5560
{
@@ -58,6 +63,12 @@ public PdfViewAndroid(Context context)
5863
// sharing the PdfView's grid cell. Start transparent so an unset MAUI BackgroundColor
5964
// composites the way callers expect.
6065
_pdfView.SetBackgroundColor(global::Android.Graphics.Color.Transparent);
66+
67+
// PDFView releases its document when detached from the window - which happens whenever
68+
// another page covers this one, not just on a real teardown - and never reloads on its
69+
// own. Reload on genuine reattach instead of relying on callers to notice and ask for it.
70+
_attachStateListener = new AttachStateListener(this);
71+
_pdfView.AddOnAttachStateChangeListener(_attachStateListener);
6172
}
6273

6374
/// <summary>
@@ -175,8 +186,8 @@ private void ReclampZoom()
175186
}
176187

177188
/// <summary>
178-
/// Pushes <see cref="_zoom"/> back to the control once it can accept one. Posted so it
179-
/// runs after the layout pass that follows a load.
189+
/// Pushes <see cref="_zoom"/> (and, after a reattach, the pan position) back to the
190+
/// control. Posted so it runs after the layout pass that follows a load.
180191
/// </summary>
181192
private void SyncZoom()
182193
{
@@ -185,8 +196,11 @@ private void SyncZoom()
185196

186197
_pdfView.Post(() =>
187198
{
188-
if (!_disposed && _zoomNeedsApply && TryApplyZoom(_zoom))
199+
if (!_disposed && _zoomNeedsApply && TryRestoreZoomAndPosition())
200+
{
189201
_zoomNeedsApply = false;
202+
_positionNeedsApply = false;
203+
}
190204
});
191205
}
192206

@@ -217,16 +231,43 @@ private bool TryApplyZoom(float zoom)
217231
if (Math.Abs(_pdfView.Zoom - zoom) > float.Epsilon)
218232
{
219233
_pdfView.ZoomCenteredTo(zoom, new global::Android.Graphics.PointF(_pdfView.Width / 2f, _pdfView.Height / 2f));
220-
_pdfView.LoadPages();
221-
// Re-settles the page under a snapping display mode, as the animated path does.
222-
_pdfView.PerformPageSnap();
223-
// The viewport now covers a different set of pages.
224-
EnsureVisiblePagesOpen();
234+
RefreshRenderState();
225235
}
226236

227237
return true;
228238
}
229239

240+
/// <summary>
241+
/// Reapplies zoom and pan after a reload. After a reattach, the saved zoom and offset are
242+
/// a consistent pair, so ZoomTo + MoveTo restores both. Otherwise defers to
243+
/// <see cref="TryApplyZoom"/>, which keeps the offset consistent with the zoom.
244+
/// </summary>
245+
private bool TryRestoreZoomAndPosition()
246+
{
247+
if (!_positionNeedsApply)
248+
return TryApplyZoom(_zoom);
249+
250+
if (_pageCount == 0 || _pdfView.Width <= 0 || _pdfView.Height <= 0)
251+
return false;
252+
253+
_pdfView.ZoomTo(_zoom);
254+
_pdfView.MoveTo(_offsetX, _offsetY);
255+
RefreshRenderState();
256+
257+
return true;
258+
}
259+
260+
/// <summary>
261+
/// Re-renders after a zoom or move: reloads visible tiles, re-snaps the page, and opens
262+
/// the pages now in view.
263+
/// </summary>
264+
private void RefreshRenderState()
265+
{
266+
_pdfView.LoadPages();
267+
_pdfView.PerformPageSnap();
268+
EnsureVisiblePagesOpen();
269+
}
270+
230271
/// <summary>
231272
/// Opens every page a touch inside the current viewport could resolve to.
232273
///
@@ -543,6 +584,27 @@ public void Reload()
543584

544585
#endregion
545586

587+
private void OnDetachedFromWindow()
588+
{
589+
_wasDetached = true;
590+
591+
// Native state is already reset here, so rely on what ReportZoomIfChanged tracked -
592+
// and only restore position if that tracking was live (not mid-load).
593+
_positionNeedsApply |= !_zoomNeedsApply && _pageCount > 0;
594+
_zoomNeedsApply = true;
595+
}
596+
597+
// Only reload on a genuine reattach after a real detach - not the initial attach every
598+
// view gets when it's first created, which this would otherwise also fire on.
599+
private void OnReattachedToWindow()
600+
{
601+
if (_disposed || !_wasDetached)
602+
return;
603+
604+
_wasDetached = false;
605+
LoadDocument();
606+
}
607+
546608
private void LoadDocument(bool preserveZoom = true)
547609
{
548610
if (_source == null)
@@ -555,9 +617,15 @@ private void LoadDocument(bool preserveZoom = true)
555617
// settle on the level to replay afterwards — the same treatment the current page
556618
// gets — and mark it unapplied.
557619
if (preserveZoom)
620+
{
558621
CaptureZoom();
622+
}
559623
else
624+
{
560625
_zoom = Math.Clamp(1.0f, _minZoom, _maxZoom);
626+
// New document: don't restore the previous one's position.
627+
_positionNeedsApply = false;
628+
}
561629

562630
_zoomNeedsApply = true;
563631

@@ -570,7 +638,7 @@ private void LoadDocument(bool preserveZoom = true)
570638
{
571639
FilePdfSource fileSource => _pdfView.FromFile(new Java.IO.File(fileSource.FilePath)),
572640
UriPdfSource uriSource => _pdfView.FromUri(global::Android.Net.Uri.Parse(uriSource.Uri.ToString())),
573-
StreamPdfSource streamSource => _pdfView.FromStream(streamSource.Stream),
641+
StreamPdfSource streamSource => _pdfView.FromStream(streamSource.OpenDocument()),
574642
BytesPdfSource bytesSource => _pdfView.FromBytes(bytesSource.Data),
575643
AssetPdfSource assetSource => _pdfView.FromAsset(assetSource.AssetName),
576644
_ => throw new NotSupportedException($"PDF source type {_source.GetType().Name} is not supported.")
@@ -670,11 +738,11 @@ private void OnDocumentLoadedWithPageRestore(int pageCount, int pageToRestore)
670738

671739
/// <summary>
672740
/// Publishes the level the control is actually showing, so a caller bound to Zoom sees a
673-
/// pinch or double-tap.
741+
/// pinch or double-tap. Also records the pan offset for a reattach restore.
674742
///
675743
/// This runs on the draw path, so it has to stay cheap: on all but the frames where the
676-
/// zoom genuinely moved it is a field read and a float compare, and it allocates only
677-
/// when it actually publishes.
744+
/// zoom genuinely moved it is three property reads and a float compare, and it allocates
745+
/// only when it actually publishes.
678746
///
679747
/// The threshold is what keeps the round trip closed. Publishing sets Zoom on the virtual
680748
/// view, whose handler compares against this same control before pushing anything back,
@@ -688,6 +756,10 @@ private void ReportZoomIfChanged()
688756
if (_disposed || _zoomNeedsApply || _pageCount == 0)
689757
return;
690758

759+
// Before the zoom check - a pan changes the offset without changing zoom.
760+
_offsetX = _pdfView.CurrentXOffset;
761+
_offsetY = _pdfView.CurrentYOffset;
762+
691763
var zoom = Math.Clamp(_pdfView.Zoom, _minZoom, _maxZoom);
692764

693765
if (Math.Abs(zoom - _lastReportedZoom) < ZoomReportThreshold)
@@ -732,17 +804,6 @@ private void OnRendered(int pageCount)
732804
Rendered?.Invoke(this, new RenderedEventArgs(pageCount));
733805
}
734806

735-
#region Helper Methods
736-
737-
private PDFView.Configurator FromStream(Stream stream)
738-
{
739-
var memoryStream = new MemoryStream();
740-
stream.CopyTo(memoryStream);
741-
return _pdfView.FromBytes(memoryStream.ToArray());
742-
}
743-
744-
#endregion
745-
746807
#region Listener Implementations
747808

748809
private class LoadCompleteListener : Java.Lang.Object, IOnLoadCompleteListener
@@ -900,6 +961,32 @@ public void OnInitiallyRendered(int nbPages)
900961
}
901962
}
902963

964+
private class AttachStateListener : Java.Lang.Object, global::Android.Views.View.IOnAttachStateChangeListener
965+
{
966+
private readonly WeakReference<PdfViewAndroid> _viewRef;
967+
968+
public AttachStateListener(PdfViewAndroid view)
969+
{
970+
_viewRef = new WeakReference<PdfViewAndroid>(view);
971+
}
972+
973+
public void OnViewAttachedToWindow(global::Android.Views.View attachedView)
974+
{
975+
if (_viewRef.TryGetTarget(out var view))
976+
{
977+
view.OnReattachedToWindow();
978+
}
979+
}
980+
981+
public void OnViewDetachedFromWindow(global::Android.Views.View detachedView)
982+
{
983+
if (_viewRef.TryGetTarget(out var view))
984+
{
985+
view.OnDetachedFromWindow();
986+
}
987+
}
988+
}
989+
903990
#endregion
904991

905992
public void Dispose()
@@ -912,6 +999,13 @@ public void Dispose()
912999
// a disposed Java object and throw on the UI thread.
9131000
_disposed = true;
9141001

1002+
if (_attachStateListener != null)
1003+
{
1004+
_pdfView?.RemoveOnAttachStateChangeListener(_attachStateListener);
1005+
_attachStateListener.Dispose();
1006+
_attachStateListener = null;
1007+
}
1008+
9151009
if (_tapListener != null)
9161010
{
9171011
_tapListener.Dispose();

0 commit comments

Comments
 (0)