Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,5 @@ public override void OnDestroyView()
((IShellContentController)ShellContentTab).RecyclePage(_page);
_page = null;
}

public override void OnDestroy()
{
_mauiContext
.GetDispatcher()
.Dispatch(Dispose);

base.OnDestroy();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ public static void MapEmptyViewTemplate(ItemsViewHandler2<TItemsView> handler, I
public static void MapFlowDirection(ItemsViewHandler2<TItemsView> 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<TItemsView> handler, ItemsView itemsView)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ public virtual void UpdateFlowDirection()
}
}
}

CollectionView.UpdateFlowDirection(ItemsView);
}

if (_emptyViewDisplayed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ internal partial class AlertManager
{
private partial IAlertManagerSubscription CreateSubscription(IMauiContext mauiContext)
{
throw new NotImplementedException();
return new AlertRequestHelper();
}

internal partial class AlertRequestHelper
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void
12 changes: 11 additions & 1 deletion src/Controls/src/Core/Shell/ShellSection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Controls/src/Core/TimePicker/TimePicker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
164 changes: 164 additions & 0 deletions src/Controls/tests/Core.UnitTests/ShellModalAppearingTests.cs
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
105 changes: 105 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue15173.xaml.cs
Original file line number Diff line number Diff line change
@@ -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);
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FlyoutContentTemplate property expects a DataTemplate that returns a View, but this code returns the same flyoutContent instance each time the template is invoked. This creates a shared instance issue where the same view instance could be reused, potentially causing unexpected behavior.

Consider using a lambda that creates a new instance: FlyoutContentTemplate = new DataTemplate(() => new VerticalStackLayout { ... });

Copilot uses AI. Check for mistakes.

// 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");
}
}
Loading
Loading