Skip to content

Commit 2ffb9e1

Browse files
VegardHVCopilot
andauthored
Added new multicapture camera UI (#893)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 4e16d27 commit 2ffb9e1

34 files changed

Lines changed: 1168 additions & 148 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## [61.4.0]
2+
- [ImageCapture] When taking several photos in a row, the most recent photo now shows as a thumbnail in the bottom-left corner of the camera and updates with each capture. The thumbnail can be tapped to review and/or remove photos from the capture session.
3+
- [ImageCapture] Added MaxImageCount with a default of 15 when using MultiImageCapture to avoid crash as images are stored in-memory only.
4+
- [Gallery] Fixed issue where gallery always reset to the first image when the collection changed.
5+
16
## [61.3.0]
27
- Resources was updated from DIPS.Mobile.DesignTokens
38

src/app/Components/ComponentsSamples/ImageCapturing/MultiImageCaptureModalPage.xaml.cs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public partial class MultiImageCaptureModalPage
99
private readonly bool m_requiresConfirmation;
1010
private readonly List<CapturedImage> m_capturedImages = [];
1111
private readonly ImageCapture m_imageCapture = new();
12+
private bool m_hasStartedCapture;
1213
private readonly TaskCompletionSource<List<CapturedImage>> m_completion =
1314
new(TaskCreationOptions.RunContinuationsAsynchronously);
1415

@@ -24,6 +25,10 @@ protected override async void OnAppearing()
2425
{
2526
base.OnAppearing();
2627

28+
if (m_hasStartedCapture)
29+
return;
30+
m_hasStartedCapture = true;
31+
2732
var cameraOptions = new CameraOptions
2833
{
2934
CancelButtonCommand = new Command(OnCancelled)
@@ -32,16 +37,21 @@ protected override async void OnAppearing()
3237
var multiImageCaptureOptions = new MultiImageCaptureOptions
3338
{
3439
RequiresConfirmationOnEachImage = m_requiresConfirmation,
35-
FinishedButtonCommand = new Command(OnFinished)
40+
FinishedButtonCommand = new Command(OnFinished),
41+
MaxImageCount = 10,
42+
OnImageRemoved = OnImageRemoved
3643
};
3744

3845
await m_imageCapture.StartMultiImageCapture(CameraPreview, OnImageCaptured, OnCameraFailed,
3946
cameraOptions, multiImageCaptureOptions);
4047
}
4148

42-
protected override void OnDisappearing()
49+
protected override void OnHandlerChanging(HandlerChangingEventArgs args)
4350
{
44-
base.OnDisappearing();
51+
base.OnHandlerChanging(args);
52+
53+
if (args.NewHandler is not null)
54+
return;
4555

4656
m_imageCapture.Stop();
4757

@@ -54,6 +64,11 @@ private void OnImageCaptured(CapturedImage capturedImage)
5464
m_capturedImages.Add(capturedImage);
5565
}
5666

67+
private void OnImageRemoved(CapturedImage capturedImage)
68+
{
69+
m_capturedImages.Remove(capturedImage);
70+
}
71+
5772
private async void OnFinished()
5873
{
5974
m_completion.TrySetResult(m_capturedImages);

src/library/DIPS.Mobile.UI/API/Camera/Gallery/BottomSheet/BottomToolbar/GalleryBottomSheetBottomToolbar.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,6 @@ public void GoToDefaultState(IGalleryDefaultStateObserver observer)
3939
};
4040

4141
Add(removeButtonWithText);
42-
Add(doneButtonWithText);
42+
Add(doneButtonWithText);
4343
}
4444
}

src/library/DIPS.Mobile.UI/API/Camera/Gallery/BottomSheet/GalleryBottomSheet.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ protected override void OnHandlerChanging(HandlerChangingEventArgs args)
212212
if (args.NewHandler is not null)
213213
{
214214
OnImagesChanged();
215-
_ = OnCarouselViewPositionChanged(m_carouselView!.Position);
215+
_ = OnCarouselViewPositionChanged(m_carouselView!.Position);
216216
}
217217
}
218218

