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 d070f8baa..0163e3e02 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 b09a32b57..2dc9a6531 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 c3e9f61ae..27ef4de01 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/Extensions/PopupExtensionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/PopupExtensionsTests.cs index 31c419942..3e39b5245 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 4d8b8f24e..ac6cfc284 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.Children.OfType().Single().GestureRecognizers[0]; } #pragma warning restore CA1416 \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupOptionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupOptionsTests.cs index 51f61371b..f1573c6c8 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.UnitTests/Views/Popup/PopupPageTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs index d267eceeb..e80844570 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] @@ -429,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] @@ -488,9 +507,6 @@ public void PopupPage_ShouldRespectLayoutOptions() Assert.Equal(LayoutOptions.End, border.HorizontalOptions); } - static TapGestureRecognizer GetTapOutsideGestureRecognizer(PopupPage popupPage) => - (TapGestureRecognizer)popupPage.Content.Children.OfType().Single().GestureRecognizers[0]; - // Helper class for testing protected methods sealed class TestablePopupPage(View view, IPopupOptions popupOptions) : PopupPage(view, popupOptions) { diff --git a/src/CommunityToolkit.Maui/Options.cs b/src/CommunityToolkit.Maui/Options.cs index aff8c1997..2d8d31969 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 d2de58bc0..7f40b3ae7 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 538e9d53b..b4ad2e8e2 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) @@ -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; @@ -147,6 +147,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 static bool GetCanBeDismissedByTappingOutsideOfPopup(in Popup popup, in IPopupOptions popupOptions) => popup.CanBeDismissedByTappingOutsideOfPopup & popupOptions.CanBeDismissedByTappingOutsideOfPopup; @@ -180,21 +191,31 @@ void IQueryAttributable.ApplyQueryAttributes(IDictionary query) } } + void HandleTapGestureRecognizerTapped(object? sender, TappedEventArgs e) + { + ArgumentNullException.ThrowIfNull(sender); + + var popupPageLayout = (PopupPageLayout)sender; + var position = e.GetPosition(Content); + + if (position is null) + { + return; + } + + // Execute tapOutsideOfPopupCommand only if tap occurred outside the PopupBorder + if (popupPageLayout.PopupBorder.Bounds.Contains(position.Value) is false) + { + TryExecuteTapOutsideOfPopupCommand(); + } + } + internal sealed partial class PopupPageLayout : Grid { - public PopupPageLayout(in Popup popupContent, in IPopupOptions options, in ICommand tapOutsideOfPopupCommand) + public PopupPageLayout(in Popup popupContent, in IPopupOptions options) { Background = BackgroundColor = null; - var tappableBackground = new BoxView - { - BackgroundColor = Colors.Transparent, - 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 - PopupBorder = new Border { BackgroundColor = popupContent.BackgroundColor ??= Options.DefaultPopupSettings.BackgroundColor, @@ -232,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; @@ -246,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; @@ -261,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;