diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentContainer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentContainer.cs index 42a7aa77c4ca..d483c1ad4fe1 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentContainer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentContainer.cs @@ -37,14 +37,5 @@ public override void OnDestroyView() ((IShellContentController)ShellContentTab).RecyclePage(_page); _page = null; } - - public override void OnDestroy() - { - _mauiContext - .GetDispatcher() - .Dispatch(Dispose); - - base.OnDestroy(); - } } } \ No newline at end of file diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs index 4ddb69f39500..ec7254f689cb 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs @@ -242,6 +242,19 @@ public override void ViewDidLoad() UpdateFlyoutAccessibility(); } + public override void ViewWillTransitionToSize(CGSize toSize, IUIViewControllerTransitionCoordinator coordinator) + { + base.ViewWillTransitionToSize(toSize, coordinator); + + coordinator.AnimateAlongsideTransition((IUIViewControllerTransitionCoordinatorContext obj) => + { + if (IsOpen && TapoffView != null) + { + TapoffView.Frame = View.Bounds; + } + }, null); + } + protected override void Dispose(bool disposing) { base.Dispose(disposing); diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs index 90c0c7fc0e24..ac0f55a9985e 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs @@ -805,6 +805,11 @@ public override void WillShowViewController(UINavigationController navigationCon navBarVisible = _self._renderer.ShowNavBar; else navBarVisible = Shell.GetNavBarIsVisible(element); + + // Update navigation bar visibility during the transition + // This ensures the correct nav bar state is applied as part of the navigation animation + bool animateVisibilityChange = animated && Shell.GetNavBarVisibilityAnimationEnabled(element); + navigationController.SetNavigationBarHidden(!navBarVisible, animateVisibilityChange); } var coordinator = viewController.GetTransitionCoordinator(); diff --git a/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs b/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs index 3ab381d1ed8e..c8e8ed19df3d 100644 --- a/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs +++ b/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs @@ -108,6 +108,12 @@ public static void MapEmptyViewTemplate(ItemsViewHandler2 handler, I public static void MapFlowDirection(ItemsViewHandler2 handler, ItemsView itemsView) { handler.Controller?.UpdateFlowDirection(); + + // UIKit does not automatically mirror or reflow UICollectionView layouts when the flow direction + // (semanticContentAttribute) changes at runtime. To ensure correct RTL/LTR behavior, we explicitly + // notify the controller to rebuild or reassign its layout. Without this, UICollectionViewCompositionalLayout + // and other layouts will keep their previous geometry and ignore the new direction. + handler.UpdateLayout(); } public static void MapIsVisible(ItemsViewHandler2 handler, ItemsView itemsView) diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs index 688bf7d0ae99..e042c118e9fa 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs @@ -312,6 +312,8 @@ public virtual void UpdateFlowDirection() } } } + + CollectionView.UpdateFlowDirection(ItemsView); } if (_emptyViewDisplayed) diff --git a/src/Controls/src/Core/Platform/AlertManager/AlertManager.Standard.cs b/src/Controls/src/Core/Platform/AlertManager/AlertManager.Standard.cs index f482979910a8..314f743d9203 100644 --- a/src/Controls/src/Core/Platform/AlertManager/AlertManager.Standard.cs +++ b/src/Controls/src/Core/Platform/AlertManager/AlertManager.Standard.cs @@ -7,7 +7,7 @@ internal partial class AlertManager { private partial IAlertManagerSubscription CreateSubscription(IMauiContext mauiContext) { - throw new NotImplementedException(); + return new AlertRequestHelper(); } internal partial class AlertRequestHelper diff --git a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 7dc5c58110bf..7d0110928035 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void diff --git a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 7dc5c58110bf..7d0110928035 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void diff --git a/src/Controls/src/Core/Shell/ShellSection.cs b/src/Controls/src/Core/Shell/ShellSection.cs index dcf7b73dc85a..c2098cdd2092 100644 --- a/src/Controls/src/Core/Shell/ShellSection.cs +++ b/src/Controls/src/Core/Shell/ShellSection.cs @@ -992,12 +992,22 @@ protected virtual void OnRemovePage(Page page) } internal bool IsVisibleSection => Parent?.Parent is Shell shell && shell.CurrentItem?.CurrentItem == this; + void PresentedPageDisappearing() { if (this is IShellSectionController sectionController) { CurrentItem?.SendDisappearing(); - sectionController.PresentedPage?.SendDisappearing(); + var presentedPage = sectionController.PresentedPage; + if (presentedPage is not null) + { + // Don't send disappearing to a modal page if we're switching ShellItems + // The modal belongs to the new ShellItem, not the old one being disappeared + if (IsVisibleSection) + { + presentedPage.SendDisappearing(); + } + } } } diff --git a/src/Controls/src/Core/TimePicker/TimePicker.cs b/src/Controls/src/Core/TimePicker/TimePicker.cs index 68a628596cd0..516c1d3f01de 100644 --- a/src/Controls/src/Core/TimePicker/TimePicker.cs +++ b/src/Controls/src/Core/TimePicker/TimePicker.cs @@ -222,7 +222,7 @@ void ITextElement.OnTextTransformChanged(TextTransform oldValue, TextTransform n static void TimePropertyChanged(BindableObject bindable, object oldValue, object newValue) { if (bindable is TimePicker timePicker) - timePicker.TimeSelected?.Invoke(timePicker, new TimeChangedEventArgs((TimeSpan)oldValue, (TimeSpan)newValue)); + timePicker.TimeSelected?.Invoke(timePicker, new TimeChangedEventArgs((TimeSpan?)oldValue, (TimeSpan?)newValue)); } private protected override string GetDebuggerDisplay() diff --git a/src/Controls/tests/Core.UnitTests/ShellModalAppearingTests.cs b/src/Controls/tests/Core.UnitTests/ShellModalAppearingTests.cs new file mode 100644 index 000000000000..466fc00591e5 --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/ShellModalAppearingTests.cs @@ -0,0 +1,164 @@ +using System.Threading.Tasks; +using Microsoft.Maui.Controls.Internals; +using Xunit; + +namespace Microsoft.Maui.Controls.Core.UnitTests +{ + public class ShellModalAppearingTests : ShellTestBase + { + [Fact] + public async Task ModalPageOnAppearingTriggeredOnceWithShellItemChangeToModal() + { + // Test the exact scenario from the issue: navigating from one ShellItem to another with a modal page + TestShell shell = new TestShell(); + + Routing.RegisterRoute("ModalPage", typeof(ModalPage31584)); + + var mainItem = CreateShellItem(shellItemRoute: "MainPage", shellSectionRoute: "MainSection", shellContentRoute: "MainContent"); + var homeItem = CreateShellItem(shellItemRoute: "Home", shellSectionRoute: "HomeSection", shellContentRoute: "HomeContent"); + + shell.Items.Add(mainItem); + shell.Items.Add(homeItem); + + // Start on MainPage + Assert.Equal(mainItem, shell.CurrentItem); + + // Navigate to //Home/ModalPage (changes ShellItem AND pushes modal) + await shell.GoToAsync("//Home/ModalPage"); + + // Verify we switched to Home ShellItem + Assert.Equal(homeItem, shell.CurrentItem); + + // Get the modal page from the Home ShellItem's section + var homeSection = homeItem.Items[0]; + var modalPage = (homeSection as IShellSectionController).PresentedPage as ModalPage31584; + + Assert.NotNull(modalPage); + + // The bug would cause OnAppearing to be called twice: + // 1. When the modal is pushed to the new ShellItem + // 2. When the old ShellItem's section tries to send disappearing to it + // The fix ensures OnAppearing is only called once + Assert.Equal(1, modalPage.AppearingCount); + } + + [Fact] + public async Task ModalPageDisappearingNotCalledByOldShellItemDuringSwitch() + { + // This test verifies the core fix: when switching ShellItems with a modal page, + // the old ShellItem's section should NOT call SendDisappearing on the modal page + // because the modal belongs to the new ShellItem, not the old one + TestShell shell = new TestShell(); + + Routing.RegisterRoute("ModalPage", typeof(ModalPage31584)); + + var mainItem = CreateShellItem(shellItemRoute: "MainPage", shellSectionRoute: "MainSection", shellContentRoute: "MainContent"); + var homeItem = CreateShellItem(shellItemRoute: "Home", shellSectionRoute: "HomeSection", shellContentRoute: "HomeContent"); + + shell.Items.Add(mainItem); + shell.Items.Add(homeItem); + + // Navigate to //Home/ModalPage + await shell.GoToAsync("//Home/ModalPage"); + + var homeSection = homeItem.Items[0]; + var modalPage = (homeSection as IShellSectionController).PresentedPage as ModalPage31584; + + Assert.NotNull(modalPage); + + // The modal should have appeared once + Assert.Equal(1, modalPage.AppearingCount); + + // And should not have disappeared (because it's still visible) + Assert.Equal(0, modalPage.DisappearingCount); + } + + [Fact] + public async Task ModalPageLifecycleCorrectWithNormalModalPush() + { + // Verify that normal modal push/pop still works correctly after the fix + TestShell shell = new TestShell(); + + Routing.RegisterRoute("ModalPage", typeof(ModalPage31584)); + + shell.Items.Add(CreateShellItem(shellItemRoute: "MainPage", shellSectionRoute: "MainSection", shellContentRoute: "MainContent")); + + // Normal modal navigation (no ShellItem change) + await shell.GoToAsync("ModalPage"); + + var mainSection = shell.Items[0].Items[0]; + var modalPage = (mainSection as IShellSectionController).PresentedPage as ModalPage31584; + + Assert.NotNull(modalPage); + Assert.Equal(1, modalPage.AppearingCount); + Assert.Equal(0, modalPage.DisappearingCount); + + // Pop the modal + await shell.GoToAsync(".."); + + // Modal should have disappeared once + Assert.Equal(1, modalPage.AppearingCount); + Assert.Equal(1, modalPage.DisappearingCount); + } + + [Fact] + public async Task RapidShellItemSwitchingWithModal() + { + // Test rapidly switching between ShellItems with modals to ensure lifecycle stays correct + TestShell shell = new TestShell(); + + Routing.RegisterRoute("ModalPage1", typeof(ModalPage31584)); + Routing.RegisterRoute("ModalPage2", typeof(ModalPage31584)); + + shell.Items.Add(CreateShellItem(shellItemRoute: "Item1", shellSectionRoute: "Section1", shellContentRoute: "Content1")); + shell.Items.Add(CreateShellItem(shellItemRoute: "Item2", shellSectionRoute: "Section2", shellContentRoute: "Content2")); + shell.Items.Add(CreateShellItem(shellItemRoute: "Item3", shellSectionRoute: "Section3", shellContentRoute: "Content3")); + + // Navigate to modal on Item2 + await shell.GoToAsync("//Item2/ModalPage1"); + var section2 = shell.Items[1].Items[0]; + var modal1 = (section2 as IShellSectionController).PresentedPage as ModalPage31584; + + Assert.NotNull(modal1); + Assert.Equal(1, modal1.AppearingCount); + + // Switch to Item3 with another modal + await shell.GoToAsync("//Item3/ModalPage2"); + var section3 = shell.Items[2].Items[0]; + var modal2 = (section3 as IShellSectionController).PresentedPage as ModalPage31584; + + Assert.NotNull(modal2); + // Second modal should have appeared exactly once (the bug would cause it to appear twice) + Assert.Equal(1, modal2.AppearingCount); + Assert.Equal(0, modal2.DisappearingCount); + } + + public class ModalPage31584 : ContentPage + { + public int AppearingCount { get; private set; } + public int DisappearingCount { get; private set; } + + public ModalPage31584() + { + Shell.SetPresentationMode(this, PresentationMode.Modal); + } + + protected override void OnAppearing() + { + base.OnAppearing(); + AppearingCount++; + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + DisappearingCount++; + } + } + + public ShellModalAppearingTests() + { + // Routes are already registered in the test methods as needed + } + } +} diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/NavigationBarShouldRemainHiddenAfterNavigatingBack.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/NavigationBarShouldRemainHiddenAfterNavigatingBack.png new file mode 100644 index 000000000000..05d65688be86 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/NavigationBarShouldRemainHiddenAfterNavigatingBack.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerticalGridCollectionViewLTRToRTLToggleShouldWork.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerticalGridCollectionViewLTRToRTLToggleShouldWork.png new file mode 100644 index 000000000000..3806ef0a7c2d Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerticalGridCollectionViewLTRToRTLToggleShouldWork.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerticalGridCollectionViewRTLColumnMirroringShouldWork.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerticalGridCollectionViewRTLColumnMirroringShouldWork.png new file mode 100644 index 000000000000..0f123badb407 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerticalGridCollectionViewRTLColumnMirroringShouldWork.png differ diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue15173.xaml.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue15173.xaml.cs new file mode 100644 index 000000000000..b32a478eeb45 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue15173.xaml.cs @@ -0,0 +1,105 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 15173, "Shell Flyout overlay does not resize on device rotation", PlatformAffected.iOS)] +public class Issue15173 : TestShell +{ + protected override void Init() + { + FlyoutBehavior = FlyoutBehavior.Flyout; + FlyoutBackgroundColor = Colors.White; + FlyoutBackdrop = new SolidColorBrush(Colors.Orange); + + // Create flyout content + var flyoutContent = new VerticalStackLayout + { + Padding = new Thickness(20), + Spacing = 10, + BackgroundColor = Colors.LightGray, + Children = + { + new Label + { + Text = "Flyout Menu", + FontSize = 24, + FontAttributes = FontAttributes.Bold, + Margin = new Thickness(0, 20, 0, 10) + }, + new Button + { + Text = "Menu Item 1", + BackgroundColor = Colors.White, + TextColor = Colors.Black, + AutomationId = "MenuItem1" + }, + new Button + { + Text = "Menu Item 2", + BackgroundColor = Colors.White, + TextColor = Colors.Black, + AutomationId = "MenuItem2" + }, + new Button + { + Text = "Menu Item 3", + BackgroundColor = Colors.White, + TextColor = Colors.Black, + AutomationId = "MenuItem3" + } + } + }; + + // Set the flyout content using the property + FlyoutContentTemplate = new DataTemplate(() => flyoutContent); + + // Create main content page + var mainPage = new ContentPage + { + BackgroundColor = Colors.White, + Content = new VerticalStackLayout + { + Padding = new Thickness(30), + Spacing = 20, + Children = + { + new Label + { + Text = "Shell Flyout Rotation Test", + FontSize = 24, + FontAttributes = FontAttributes.Bold, + HorizontalOptions = LayoutOptions.Center, + TextColor = Colors.Black, + AutomationId = "PageTitle" + }, + new Label + { + Text = "Instructions:", + FontSize = 18, + FontAttributes = FontAttributes.Bold, + TextColor = Colors.Black, + Margin = new Thickness(0, 20, 0, 0) + }, + new Label + { + Text = "1. Open the flyout (hamburger menu)", + FontSize = 16, + TextColor = Colors.Black + }, + new Label + { + Text = "2. Rotate the device from portrait to landscape", + FontSize = 16, + TextColor = Colors.Black + }, + new Label + { + Text = "3. The overlay should cover the entire landscape screen", + FontSize = 16, + TextColor = Colors.Black + } + } + } + }; + + AddContentPage(mainPage, "Home"); + } +} diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue32359.xaml b/src/Controls/tests/TestCases.HostApp/Issues/Issue32359.xaml new file mode 100644 index 000000000000..c10e69eeb3f2 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue32359.xaml @@ -0,0 +1,51 @@ + + + + +