src/library/DIPS.Mobile.UI/API/Camera/ImageCapturing/Android/ImageCapture.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ private partial void PlatformCapturePhoto()
6262
? AndroidX.Camera.Core.ImageCapture.FlashModeOn
6363
: AndroidX.Camera.Core.ImageCapture.FlashModeOff;
6464

65-
m_imageCaptureCallback?.Dispose();
65+
m_imageCaptureCallback?.IgnoreRemainingCallbacks();
6666
m_imageCaptureCallback = new ImageCaptureCallback(
6767
onCaptureStarted: () => SimulateCameraShutter(false),
6868
onImageCaptureFailed: InvokeOnImageCaptureFailed,
@@ -80,7 +80,7 @@ private partial async Task PlatformStop()
8080
{
8181
CancelAnyActiveImageProcessing();
8282

83-
m_imageCaptureCallback?.Dispose();
83+
m_imageCaptureCallback?.IgnoreRemainingCallbacks();
8484
m_imageCaptureCallback = null;
8585

8686
await base.TryStop();
@@ -96,7 +96,7 @@ private void InvokeOnImageCaptureFailed(ImageCaptureException imageCaptureExcept
9696
{
9797
if (imageCaptureException.Message != null)
9898
{
99-
PlatformOnCameraFailed(new CameraException("DidTryCaptureImage", imageCaptureException));
99+
OnImageCaptureFailed(new CameraException("DidTryCaptureImage", imageCaptureException));
100100
}
101101
}
102102

@@ -135,7 +135,7 @@ private async void ProcessImageAndGoToConfirmState(IImageProxy imageProxy)
135135
}
136136
catch (Exception e)
137137
{
138-
PlatformOnCameraFailed(new CameraException("ProcessImageFailed", e));
138+
OnImageCaptureFailed(new CameraException("ProcessImageFailed", e));
139139
}
140140
}
141141

src/library/DIPS.Mobile.UI/API/Camera/ImageCapturing/Android/ImageCaptureCallback.cs

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Android.Runtime;
12
using AndroidX.Camera.Core;
23

34
namespace DIPS.Mobile.UI.API.Camera.ImageCapturing;
@@ -17,11 +18,25 @@ public ImageCaptureCallback(
1718
m_onImageCaptured = onImageCaptured;
1819
m_onImageCaptureFailed = onImageCaptureFailed;
1920
}
21+
22+
/// <remarks>
23+
/// If CameraX hands this Java callback to managed code after the C# instance has been disposed, Android will try to rebuild the
24+
/// managed object, and crash if this constructor doesn't exist.
25+
///
26+
/// From the .NET for Android architecture docs, "Premature Dispose() Calls" section:
27+
/// https://learn.microsoft.com/en-us/previous-versions/xamarin/android/internals/architecture
28+
/// "If a JNI handle enters managed code after the mapping has been broken, it looks like Java Activation,
29+
/// and the (IntPtr, JniHandleOwnership) constructor will be checked for and invoked. If the constructor
30+
/// doesn't exist, then an exception will be thrown."
31+
/// </remarks>
32+
protected ImageCaptureCallback(IntPtr javaReference, JniHandleOwnership transfer)
33+
: base(javaReference, transfer)
34+
{
35+
}
2036

2137
/// <summary>
2238
/// Called when the camera hardware has started exposing the sensor.
2339
/// Use this to display shutter animation or similar feedback.
24-
///
2540
/// </summary>
2641
/// <remarks>
2742
/// From docs:
@@ -41,18 +56,24 @@ public override void OnError(ImageCaptureException exception)
4156
{
4257
m_onImageCaptureFailed?.Invoke(exception);
4358
base.OnError(exception);
44-
ClearDelegates();
59+
IgnoreRemainingCallbacks();
4560
}
4661

4762
public override void OnCaptureSuccess(IImageProxy image)
4863
{
4964
m_onImageCaptured?.Invoke(image);
5065
base.OnCaptureSuccess(image);
5166
image?.Close();
52-
ClearDelegates();
67+
IgnoreRemainingCallbacks();
5368
}
54-
55-
private void ClearDelegates()
69+
70+
/// <remarks>
71+
/// From the .NET for Android architecture docs, "Premature Dispose() Calls" section:
72+
/// https://learn.microsoft.com/en-us/previous-versions/xamarin/android/internals/architecture
73+
/// "Only Dispose() of managed callable wrapper subclasses when you know that the Java object will not be
74+
/// used anymore..."
75+
/// </remarks>
76+
internal void IgnoreRemainingCallbacks()
5677
{
5778
m_onCaptureStarted = null;
5879
m_onImageCaptured = null;
@@ -61,7 +82,7 @@ private void ClearDelegates()
6182

6283
protected override void Dispose(bool disposing)
6384
{
64-
ClearDelegates();
85+
IgnoreRemainingCallbacks();
6586
base.Dispose(disposing);
6687
}
6788
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
using DIPS.Mobile.UI.API.Camera.ImageCapturing.Views;
2+
using DIPS.Mobile.UI.API.Camera.ImageCapturing.Views.BottomToolbar.StreamingState;
3+
using DIPS.Mobile.UI.Components.Alerting.Dialog;
4+
using DIPS.Mobile.UI.Resources.LocalizedStrings.LocalizedStrings;
5+
6+
namespace DIPS.Mobile.UI.API.Camera.ImageCapturing;
7+
8+
public partial class ImageCapture
9+
{
10+
private readonly List<CapturedImage> m_capturedImages = [];
11+
private CapturedImagesGalleryButton? m_capturedImagesGalleryButton;
12+
private CapturedImagesGalleryOverlay? m_galleryOverlay;
13+
14+
private bool HasReachedImageLimit =>
15+
m_cameraSession is MultiCaptureSession multiCaptureSession
16+
&& m_capturedImages.Count >= multiCaptureSession.MultiImageCaptureOptions.MaxImageCount;
17+
18+
/// <summary>
19+
/// Only show optimistic UI when no per-image confirmation is needed. With per-image confirmation, the image is not
20+
/// committed until the user accepts it, and should not update UI optimistically.
21+
/// </summary>
22+
private bool ShouldUpdateImagePreviewImmediately =>
23+
m_cameraSession is MultiCaptureSession { MultiImageCaptureOptions.RequiresConfirmationOnEachImage: false };
24+
25+
private void OptimisticallyUpdateGalleryButton()
26+
{
27+
if (!ShouldUpdateImagePreviewImmediately)
28+
return;
29+
30+
var pendingImageCount = m_capturedImages.Count + 1;
31+
m_capturedImagesGalleryButton?.ShowPendingCapture(pendingImageCount);
32+
}
33+
34+
private CapturedImagesGalleryButton? CreateCapturedImagesGalleryButtonForMultiCapture()
35+
{
36+
if (m_cameraSession is not MultiCaptureSession)
37+
{
38+
m_capturedImagesGalleryButton = null;
39+
return null;
40+
}
41+
42+
m_capturedImagesGalleryButton = new CapturedImagesGalleryButton(OpenCapturedImagesGallery);
43+
44+
m_capturedImagesGalleryButton.ShowMostRecentImageAndCount(m_capturedImages);
45+
46+
return m_capturedImagesGalleryButton;
47+
}
48+
49+
private void AddImageAndUpdateImagePreview(CapturedImage capturedImage)
50+
{
51+
m_capturedImages.Add(capturedImage);
52+
53+
m_capturedImagesGalleryButton?.ShowMostRecentImageAndCount(m_capturedImages);
54+
55+
if (HasReachedImageLimit)
56+
{
57+
ShowMaxImagesReachedMessage();
58+
}
59+
}
60+
61+
private void ShowMaxImagesReachedMessage()
62+
{
63+
if (m_cameraSession is not MultiCaptureSession multiCaptureSession)
64+
throw new InvalidOperationException($"{nameof(ShowMaxImagesReachedMessage)} is only valid during a multi capture session, but the session was {m_cameraSession?.GetType().Name ?? "null"}.");
65+
66+
var maxImageCount = multiCaptureSession.MultiImageCaptureOptions.MaxImageCount;
67+
_ = DialogService.ShowMessage(configurator => configurator
68+
.SetTitle(DUILocalizedStrings.MaxImagesReachedTitle)
69+
.SetDescription(string.Format(DUILocalizedStrings.MaxImagesReachedMessage, maxImageCount))
70+
.SetActionTitle(DUILocalizedStrings.Ok));
71+
}
72+
73+
private void OpenCapturedImagesGallery()
74+
{
75+
if (m_capturedImages.Count == 0)
76+
throw new InvalidOperationException("Captured-images gallery opened with no images. The preview is hidden when empty, so this should be unreachable.");
77+
78+
if (m_cameraPreview is null)
79+
throw new InvalidOperationException("Captured-images gallery opened after the camera preview was torn down.");
80+
81+
// Guard against repeat taps
82+
if (m_galleryOverlay is not null)
83+
return;
84+
85+
m_cameraPreview.HidePreview();
86+
87+
var startingIndex = m_capturedImages.Count - 1;
88+
89+
m_galleryOverlay = new CapturedImagesGalleryOverlay(
90+
m_capturedImages,
91+
startingIndex,
92+
onRemoveImageAtAction: RemoveImageAndUpdateGalleryButton,
93+
onCloseAction: CloseCapturedImagesGallery);
94+
95+
m_cameraPreview.AddViewToRoot(m_galleryOverlay);
96+
}
97+
98+
private async void CloseCapturedImagesGallery()
99+
{
100+
try
101+
{
102+
ArgumentNullException.ThrowIfNull(m_cameraPreview);
103+
ArgumentNullException.ThrowIfNull(m_galleryOverlay);
104+
105+
var overlay = m_galleryOverlay;
106+
m_galleryOverlay = null;
107+
108+
m_cameraPreview.ShowPreview();
109+
110+
await overlay.FadeOutAsync();
111+
112+
// The user can navigate away from the page during the fade, which nulls the preview.
113+
if (m_cameraPreview is null)
114+
return;
115+
116+
m_cameraPreview.RemoveViewFromRoot(overlay);
117+
118+
m_capturedImagesGalleryButton?.ShowMostRecentImageAndCount(m_capturedImages);
119+
m_bottomToolbarView?.SetShutterButtonEnabled(true);
120+
}
121+
catch (Exception e)
122+
{
123+
Log(e.Message);
124+
}
125+
}
126+
127+
private void TearDownGalleryOverlay()
128+
{
129+
m_cameraPreview?.RemoveViewFromRoot(m_galleryOverlay);
130+
m_galleryOverlay = null;
131+
}
132+
133+
private void RemoveImageAndUpdateGalleryButton(int index)
134+
{
135+
if (index < 0 || index >= m_capturedImages.Count)
136+
throw new ArgumentOutOfRangeException(nameof(index), index, $"The gallery asked to remove an image at an index outside the captured set (count {m_capturedImages.Count}).");
137+
138+
var removedImage = m_capturedImages[index];
139+
m_capturedImages.RemoveAt(index);
140+
141+
if (m_cameraSession is MultiCaptureSession multiCaptureSession)
142+
{
143+
multiCaptureSession.MultiImageCaptureOptions.OnImageRemoved?.Invoke(removedImage);
144+
}
145+
146+
m_capturedImagesGalleryButton?.ShowMostRecentImageAndCount(m_capturedImages);
147+
}
148+
}

src/library/DIPS.Mobile.UI/API/Camera/ImageCapturing/ImageCapture.ConfirmState.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,16 @@ void IConfirmStateObserver.OnEditButtonTapped()
7272
void IConfirmStateObserver.OnUsePhotoButtonTapped()
7373
{
7474
m_onImageCapturedDelegate?.Invoke(m_currentlyCapturedImage);
75-
75+
7676
if (m_cameraSession is MultiCaptureSession)
7777
{
78+
AddImageAndUpdateImagePreview(m_currentlyCapturedImage);
7879
GoToStreamingState();
7980
_ = PlatformStart(m_cameraSession.CameraOptions, m_cameraFailedDelegate);
8081
return;
8182
}
8283

83-
ResetAllVisuals();
84-
PlatformStop();
84+
Stop();
8585
}
8686

8787
void IConfirmStateObserver.OnRetakePhotoButtonTapped()

0 commit comments

Comments
 (0)