From 9477a7c6083bb75329e725ff0b2c53b1b6f0c975 Mon Sep 17 00:00:00 2001 From: Shaun Lawrence <17139988+bijington@users.noreply.github.com> Date: Tue, 8 Jul 2025 19:25:29 +0100 Subject: [PATCH 1/6] Workaround .NET MAUI touch based behaviour --- src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs index 538e9d53b2..527a188974 100644 --- a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs +++ b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs @@ -200,6 +200,9 @@ public PopupPageLayout(in Popup popupContent, in IPopupOptions options, in IComm BackgroundColor = popupContent.BackgroundColor ??= Options.DefaultPopupSettings.BackgroundColor, Content = popupContent }; + + // Currently .NET MAUI allows for the TapGestureRecognizer added to the BoxView behind this Border to be triggered with a tap on Android. + PopupBorder.GestureRecognizers.Add(new TapGestureRecognizer()); // Bind `Popup` values through to Border using OneWay Bindings PopupBorder.SetBinding(Border.MarginProperty, static (Popup popup) => popup.Margin, source: popupContent, mode: BindingMode.OneWay, converter: new MarginConverter()); From 10f0222e48c0480afcfd246f0b6ca31858e776ad Mon Sep 17 00:00:00 2001 From: Shaun Lawrence <17139988+bijington@users.noreply.github.com> Date: Thu, 10 Jul 2025 07:33:33 +0100 Subject: [PATCH 2/6] Shift to checking the bounds of the touch --- .../Views/Popup/PopupPage.shared.cs | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs index 527a188974..52d66b0983 100644 --- a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs +++ b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs @@ -182,8 +182,12 @@ void IQueryAttributable.ApplyQueryAttributes(IDictionary query) internal sealed partial class PopupPageLayout : Grid { + readonly TapGestureRecognizer backgroundTapGestureRecognizer; + readonly ICommand tapOutsideOfPopupCommand; + public PopupPageLayout(in Popup popupContent, in IPopupOptions options, in ICommand tapOutsideOfPopupCommand) { + this.tapOutsideOfPopupCommand = tapOutsideOfPopupCommand; Background = BackgroundColor = null; var tappableBackground = new BoxView @@ -192,17 +196,17 @@ public PopupPageLayout(in Popup popupContent, in IPopupOptions options, in IComm HorizontalOptions = LayoutOptions.Fill, VerticalOptions = LayoutOptions.Fill }; - tappableBackground.GestureRecognizers.Add(new TapGestureRecognizer { Command = tapOutsideOfPopupCommand }); - Children.Add(tappableBackground); // Add the Tappable Background to the PopupPageLayout Grid before adding the Border to ensure the Border is displayed on top + backgroundTapGestureRecognizer = new TapGestureRecognizer(); + backgroundTapGestureRecognizer.Tapped += BackgroundTapGestureRecognizerOnTapped; + + tappableBackground.GestureRecognizers.Add(backgroundTapGestureRecognizer); + Children.Add(tappableBackground); PopupBorder = new Border { BackgroundColor = popupContent.BackgroundColor ??= Options.DefaultPopupSettings.BackgroundColor, Content = popupContent }; - - // Currently .NET MAUI allows for the TapGestureRecognizer added to the BoxView behind this Border to be triggered with a tap on Android. - PopupBorder.GestureRecognizers.Add(new TapGestureRecognizer()); // Bind `Popup` values through to Border using OneWay Bindings PopupBorder.SetBinding(Border.MarginProperty, static (Popup popup) => popup.Margin, source: popupContent, mode: BindingMode.OneWay, converter: new MarginConverter()); @@ -219,6 +223,22 @@ public PopupPageLayout(in Popup popupContent, in IPopupOptions options, in IComm Children.Add(PopupBorder); } + + void BackgroundTapGestureRecognizerOnTapped(object? sender, TappedEventArgs e) + { + var position = e.GetPosition(this); + + if (position is null) + { + return; + } + + if (PopupBorder.Bounds.Contains(position.Value) is false && + tapOutsideOfPopupCommand.CanExecute(null)) + { + tapOutsideOfPopupCommand.Execute(null); + } + } public Border PopupBorder { get; } From 51e996bcaea8178eef8cb2d480b0402db95d4919 Mon Sep 17 00:00:00 2001 From: Brandon Minnick <13558917+TheCodeTraveler@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:32:21 -0700 Subject: [PATCH 3/6] Refactor code --- .../Views/Popup/PopupPage.shared.cs | 88 +++++++++---------- 1 file changed, 43 insertions(+), 45 deletions(-) diff --git a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs index 52d66b0983..c3bec99553 100644 --- a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs +++ b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs @@ -36,7 +36,7 @@ public PopupPage(View view, IPopupOptions? popupOptions) public PopupPage(Popup popup, IPopupOptions? popupOptions) { ArgumentNullException.ThrowIfNull(popup); - + this.popup = popup; this.popupOptions = popupOptions ??= Options.DefaultPopupOptionsSettings; @@ -46,8 +46,13 @@ public PopupPage(Popup popup, IPopupOptions? popupOptions) await CloseAsync(new PopupResult(true)); }, () => GetCanBeDismissedByTappingOutsideOfPopup(popup, popupOptions)); - // Only set the content if the parent constructor hasn't set the content already; don't override content if it already exists - base.Content = new PopupPageLayout(popup, popupOptions, tapOutsideOfPopupCommand); + var pageTapGestureRecognizer = new TapGestureRecognizer(); + pageTapGestureRecognizer.Tapped += HandleTapGestureRecognizerTapped; + + base.Content = new PopupPageLayout(popup, popupOptions) + { + GestureRecognizers = { pageTapGestureRecognizer } + }; popup.PropertyChanged += HandlePopupPropertyChanged; if (popupOptions is BindableObject bindablePopupOptions) @@ -83,7 +88,7 @@ public async Task CloseAsync(PopupResult result, CancellationToken token = defau } if (Navigation.ModalStack[^1] is Microsoft.Maui.Controls.Page currentVisibleModalPage - && currentVisibleModalPage != popupPageToClose) + && currentVisibleModalPage != popupPageToClose) { throw new PopupBlockedException(currentVisibleModalPage); } @@ -101,12 +106,7 @@ public async Task CloseAsync(PopupResult result, CancellationToken token = defau protected override bool OnBackButtonPressed() { - // When the Android Back Button is tapped, we only close the Popup if the tapOutsideOfPopupCommand can execute - // In other words, we'll only close the Popup when CanBeDismissedByTappingOutsideOfPopup is true - if (tapOutsideOfPopupCommand.CanExecute(null)) - { - tapOutsideOfPopupCommand.Execute(null); - } + TryExecuteTapOutsideOfPopupCommand(); // Always return true to let the Android Operating System know that we are manually handling the Navigation request from the Android Back Button return true; @@ -180,27 +180,41 @@ void IQueryAttributable.ApplyQueryAttributes(IDictionary query) } } - internal sealed partial class PopupPageLayout : Grid + void HandleTapGestureRecognizerTapped(object? sender, TappedEventArgs e) { - readonly TapGestureRecognizer backgroundTapGestureRecognizer; - readonly ICommand tapOutsideOfPopupCommand; + ArgumentNullException.ThrowIfNull(sender); - public PopupPageLayout(in Popup popupContent, in IPopupOptions options, in ICommand tapOutsideOfPopupCommand) + var popupPageLayout = (PopupPageLayout)sender; + var position = e.GetPosition(Content); + + if (position is null) { - this.tapOutsideOfPopupCommand = tapOutsideOfPopupCommand; - Background = BackgroundColor = null; + return; + } - var tappableBackground = new BoxView - { - BackgroundColor = Colors.Transparent, - HorizontalOptions = LayoutOptions.Fill, - VerticalOptions = LayoutOptions.Fill - }; - backgroundTapGestureRecognizer = new TapGestureRecognizer(); - backgroundTapGestureRecognizer.Tapped += BackgroundTapGestureRecognizerOnTapped; + // Execute tapOutsideOfPopupCommand only if tap occurred outside the PopupBorder + if (popupPageLayout.PopupBorder.Bounds.Contains(position.Value) is false) + { + TryExecuteTapOutsideOfPopupCommand(); + } + } - tappableBackground.GestureRecognizers.Add(backgroundTapGestureRecognizer); - Children.Add(tappableBackground); + bool TryExecuteTapOutsideOfPopupCommand() + { + if (!tapOutsideOfPopupCommand.CanExecute(null)) + { + return false; + } + + tapOutsideOfPopupCommand.Execute(null); + return true; + } + + internal sealed partial class PopupPageLayout : Grid + { + public PopupPageLayout(in Popup popupContent, in IPopupOptions options) + { + Background = BackgroundColor = null; PopupBorder = new Border { @@ -223,22 +237,6 @@ public PopupPageLayout(in Popup popupContent, in IPopupOptions options, in IComm Children.Add(PopupBorder); } - - void BackgroundTapGestureRecognizerOnTapped(object? sender, TappedEventArgs e) - { - var position = e.GetPosition(this); - - if (position is null) - { - return; - } - - if (PopupBorder.Bounds.Contains(position.Value) is false && - tapOutsideOfPopupCommand.CanExecute(null)) - { - tapOutsideOfPopupCommand.Execute(null); - } - } public Border PopupBorder { get; } @@ -255,7 +253,7 @@ sealed partial class BorderStrokeConverter : BaseConverterOneWay public override Brush? ConvertFrom(Shape? value, CultureInfo? culture) => value?.Stroke; } - + sealed partial class HorizontalOptionsConverter : BaseConverterOneWay { public override LayoutOptions DefaultConvertReturnValue { get; set; } = Options.DefaultPopupSettings.HorizontalOptions; @@ -269,7 +267,7 @@ sealed partial class VerticalOptionsConverter : BaseConverterOneWay value == LayoutOptions.Fill ? Options.DefaultPopupSettings.VerticalOptions : value; } - + sealed partial class BackgroundColorConverter : BaseConverterOneWay { public override Color DefaultConvertReturnValue { get; set; } = Options.DefaultPopupSettings.BackgroundColor; @@ -284,7 +282,7 @@ sealed partial class PaddingConverter : BaseConverterOneWay value == default ? Options.DefaultPopupSettings.Padding : value; } - + sealed partial class MarginConverter : BaseConverterOneWay { public override Thickness DefaultConvertReturnValue { get; set; } = Options.DefaultPopupSettings.Margin; From 2a834489dfa2c96c40d427a19f16bfa29ae52413 Mon Sep 17 00:00:00 2001 From: Brandon Minnick <13558917+TheCodeTraveler@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:54:45 -0700 Subject: [PATCH 4/6] Update Unit Tests --- .../Popup/DefaultPopupOptionsSettingsTests.cs | 2 +- .../Views/Popup/PopupPageTests.cs | 79 +++++++++++-------- .../Views/Popup/PopupPage.shared.cs | 22 +++--- 3 files changed, 60 insertions(+), 43 deletions(-) diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupOptionsSettingsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupOptionsSettingsTests.cs index 4d8b8f24e2..d74944164a 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupOptionsSettingsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupOptionsSettingsTests.cs @@ -282,6 +282,6 @@ public void View_SetPopupDefaultsCalled_PopupOptionsOverridden_UsesProvidedPopup static TapGestureRecognizer GetTapOutsideGestureRecognizer(PopupPage popupPage) => - (TapGestureRecognizer)popupPage.Content.Children.OfType().Single().GestureRecognizers[0]; + (TapGestureRecognizer)popupPage.Content.GestureRecognizers.Single(); } #pragma warning restore CA1416 \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs index d267eceebc..5c1d8ba8f0 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs @@ -5,6 +5,7 @@ using Microsoft.Maui.Controls.PlatformConfiguration; using Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific; using Microsoft.Maui.Controls.Shapes; +using Nito.AsyncEx; using Xunit; using Application = Microsoft.Maui.Controls.Application; using Page = Microsoft.Maui.Controls.Page; @@ -195,39 +196,73 @@ public void TapGestureRecognizer_VerifyCanBeDismissedByTappingOutsideOfPopup_Sho // Act var popupPage = new PopupPage(view, popupOptions); - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); // Assert - Assert.True(tapGestureRecognizer.Command?.CanExecute(null)); + + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected because we did not call ShowPopup() + { + } // Act view.CanBeDismissedByTappingOutsideOfPopup = false; popupOptions.CanBeDismissedByTappingOutsideOfPopup = false; // Assert - Assert.False(tapGestureRecognizer.Command?.CanExecute(null)); + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.False(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected because we did not call ShowPopup() + { + } // Act view.CanBeDismissedByTappingOutsideOfPopup = true; popupOptions.CanBeDismissedByTappingOutsideOfPopup = false; // Assert - Assert.False(tapGestureRecognizer.Command?.CanExecute(null)); + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.False(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected because we did not call ShowPopup() + { + } // Act view.CanBeDismissedByTappingOutsideOfPopup = false; popupOptions.CanBeDismissedByTappingOutsideOfPopup = true; // Assert - Assert.False(tapGestureRecognizer.Command?.CanExecute(null)); + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.False(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected because we did not call ShowPopup() + { + } // Act view.CanBeDismissedByTappingOutsideOfPopup = true; popupOptions.CanBeDismissedByTappingOutsideOfPopup = true; // Assert - Assert.True(tapGestureRecognizer.Command?.CanExecute(null)); - + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected because we did not call ShowPopup() + { + } } [Fact] @@ -261,22 +296,14 @@ public void Constructor_WithViewAndPopupOptions_SetsCorrectProperties() Assert.Equal(UIModalPresentationStyle.OverFullScreen, popupPage.On().ModalPresentationStyle()); // Verify content has tap gesture recognizer attached - var gestureRecognizers = popupPage.Content.Children.OfType().Single().GestureRecognizers; + var gestureRecognizers = popupPage.Content.GestureRecognizers; Assert.Single(gestureRecognizers); Assert.IsType(gestureRecognizers[0]); // Verify PopupPageLayout structure var pageContent = popupPage.Content; - Assert.Collection( - pageContent.Children, - first => - { - first.Should().BeOfType(); - }, - second => - { - second.Should().BeOfType(); - }); + Assert.Single(pageContent.Children); + Assert.IsType(pageContent.Children.Single(), exactMatch: false); // Verify content binding context is set correctly Assert.Equal(view.BindingContext, pageContent.BindingContext); @@ -307,7 +334,7 @@ public void Constructor_WithNullPopup_ThrowsArgumentNullException() } [Fact] - public async Task TapGestureRecognizer_ShouldClosePopupWhenCanBeDismissedIsTrue() + public async Task OnTappingOutsideOfPopup_ShouldClosePopupWhenCanBeDismissedIsTrue() { // Arrange bool actionInvoked = false; @@ -323,14 +350,7 @@ public async Task TapGestureRecognizer_ShouldClosePopupWhenCanBeDismissedIsTrue( } }; - var popupPage = new PopupPage(view, popupOptions); - - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); - var command = tapGestureRecognizer.Command; - Assert.NotNull(command); - // Act & Assert - Assert.True(command.CanExecute(null)); popupOptions.OnTappingOutsideOfPopup?.Invoke(); var result = await actionInvokedTCS.Task; @@ -350,12 +370,9 @@ public void TapGestureRecognizer_ShouldNotExecuteWhenCanBeDismissedIsFalse() }; var popupPage = new PopupPage(view, popupOptions); - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); - var command = tapGestureRecognizer.Command; // Act & Assert - Assert.NotNull(command); - Assert.False(command.CanExecute(null)); + Assert.False(popupPage.TryExecuteTapOutsideOfPopupCommand()); } [Fact] @@ -489,7 +506,7 @@ public void PopupPage_ShouldRespectLayoutOptions() } static TapGestureRecognizer GetTapOutsideGestureRecognizer(PopupPage popupPage) => - (TapGestureRecognizer)popupPage.Content.Children.OfType().Single().GestureRecognizers[0]; + (TapGestureRecognizer)popupPage.Content.GestureRecognizers.Single(); // Helper class for testing protected methods sealed class TestablePopupPage(View view, IPopupOptions popupOptions) : PopupPage(view, popupOptions) diff --git a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs index c3bec99553..521afddd94 100644 --- a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs +++ b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs @@ -146,6 +146,17 @@ protected override void OnNavigatedTo(NavigatedToEventArgs args) return popup; } + + internal bool TryExecuteTapOutsideOfPopupCommand() + { + if (!tapOutsideOfPopupCommand.CanExecute(null)) + { + return false; + } + + tapOutsideOfPopupCommand.Execute(null); + return true; + } // Only dismiss when a user taps outside Popup when **both** Popup.CanBeDismissedByTappingOutsideOfPopup and PopupOptions.CanBeDismissedByTappingOutsideOfPopup are true // If either value is false, do not dismiss Popup @@ -199,17 +210,6 @@ void HandleTapGestureRecognizerTapped(object? sender, TappedEventArgs e) } } - bool TryExecuteTapOutsideOfPopupCommand() - { - if (!tapOutsideOfPopupCommand.CanExecute(null)) - { - return false; - } - - tapOutsideOfPopupCommand.Execute(null); - return true; - } - internal sealed partial class PopupPageLayout : Grid { public PopupPageLayout(in Popup popupContent, in IPopupOptions options) From 32860e0315945492f2e3fbcfe5ef4285476b1dfb Mon Sep 17 00:00:00 2001 From: Brandon Minnick <13558917+TheCodeTraveler@users.noreply.github.com> Date: Fri, 11 Jul 2025 11:03:41 -0700 Subject: [PATCH 5/6] Update Unit Tests --- .../Extensions/PopupExtensionsTests.cs | 64 ++++++++++--- .../Popup/DefaultPopupOptionsSettingsTests.cs | 92 ++++++++++--------- .../Views/Popup/PopupPageTests.cs | 19 ++-- 3 files changed, 109 insertions(+), 66 deletions(-) diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/PopupExtensionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/PopupExtensionsTests.cs index 31c419942d..3e39b52458 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/PopupExtensionsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/PopupExtensionsTests.cs @@ -5,6 +5,7 @@ using CommunityToolkit.Maui.UnitTests.Services; using CommunityToolkit.Maui.Views; using Microsoft.Maui.Controls.Shapes; +using Nito.AsyncEx; using Xunit; namespace CommunityToolkit.Maui.UnitTests.Extensions; @@ -1162,8 +1163,14 @@ public async Task ShowPopupAsync_ReferenceTypeShouldReturnNull_WhenPopupTapGestu var popupPage = (PopupPage)navigation.ModalStack[0]; popupPage.PopupClosed += HandlePopupClosed; - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); - tapGestureRecognizer.Command?.Execute(null); + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called + { + } var popupClosedResult = await popupClosedTCS.Task; var showPopupResult = await showPopupTask; @@ -1197,8 +1204,14 @@ public async Task ShowPopupAsync_Shell_ReferenceTypeShouldReturnNull_WhenPopupTa var popupPage = (PopupPage)shellNavigation.ModalStack[0]; popupPage.PopupClosed += HandlePopupClosed; - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); - tapGestureRecognizer.Command?.Execute(null); + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called + { + } var popupClosedResult = await popupClosedTCS.Task; var showPopupResult = await showPopupTask; @@ -1225,8 +1238,14 @@ public async Task ShowPopupAsync_NullableValueTypeShouldReturnResult_WhenPopupIs var popupPage = (PopupPage)navigation.ModalStack[0]; popupPage.PopupClosed += HandlePopupClosed; - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); - tapGestureRecognizer.Command?.Execute(null); + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called + { + } var popupClosedResult = await popupClosedTCS.Task; var showPopupResult = await showPopupTask; @@ -1260,8 +1279,14 @@ public async Task ShowPopupAsync_Shell_NullableValueTypeShouldReturnResult_WhenP var popupPage = (PopupPage)shellNavigation.ModalStack[0]; popupPage.PopupClosed += HandlePopupClosed; - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); - tapGestureRecognizer.Command?.Execute(null); + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called + { + } var popupClosedResult = await popupClosedTCS.Task; var showPopupResult = await showPopupTask; @@ -1288,8 +1313,14 @@ public async Task ShowPopupAsync_ValueTypeShouldReturnResult_WhenPopupIsClosedBy var popupPage = (PopupPage)navigation.ModalStack[0]; popupPage.PopupClosed += HandlePopupClosed; - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); - tapGestureRecognizer.Command?.Execute(null); + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called + { + } var popupClosedResult = await popupClosedTCS.Task; var showPopupResult = await showPopupTask; @@ -1324,8 +1355,14 @@ public async Task ShowPopupAsync_Shell_ValueTypeShouldReturnResult_WhenPopupIsCl var popupPage = (PopupPage)shellNavigation.ModalStack[0]; popupPage.PopupClosed += HandlePopupClosed; - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); - tapGestureRecognizer.Command?.Execute(null); + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called + { + } var popupClosedResult = await popupClosedTCS.Task; var showPopupResult = await showPopupTask; @@ -1472,9 +1509,6 @@ public async Task ShowPopupAsync_TaskShouldCompleteWhenCloseAsyncIsCalled() Assert.Equal(expectedResult, popupResult.Result); Assert.False(popupResult.WasDismissedByTappingOutsideOfPopup); } - - static TapGestureRecognizer GetTapOutsideGestureRecognizer(PopupPage popupPage) => - (TapGestureRecognizer)popupPage.Content.Children.OfType().Single().GestureRecognizers[0]; } sealed class ViewWithIQueryAttributable : Button, IQueryAttributable diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupOptionsSettingsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupOptionsSettingsTests.cs index d74944164a..ac6cfc284a 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupOptionsSettingsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupOptionsSettingsTests.cs @@ -27,19 +27,25 @@ public void Popup_SetPopupOptionsDefaultsNotCalled_UsesPopupOptionsDefaults() var popupPage = new PopupPage(new Popup(), null); var popupBorder = popupPage.Content.PopupBorder; - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); - // Assert - Assert.True(tapGestureRecognizer.Command?.CanExecute(null)); + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called + { + } + Assert.Equal(2, popupBorder.StrokeThickness); Assert.Equal(Colors.LightGray, popupBorder.Stroke); Assert.Equal(Colors.Black.WithAlpha(0.3f), popupPage.BackgroundColor); - + Assert.Equal(Colors.Black, popupBorder.Shadow.Brush); Assert.Equal(new(20, 20), popupBorder.Shadow.Offset); Assert.Equal(40, popupBorder.Shadow.Radius); Assert.Equal(0.8f, popupBorder.Shadow.Opacity); - + Assert.Equal(new CornerRadius(20, 20, 20, 20), ((RoundRectangle?)popupBorder.StrokeShape)?.CornerRadius); Assert.Equal(2, ((RoundRectangle?)popupBorder.StrokeShape)?.StrokeThickness); Assert.Equal(Colors.LightGray, ((RoundRectangle?)popupBorder.StrokeShape)?.Stroke); @@ -52,19 +58,25 @@ public void Popup_SetPopupOptionsNotCalled_PopupOptionsEmptyUsed_UsesPopupOption var popupPage = new PopupPage(new Popup(), PopupOptions.Empty); var popupBorder = popupPage.Content.PopupBorder; - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); - // Assert - Assert.True(tapGestureRecognizer.Command?.CanExecute(null)); + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called + { + } + Assert.Equal(2, popupBorder.StrokeThickness); Assert.Equal(Colors.LightGray, popupBorder.Stroke); Assert.Equal(Colors.Black.WithAlpha(0.3f), popupPage.BackgroundColor); - + Assert.Equal(Colors.Black, popupBorder.Shadow.Brush); Assert.Equal(new(20, 20), popupBorder.Shadow.Offset); Assert.Equal(40, popupBorder.Shadow.Radius); Assert.Equal(0.8f, popupBorder.Shadow.Opacity); - + Assert.Equal(new CornerRadius(20, 20, 20, 20), ((RoundRectangle?)popupBorder.StrokeShape)?.CornerRadius); Assert.Equal(2, ((RoundRectangle?)popupBorder.StrokeShape)?.StrokeThickness); Assert.Equal(Colors.LightGray, ((RoundRectangle?)popupBorder.StrokeShape)?.Stroke); @@ -90,20 +102,18 @@ public void Popup_SetPopupDefaultsCalled_UsesDefaultPopupOptionsSettings() var popupPage = new PopupPage(new Popup(), null); var popupBorder = popupPage.Content.PopupBorder; - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); // Act try { // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute - AsyncContext.Run(() => { tapGestureRecognizer.Command?.Execute(null); }); + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); } catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called { } - // // Assert - Assert.True(tapGestureRecognizer.Command?.CanExecute(null)); + // Assert Assert.True(hasOnTappingOutsideOfPopupExecuted); Assert.Equal(defaultPopupSettings.PageOverlayColor, popupPage.BackgroundColor); Assert.Equal(defaultPopupSettings.Shadow, popupBorder.Shadow); @@ -130,20 +140,18 @@ public void Popup_SetPopupDefaultsCalled_PopupOptionsOverridden_UsesProvidedPopu var popupPage = new PopupPage(new Popup(), defaultPopupSettings); var popupBorder = popupPage.Content.PopupBorder; - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); // Act try { // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute - AsyncContext.Run(() => { tapGestureRecognizer.Command?.Execute(null); }); + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); } catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called { } // // Assert - Assert.True(tapGestureRecognizer.Command?.CanExecute(null)); Assert.True(hasOnTappingOutsideOfPopupExecuted); Assert.Equal(defaultPopupSettings.PageOverlayColor, popupPage.BackgroundColor); Assert.Equal(defaultPopupSettings.Shadow, popupBorder.Shadow); @@ -157,19 +165,25 @@ public void View_SetPopupOptionsDefaultsNotCalled_UsesPopupOptionsDefaults() var popupPage = new PopupPage(new View(), null); var popupBorder = popupPage.Content.PopupBorder; - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); - // Assert - Assert.True(tapGestureRecognizer.Command?.CanExecute(null)); + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called + { + } + Assert.Equal(2, popupBorder.StrokeThickness); Assert.Equal(Colors.LightGray, popupBorder.Stroke); Assert.Equal(Colors.Black.WithAlpha(0.3f), popupPage.BackgroundColor); - + Assert.Equal(Colors.Black, popupBorder.Shadow.Brush); Assert.Equal(new(20, 20), popupBorder.Shadow.Offset); Assert.Equal(40, popupBorder.Shadow.Radius); Assert.Equal(0.8f, popupBorder.Shadow.Opacity); - + Assert.Equal(new CornerRadius(20, 20, 20, 20), ((RoundRectangle?)popupBorder.StrokeShape)?.CornerRadius); Assert.Equal(2, ((RoundRectangle?)popupBorder.StrokeShape)?.StrokeThickness); Assert.Equal(Colors.LightGray, ((RoundRectangle?)popupBorder.StrokeShape)?.Stroke); @@ -182,19 +196,24 @@ public void View_SetPopupOptionsNotCalled_PopupOptionsEmptyUsed_UsesPopupOptions var popupPage = new PopupPage(new View(), PopupOptions.Empty); var popupBorder = popupPage.Content.PopupBorder; - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); - -// Assert - Assert.True(tapGestureRecognizer.Command?.CanExecute(null)); + // Assert + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called + { + } Assert.Equal(2, popupBorder.StrokeThickness); Assert.Equal(Colors.LightGray, popupBorder.Stroke); Assert.Equal(Colors.Black.WithAlpha(0.3f), popupPage.BackgroundColor); - + Assert.Equal(Colors.Black, popupBorder.Shadow.Brush); Assert.Equal(new(20, 20), popupBorder.Shadow.Offset); Assert.Equal(40, popupBorder.Shadow.Radius); Assert.Equal(0.8f, popupBorder.Shadow.Opacity); - + Assert.Equal(new CornerRadius(20, 20, 20, 20), ((RoundRectangle?)popupBorder.StrokeShape)?.CornerRadius); Assert.Equal(2, ((RoundRectangle?)popupBorder.StrokeShape)?.StrokeThickness); Assert.Equal(Colors.LightGray, ((RoundRectangle?)popupBorder.StrokeShape)?.Stroke); @@ -220,20 +239,18 @@ public void View_SetPopupDefaultsCalled_UsesDefaultPopupOptionsSettings() var popupPage = new PopupPage(new View(), null); var popupBorder = popupPage.Content.PopupBorder; - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); // Act try { // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute - AsyncContext.Run(() => { tapGestureRecognizer.Command?.Execute(null); }); + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); } catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called { } - // // Assert - Assert.True(tapGestureRecognizer.Command?.CanExecute(null)); + // Assert Assert.True(hasOnTappingOutsideOfPopupExecuted); Assert.Equal(defaultPopupSettings.PageOverlayColor, popupPage.BackgroundColor); Assert.Equal(defaultPopupSettings.Shadow, popupBorder.Shadow); @@ -260,28 +277,21 @@ public void View_SetPopupDefaultsCalled_PopupOptionsOverridden_UsesProvidedPopup var popupPage = new PopupPage(new View(), defaultPopupSettings); var popupBorder = popupPage.Content.PopupBorder; - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); - // Act + // // Assert try { // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute - AsyncContext.Run(() => { tapGestureRecognizer.Command?.Execute(null); }); + AsyncContext.Run(() => Assert.True(popupPage.TryExecuteTapOutsideOfPopupCommand())); } catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called { } - // // Assert - Assert.True(tapGestureRecognizer.Command?.CanExecute(null)); Assert.True(hasOnTappingOutsideOfPopupExecuted); Assert.Equal(defaultPopupSettings.PageOverlayColor, popupPage.BackgroundColor); Assert.Equal(defaultPopupSettings.Shadow, popupBorder.Shadow); Assert.Equal(defaultPopupSettings.Shape, popupBorder.StrokeShape); } - - - static TapGestureRecognizer GetTapOutsideGestureRecognizer(PopupPage popupPage) => - (TapGestureRecognizer)popupPage.Content.GestureRecognizers.Single(); } #pragma warning restore CA1416 \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs index 5c1d8ba8f0..e808445700 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs @@ -446,13 +446,15 @@ public void TappingOutside_ShouldNotClosePopup_WhenCanBeDismissedIsFalse() }; var popupPage = new PopupPage(view, popupOptions); - // Act - var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); - var command = tapGestureRecognizer.Command; - - // Assert - Assert.NotNull(command); - Assert.False(command.CanExecute(null)); + // Act // Assert + try + { + // Run using AsyncContext to catch Exception thrown by fire-and-forget ICommand.Execute + AsyncContext.Run(() => Assert.False(popupPage.TryExecuteTapOutsideOfPopupCommand())); + } + catch (PopupNotFoundException) // PopupNotFoundException is expected here because `ShowPopup` was never called + { + } } [Fact] @@ -505,9 +507,6 @@ public void PopupPage_ShouldRespectLayoutOptions() Assert.Equal(LayoutOptions.End, border.HorizontalOptions); } - static TapGestureRecognizer GetTapOutsideGestureRecognizer(PopupPage popupPage) => - (TapGestureRecognizer)popupPage.Content.GestureRecognizers.Single(); - // Helper class for testing protected methods sealed class TestablePopupPage(View view, IPopupOptions popupOptions) : PopupPage(view, popupOptions) { From d7538d2a447eeb03d47600a07080af70bfada494 Mon Sep 17 00:00:00 2001 From: Brandon Minnick <13558917+TheCodeTraveler@users.noreply.github.com> Date: Fri, 11 Jul 2025 11:05:17 -0700 Subject: [PATCH 6/6] `dotnet format` --- .../Pages/Views/MediaElement/MediaElementPage.xaml.cs | 4 ++-- .../ViewModels/Views/Popup/ComplexPopupViewModel.cs | 2 +- .../Extensions/AppBuilderExtensionsTests.cs | 2 +- .../Views/Popup/PopupOptionsTests.cs | 2 +- src/CommunityToolkit.Maui/Options.cs | 4 ++-- .../Primitives/DefaultPopupOptionsSettings.cs | 10 +++++----- .../Views/Popup/PopupPage.shared.cs | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs index d070f8baa5..0163e3e02f 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -285,7 +285,7 @@ async void DisplayPopup(object sender, EventArgs e) await this.ShowPopupAsync(popupMediaElement); - popupMediaElement.Stop(); - popupMediaElement.Source = null; + popupMediaElement.Stop(); + popupMediaElement.Source = null; } } \ No newline at end of file diff --git a/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/Popup/ComplexPopupViewModel.cs b/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/Popup/ComplexPopupViewModel.cs index b09a32b578..2dc9a65315 100644 --- a/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/Popup/ComplexPopupViewModel.cs +++ b/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/Popup/ComplexPopupViewModel.cs @@ -7,7 +7,7 @@ public partial class ComplexPopupViewModel(IPopupService popupService) : Observa { readonly IPopupService popupService = popupService; readonly INavigation navigation = Application.Current?.Windows[0].Page?.Navigation ?? throw new InvalidOperationException("Unable to locate INavigation"); - + [ObservableProperty, NotifyCanExecuteChangedFor(nameof(ReturnButtonTappedCommand))] public partial string ReturnText { get; set; } = string.Empty; diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/AppBuilderExtensionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/AppBuilderExtensionsTests.cs index c3e9f61aea..27ef4de017 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/AppBuilderExtensionsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/AppBuilderExtensionsTests.cs @@ -110,7 +110,7 @@ public void UseMauiCommunityToolkit_ShouldAssignValues() Shadow = null, Shape = null }; - + Core.AppBuilderExtensions.ShouldUseStatusBarBehaviorOnAndroidModalPageOptionCompleted += HandleShouldUseStatusBarBehaviorOnAndroidModalPageOptionCompleted; // Act diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupOptionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupOptionsTests.cs index 51f61371bd..f1573c6c80 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupOptionsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupOptionsTests.cs @@ -24,7 +24,7 @@ public void CanBeDismissedByTappingOutsideOfPopup_SetValue_ShouldBeUpdated() public void Shadow_DefaultValue_ShouldBeTrue() { var popupOptions = new PopupOptions(); - + Assert.Equal(Colors.Black, popupOptions.Shadow?.Brush); Assert.Equal(new(20, 20), popupOptions.Shadow?.Offset); Assert.Equal(40, popupOptions.Shadow?.Radius); diff --git a/src/CommunityToolkit.Maui/Options.cs b/src/CommunityToolkit.Maui/Options.cs index aff8c1997a..2d8d319694 100644 --- a/src/CommunityToolkit.Maui/Options.cs +++ b/src/CommunityToolkit.Maui/Options.cs @@ -1,7 +1,7 @@ using CommunityToolkit.Maui.Behaviors; using CommunityToolkit.Maui.Converters; -using CommunityToolkit.Maui.Views; using CommunityToolkit.Maui.Extensions; +using CommunityToolkit.Maui.Views; #if WINDOWS using Microsoft.Maui.LifecycleEvents; #endif @@ -124,7 +124,7 @@ public void SetPopupDefaults(DefaultPopupSettings globalPopupSettings) { DefaultPopupSettings = globalPopupSettings; } - + /// /// Sets the default settings for /// diff --git a/src/CommunityToolkit.Maui/Primitives/DefaultPopupOptionsSettings.cs b/src/CommunityToolkit.Maui/Primitives/DefaultPopupOptionsSettings.cs index d2de58bc0d..7f40b3ae76 100644 --- a/src/CommunityToolkit.Maui/Primitives/DefaultPopupOptionsSettings.cs +++ b/src/CommunityToolkit.Maui/Primitives/DefaultPopupOptionsSettings.cs @@ -9,20 +9,20 @@ namespace CommunityToolkit.Maui; public record DefaultPopupOptionsSettings : IPopupOptions { /// - public bool CanBeDismissedByTappingOutsideOfPopup { get; init; }= PopupOptionsDefaults.CanBeDismissedByTappingOutsideOfPopup; + public bool CanBeDismissedByTappingOutsideOfPopup { get; init; } = PopupOptionsDefaults.CanBeDismissedByTappingOutsideOfPopup; /// - public Action? OnTappingOutsideOfPopup { get; init;} = PopupOptionsDefaults.OnTappingOutsideOfPopup; + public Action? OnTappingOutsideOfPopup { get; init; } = PopupOptionsDefaults.OnTappingOutsideOfPopup; /// - public Color PageOverlayColor { get;init; } = PopupOptionsDefaults.PageOverlayColor; + public Color PageOverlayColor { get; init; } = PopupOptionsDefaults.PageOverlayColor; /// public Shape? Shape { get; init; } = PopupOptionsDefaults.Shape; /// - public Shadow? Shadow { get; init; } = PopupOptionsDefaults.Shadow; - + public Shadow? Shadow { get; init; } = PopupOptionsDefaults.Shadow; + /// /// Default Values for /// diff --git a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs index 521afddd94..b4ad2e8e2d 100644 --- a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs +++ b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs @@ -88,7 +88,7 @@ public async Task CloseAsync(PopupResult result, CancellationToken token = defau } if (Navigation.ModalStack[^1] is Microsoft.Maui.Controls.Page currentVisibleModalPage - && currentVisibleModalPage != popupPageToClose) + && currentVisibleModalPage != popupPageToClose) { throw new PopupBlockedException(currentVisibleModalPage); } @@ -146,7 +146,7 @@ protected override void OnNavigatedTo(NavigatedToEventArgs args) return popup; } - + internal bool TryExecuteTapOutsideOfPopupCommand() { if (!tapOutsideOfPopupCommand.CanExecute(null))