diff --git a/src/CommunityToolkit.Maui.UnitTests/CommunityToolkit.Maui.UnitTests.csproj b/src/CommunityToolkit.Maui.UnitTests/CommunityToolkit.Maui.UnitTests.csproj index a892d1d124..27187601bf 100644 --- a/src/CommunityToolkit.Maui.UnitTests/CommunityToolkit.Maui.UnitTests.csproj +++ b/src/CommunityToolkit.Maui.UnitTests/CommunityToolkit.Maui.UnitTests.csproj @@ -21,6 +21,7 @@ + diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/PopupExtensionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/PopupExtensionsTests.cs index 45f0907a86..cef2f4423c 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/PopupExtensionsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/PopupExtensionsTests.cs @@ -192,7 +192,7 @@ public void ShowPopupAsync_WithViewType_SetsCorrectDefaults() navigation.ShowPopup(label); popupPage = (PopupPage)navigation.ModalStack[0]; - autogeneratedPopup = (Popup)(((Border)popupPage.Content.Children[0]).Content ?? throw new InvalidOperationException("Border Content cannot be null")); + autogeneratedPopup = (Popup)(popupPage.Content.PopupBorder.Content ?? throw new InvalidOperationException("Border Content cannot be null")); // Assert Assert.Equal(PopupDefaults.BackgroundColor, autogeneratedPopup.BackgroundColor); @@ -435,7 +435,7 @@ public void ShowPopupAsync_WithCustomOptions_AppliesOptions() var popupPage = (PopupPage)navigation.ModalStack[0]; var popupPageContent = popupPage.Content; - var border = (Border)popupPageContent.Children[0]; + var border = popupPageContent.PopupBorder; var popup = border.Content; // Assert @@ -507,7 +507,7 @@ public void ShowPopupAsync_Shell_WithCustomOptions_AppliesOptions() var popupPage = (PopupPage)shellNavigation.ModalStack[0]; var popupPageContent = popupPage.Content; - var border = (Border)popupPageContent.Children[0]; + var border = popupPageContent.PopupBorder; var popup = border.Content; // Assert @@ -579,7 +579,7 @@ public void ShowPopupAsyncWithView_WithCustomOptions_AppliesOptions() var popupPage = (PopupPage)navigation.ModalStack[0]; var popupPageContent = popupPage.Content; - var border = (Border)popupPageContent.Children[0]; + var border = popupPageContent.PopupBorder; var popup = (Popup)(border.Content ?? throw new InvalidCastException()); // Assert @@ -660,7 +660,7 @@ public void ShowPopupAsyncWithView_Shell_WithCustomOptions_AppliesOptions() var popupPage = (PopupPage)shellNavigation.ModalStack[0]; var popupPageContent = popupPage.Content; - var border = (Border)popupPageContent.Children[0]; + var border = popupPageContent.PopupBorder; var popup = (Popup)(border.Content ?? throw new InvalidCastException()); // Assert @@ -1149,7 +1149,7 @@ public async Task ShowPopupAsync_ReferenceTypeShouldReturnNull_WhenPopupTapGestu var popupPage = (PopupPage)navigation.ModalStack[0]; popupPage.PopupClosed += HandlePopupClosed; - var tapGestureRecognizer = (TapGestureRecognizer)popupPage.Content.GestureRecognizers[0]; + var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); tapGestureRecognizer.Command?.Execute(null); var popupClosedResult = await popupClosedTCS.Task; @@ -1184,7 +1184,7 @@ public async Task ShowPopupAsync_Shell_ReferenceTypeShouldReturnNull_WhenPopupTa var popupPage = (PopupPage)shellNavigation.ModalStack[0]; popupPage.PopupClosed += HandlePopupClosed; - var tapGestureRecognizer = (TapGestureRecognizer)popupPage.Content.GestureRecognizers[0]; + var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); tapGestureRecognizer.Command?.Execute(null); var popupClosedResult = await popupClosedTCS.Task; @@ -1212,7 +1212,7 @@ public async Task ShowPopupAsync_NullableValueTypeShouldReturnResult_WhenPopupIs var popupPage = (PopupPage)navigation.ModalStack[0]; popupPage.PopupClosed += HandlePopupClosed; - var tapGestureRecognizer = (TapGestureRecognizer)popupPage.Content.GestureRecognizers[0]; + var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); tapGestureRecognizer.Command?.Execute(null); var popupClosedResult = await popupClosedTCS.Task; @@ -1247,7 +1247,7 @@ public async Task ShowPopupAsync_Shell_NullableValueTypeShouldReturnResult_WhenP var popupPage = (PopupPage)shellNavigation.ModalStack[0]; popupPage.PopupClosed += HandlePopupClosed; - var tapGestureRecognizer = (TapGestureRecognizer)popupPage.Content.GestureRecognizers[0]; + var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); tapGestureRecognizer.Command?.Execute(null); var popupClosedResult = await popupClosedTCS.Task; @@ -1275,7 +1275,7 @@ public async Task ShowPopupAsync_ValueTypeShouldReturnResult_WhenPopupIsClosedBy var popupPage = (PopupPage)navigation.ModalStack[0]; popupPage.PopupClosed += HandlePopupClosed; - var tapGestureRecognizer = (TapGestureRecognizer)popupPage.Content.GestureRecognizers[0]; + var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); tapGestureRecognizer.Command?.Execute(null); var popupClosedResult = await popupClosedTCS.Task; @@ -1311,7 +1311,7 @@ public async Task ShowPopupAsync_Shell_ValueTypeShouldReturnResult_WhenPopupIsCl var popupPage = (PopupPage)shellNavigation.ModalStack[0]; popupPage.PopupClosed += HandlePopupClosed; - var tapGestureRecognizer = (TapGestureRecognizer)popupPage.Content.GestureRecognizers[0]; + var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); tapGestureRecognizer.Command?.Execute(null); var popupClosedResult = await popupClosedTCS.Task; @@ -1459,6 +1459,9 @@ 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/Services/PopupServiceTests.cs b/src/CommunityToolkit.Maui.UnitTests/Services/PopupServiceTests.cs index e4abe072d5..41f929d100 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Services/PopupServiceTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Services/PopupServiceTests.cs @@ -166,7 +166,7 @@ public void ShowPopupAsync_WithCustomOptions_AppliesOptions() var popupPage = (PopupPage)navigation.ModalStack[0]; var popupPageLayout = popupPage.Content; - var border = (Border)popupPageLayout.Children[0]; + var border = popupPageLayout.PopupBorder; var popup = border.Content; // Assert diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs index 40c537be4f..1efd44b9f3 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs @@ -250,13 +250,22 @@ public void Constructor_WithViewAndPopupOptions_SetsCorrectProperties() Assert.Equal(UIModalPresentationStyle.OverFullScreen, popupPage.On().ModalPresentationStyle()); // Verify content has tap gesture recognizer attached - Assert.Single(popupPage.Content.GestureRecognizers); - Assert.IsType(popupPage.Content.GestureRecognizers[0]); + var gestureRecognizers = popupPage.Content.Children.OfType().Single().GestureRecognizers; + Assert.Single(gestureRecognizers); + Assert.IsType(gestureRecognizers[0]); // Verify PopupPageLayout structure var pageContent = popupPage.Content; - Assert.Single(pageContent.Children); - Assert.IsType(pageContent.Children[0]); + Assert.Collection( + pageContent.Children, + first => + { + first.Should().BeOfType(); + }, + second => + { + second.Should().BeOfType(); + }); // Verify content binding context is set correctly Assert.Equal(view.BindingContext, pageContent.BindingContext); @@ -316,7 +325,7 @@ public async Task TapGestureRecognizer_ShouldClosePopupWhenCanBeDismissedIsTrue( var popupPage = new PopupPage(view, popupOptions); - var tapGestureRecognizer = (TapGestureRecognizer)popupPage.Content.GestureRecognizers[0]; + var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); var command = tapGestureRecognizer.Command; Assert.NotNull(command); @@ -341,7 +350,7 @@ public void TapGestureRecognizer_ShouldNotExecuteWhenCanBeDismissedIsFalse() }; var popupPage = new PopupPage(view, popupOptions); - var tapGestureRecognizer = (TapGestureRecognizer)popupPage.Content.GestureRecognizers[0]; + var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); var command = tapGestureRecognizer.Command; // Act & Assert @@ -421,7 +430,7 @@ public void TappingOutside_ShouldNotClosePopup_WhenCanBeDismissedIsFalse() var popupPage = new PopupPage(view, popupOptions); // Act - var tapGestureRecognizer = (TapGestureRecognizer)popupPage.Content.GestureRecognizers[0]; + var tapGestureRecognizer = GetTapOutsideGestureRecognizer(popupPage); var command = tapGestureRecognizer.Command; // Assert @@ -472,12 +481,15 @@ public void PopupPage_ShouldRespectLayoutOptions() // Act var popupPage = new PopupPage(view, PopupOptions.Empty); - var border = (Border)popupPage.Content.Children[0]; + var border = popupPage.Content.PopupBorder; // Assert Assert.Equal(LayoutOptions.Start, border.VerticalOptions); 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/Views/Popup/PopupPage.shared.cs b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs index 8cd851161c..13cacf49b0 100644 --- a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs +++ b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Globalization; +using System.Windows.Input; using CommunityToolkit.Maui.Converters; using CommunityToolkit.Maui.Core; using CommunityToolkit.Maui.Extensions; @@ -41,16 +42,14 @@ public PopupPage(Popup popup, IPopupOptions popupOptions) this.popup = popup; this.popupOptions = 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 = new Command(async () => { popupOptions.OnTappingOutsideOfPopup?.Invoke(); await CloseAsync(new PopupResult(true)); }, () => popupOptions.CanBeDismissedByTappingOutsideOfPopup); - Content.GestureRecognizers.Add(new TapGestureRecognizer { Command = tapOutsideOfPopupCommand }); + // 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); if (popupOptions is BindableObject bindablePopupOptions) { @@ -104,7 +103,7 @@ public async Task CloseAsync(PopupResult result, CancellationToken token = defau popupClosedEventManager.HandleEvent(this, result, nameof(PopupClosed)); } - + protected override bool OnBackButtonPressed() { // Only close the Popup if PopupOptions.CanBeDismissedByTappingOutsideOfPopup is true @@ -112,7 +111,7 @@ protected override bool OnBackButtonPressed() { CloseAsync(new PopupResult(true), CancellationToken.None).SafeFireAndForget(); } - + // 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; } @@ -176,33 +175,44 @@ void IQueryAttributable.ApplyQueryAttributes(IDictionary query) internal sealed partial class PopupPageLayout : Grid { - public PopupPageLayout(in Popup popupContent, in IPopupOptions options) + public PopupPageLayout(in Popup popupContent, in IPopupOptions options, in ICommand tapOutsideOfPopupCommand) { Background = BackgroundColor = null; - var border = new Border + 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 ??= PopupDefaults.BackgroundColor, Content = popupContent }; // Bind `Popup` values through to Border using OneWay Bindings - border.SetBinding(Border.MarginProperty, static (Popup popup) => popup.Margin, source: popupContent, mode: BindingMode.OneWay); - border.SetBinding(Border.PaddingProperty, static (Popup popup) => popup.Padding, source: popupContent, mode: BindingMode.OneWay); - border.SetBinding(Border.BackgroundProperty, static (Popup popup) => popup.Background, source: popupContent, mode: BindingMode.OneWay); - border.SetBinding(Border.BackgroundColorProperty, static (Popup popup) => popup.BackgroundColor, source: popupContent, mode: BindingMode.OneWay); - border.SetBinding(Border.VerticalOptionsProperty, static (Popup popup) => popup.VerticalOptions, source: popupContent, mode: BindingMode.OneWay); - border.SetBinding(Border.HorizontalOptionsProperty, static (Popup popup) => popup.HorizontalOptions, source: popupContent, mode: BindingMode.OneWay); + PopupBorder.SetBinding(Border.MarginProperty, static (Popup popup) => popup.Margin, source: popupContent, mode: BindingMode.OneWay); + PopupBorder.SetBinding(Border.PaddingProperty, static (Popup popup) => popup.Padding, source: popupContent, mode: BindingMode.OneWay); + PopupBorder.SetBinding(Border.BackgroundProperty, static (Popup popup) => popup.Background, source: popupContent, mode: BindingMode.OneWay); + PopupBorder.SetBinding(Border.BackgroundColorProperty, static (Popup popup) => popup.BackgroundColor, source: popupContent, mode: BindingMode.OneWay); + PopupBorder.SetBinding(Border.VerticalOptionsProperty, static (Popup popup) => popup.VerticalOptions, source: popupContent, mode: BindingMode.OneWay); + PopupBorder.SetBinding(Border.HorizontalOptionsProperty, static (Popup popup) => popup.HorizontalOptions, source: popupContent, mode: BindingMode.OneWay); // Bind `PopupOptions` values through to Border using OneWay Bindings - border.SetBinding(Border.ShadowProperty, static (IPopupOptions options) => options.Shadow, source: options, mode: BindingMode.OneWay); - border.SetBinding(Border.StrokeProperty, static (IPopupOptions options) => options.Shape, source: options, converter: new BorderStrokeConverter(), mode: BindingMode.OneWay); - border.SetBinding(Border.StrokeShapeProperty, static (IPopupOptions options) => options.Shape, source: options, mode: BindingMode.OneWay); - border.SetBinding(Border.StrokeThicknessProperty, static (IPopupOptions options) => options.Shape, source: options, converter: new BorderStrokeThicknessConverter(), mode: BindingMode.OneWay); + PopupBorder.SetBinding(Border.ShadowProperty, static (IPopupOptions options) => options.Shadow, source: options, mode: BindingMode.OneWay); + PopupBorder.SetBinding(Border.StrokeProperty, static (IPopupOptions options) => options.Shape, source: options, converter: new BorderStrokeConverter(), mode: BindingMode.OneWay); + PopupBorder.SetBinding(Border.StrokeShapeProperty, static (IPopupOptions options) => options.Shape, source: options, mode: BindingMode.OneWay); + PopupBorder.SetBinding(Border.StrokeThicknessProperty, static (IPopupOptions options) => options.Shape, source: options, converter: new BorderStrokeThicknessConverter(), mode: BindingMode.OneWay); - Children.Add(border); + Children.Add(PopupBorder); } + public Border PopupBorder { get; } + sealed partial class BorderStrokeThicknessConverter : BaseConverterOneWay { public override double DefaultConvertReturnValue { get; set; } = PopupOptionsDefaults.BorderStrokeThickness;