diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index aaa22df4882e..08517ca18b37 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -181,10 +181,17 @@ git commit -m "Fix: Description of the change"
2. Exception: If the user's instructions explicitly include pushing, proceed without asking.
### Documentation
+
- Update XML documentation for public APIs
- Follow existing code documentation patterns
- Update relevant docs in `docs/` folder when needed
+**Platform-Specific Documentation:**
+- `.github/instructions/safe-area-ios.instructions.md` - Safe area investigation (iOS/macCatalyst)
+- `.github/instructions/uitests.instructions.md` - UI test guidelines (includes safe area testing section)
+- `.github/instructions/android.instructions.md` - Android handler implementation
+- `.github/instructions/xaml-unittests.instructions.md` - XAML unit test guidelines
+
### Opening PRs
All PRs are required to have this at the top of the description:
diff --git a/.github/instructions/safe-area-ios.instructions.md b/.github/instructions/safe-area-ios.instructions.md
new file mode 100644
index 000000000000..0ee81f2c462f
--- /dev/null
+++ b/.github/instructions/safe-area-ios.instructions.md
@@ -0,0 +1,34 @@
+---
+applyTo:
+ - "**/Platform/iOS/MauiView.cs"
+ - "**/Platform/iOS/MauiScrollView.cs"
+ - "**/Platform/iOS/*SafeArea*"
+---
+
+# Safe Area Guidelines (iOS/macCatalyst)
+
+## Platform Differences
+
+| | macOS 14/15 | macOS 26+ |
+|-|-------------|-----------|
+| Title bar inset | ~28px | ~0px |
+| Used in CI | ✅ | ❌ |
+
+Local macOS 26+ testing does NOT validate CI behavior. Fixes must pass CI on macOS 14/15.
+
+| Platform | `UseSafeArea` default |
+|----------|-----------------------|
+| iOS | `false` |
+| macCatalyst | `true` |
+
+## Architecture (PR #34024)
+
+**`IsParentHandlingSafeArea`** — before applying adjustments, `MauiView`/`MauiScrollView` walk ancestors to check if any ancestor handles the **same edges**. If so, descendant skips (avoids double-padding). Edge-aware: parent handling `Top` does not block child handling `Bottom`. Result cached in `bool? _parentHandlesSafeArea`; cleared on `SafeAreaInsetsDidChange`, `InvalidateSafeArea`, `MovedToWindow`. `AppliesSafeAreaAdjustments` is `internal` for cross-type ancestor checks.
+
+**`EqualsAtPixelLevel`** — safe area compared at device-pixel resolution to absorb sub-pixel animation noise (`0.0000001pt` during `TranslateToAsync`), preventing oscillation loops (#32586, #33934).
+
+## Anti-Patterns
+
+**❌ Window Guard** — comparing `Window.SafeAreaInsets` to filter callbacks blocks legitimate updates. On macCatalyst + custom TitleBar, `WindowViewController` pushes content down, changing the **view's** `SafeAreaInsets` without changing the **window's**. Caused 28px CI shift (macOS 14/15 only). Never gate per-view callbacks on window-level insets.
+
+**❌ Semantic mismatch** — `_safeArea` is filtered by `GetSafeAreaForEdge` (zeroes edges per `SafeAreaRegions`); raw `UIView.SafeAreaInsets` includes all edges. Never compare them — compare raw-to-raw or adjusted-to-adjusted.
diff --git a/.github/instructions/uitests.instructions.md b/.github/instructions/uitests.instructions.md
index b8eb01bab8f3..3b0e5c7747d7 100644
--- a/.github/instructions/uitests.instructions.md
+++ b/.github/instructions/uitests.instructions.md
@@ -731,3 +731,45 @@ grep -r "UITestEntry\|UITestEditor\|UITestSearchBar" src/Controls/tests/TestCase
- Common helper methods
- Platform-specific workarounds
- UITest optimized control usage
+
+### Safe Area Testing (iOS/MacCatalyst)
+
+**⚠️ CRITICAL for macCatalyst safe area tests:**
+
+Safe area behavior differs significantly between macOS versions. Tests must account for this variability.
+
+| macOS Version | Title Bar Safe Area | CI Environment |
+|---------------|---------------------|----------------|
+| **macOS 14/15** | ~28px top inset | ✅ Used by CI |
+| **macOS 26 (Liquid Glass)** | ~0px top inset | ❌ Local dev only |
+
+**Rules for safe area tests:**
+
+1. **Use tolerances for safe area measurements** - Exact pixel values vary by macOS version
+2. **Test behavior, not exact values** - Verify content is NOT obscured, rather than checking exact padding pixels
+3. **Use `GetRect()` for child content position** - Measure where content actually appears, not parent size
+4. **Never hardcode safe area expectations** - Tests should pass on macOS 14/15 AND macOS 26
+
+**Example patterns:**
+
+```csharp
+// ❌ BAD: Hardcoded safe area value (breaks across macOS versions)
+var safeArea = element.GetRect();
+Assert.That(safeArea.Y, Is.EqualTo(28)); // Fails on macOS 26
+
+// ✅ GOOD: Test that content is not obscured by title bar
+var contentRect = App.WaitForElement("MyContent").GetRect();
+var titleBarRect = App.WaitForElement("TitleBar").GetRect();
+Assert.That(contentRect.Y, Is.GreaterThanOrEqualTo(titleBarRect.Height),
+ "Content should not be obscured by title bar");
+
+// ✅ GOOD: Use tolerance for safe area (accounts for OS differences)
+Assert.That(contentRect.Y, Is.GreaterThan(0).And.LessThan(50),
+ "Content should have some top padding but not excessive");
+```
+
+**Test category**: Use `UITestCategories.SafeAreaEdges` for safe area tests.
+
+**Platform scope**: Safe area tests should typically run on iOS and MacCatalyst (not just one).
+
+**See also**: `.github/instructions/safe-area-debugging.instructions.md` for investigation guidelines
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue28986_ParentChildTest.xaml b/src/Controls/tests/TestCases.HostApp/Issues/Issue28986_ParentChildTest.xaml
new file mode 100644
index 000000000000..f3bfdc2b3a4e
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue28986_ParentChildTest.xaml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue28986_ParentChildTest.xaml.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue28986_ParentChildTest.xaml.cs
new file mode 100644
index 000000000000..bbebeab6f7a1
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue28986_ParentChildTest.xaml.cs
@@ -0,0 +1,59 @@
+namespace Maui.Controls.Sample.Issues;
+
+[Issue(IssueTracker.Github, 28986, "SafeAreaEdges independent handling for parent and child controls", PlatformAffected.iOS, issueTestNumber: 9)]
+public partial class Issue28986_ParentChildTest : ContentPage
+{
+ bool _parentTopEnabled = true;
+ bool _parentBottomEnabled = false;
+ bool _childBottomEnabled = true;
+
+ public Issue28986_ParentChildTest()
+ {
+ InitializeComponent();
+ UpdateParentGridSafeAreaEdges();
+ UpdateStatusLabel();
+ }
+
+ void OnToggleParentTop(object sender, EventArgs e)
+ {
+ _parentTopEnabled = !_parentTopEnabled;
+ UpdateParentGridSafeAreaEdges();
+ UpdateStatusLabel();
+ }
+
+ void OnToggleParentBottom(object sender, EventArgs e)
+ {
+ _parentBottomEnabled = !_parentBottomEnabled;
+ UpdateParentGridSafeAreaEdges();
+ UpdateStatusLabel();
+ }
+
+ void OnToggleChildBottom(object sender, EventArgs e)
+ {
+ _childBottomEnabled = !_childBottomEnabled;
+
+ // Toggle between Bottom=Container and Bottom=None
+ ChildGrid.SafeAreaEdges = _childBottomEnabled
+ ? new SafeAreaEdges(SafeAreaRegions.None, SafeAreaRegions.None, SafeAreaRegions.None, SafeAreaRegions.Container)
+ : new SafeAreaEdges(SafeAreaRegions.None);
+
+ UpdateStatusLabel();
+ }
+
+ void UpdateParentGridSafeAreaEdges()
+ {
+ // Build parent grid SafeAreaEdges based on top and bottom flags
+ SafeAreaRegions top = _parentTopEnabled ? SafeAreaRegions.Container : SafeAreaRegions.None;
+ SafeAreaRegions bottom = _parentBottomEnabled ? SafeAreaRegions.Container : SafeAreaRegions.None;
+
+ ParentGrid.SafeAreaEdges = new SafeAreaEdges(SafeAreaRegions.None, top, SafeAreaRegions.None, bottom);
+ }
+
+ void UpdateStatusLabel()
+ {
+ var parentTop = _parentTopEnabled ? "Container" : "None";
+ var parentBottom = _parentBottomEnabled ? "Container" : "None";
+ var childBottom = _childBottomEnabled ? "Container" : "None";
+ StatusLabel.Text = $"Parent: Top={parentTop}, Bottom={parentBottom} | Child: Bottom={childBottom}";
+ }
+}
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue32586.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue32586.cs
new file mode 100644
index 000000000000..f80dda548b78
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue32586.cs
@@ -0,0 +1,250 @@
+namespace Maui.Controls.Sample.Issues;
+
+[Issue(IssueTracker.Github, 32586, "[iOS] Layout issue using TranslateToAsync causes infinite property changed cycle", PlatformAffected.Android | PlatformAffected.iOS)]
+public class Issue32586 : ContentPage
+{
+ const uint AnimationDuration = 250;
+ Button FooterButton;
+ Button ParentSafeAreaToggleButton;
+ Button ChildSafeAreaToggleButton;
+ Label TestLabel;
+ Label SafeAreaStatusLabel;
+ ContentView FooterView;
+ Grid MainGrid;
+ VerticalStackLayout verticalStackLayout;
+
+ public Issue32586()
+ {
+ // Create the main grid
+ MainGrid = new Grid
+ {
+ BackgroundColor = Colors.Orange,
+ AutomationId = "MainGrid",
+ RowDefinitions =
+ {
+ new RowDefinition(GridLength.Star),
+ new RowDefinition(GridLength.Auto)
+ }
+ };
+
+ // Create the main content layout
+ verticalStackLayout = new VerticalStackLayout
+ {
+ Padding = new Thickness(30, 10, 30, 0),
+ Spacing = 25
+ };
+
+ // Top marker label - its Y position indicates whether safe area is applied
+ var topMarker = new Label
+ {
+ Text = "Top Marker",
+ FontSize = 12,
+ HorizontalOptions = LayoutOptions.Center,
+ AutomationId = "TopMarker"
+ };
+
+ // Create FooterButton
+ FooterButton = new Button
+ {
+ Text = "Show Footer",
+ HorizontalOptions = LayoutOptions.Center,
+ AutomationId = "FooterButton"
+ };
+ FooterButton.Clicked += OnFooterButtonClicked;
+
+ // Create info label
+ var infoLabel = new Label
+ {
+ Text = "Click to verify UI responsiveness",
+ HorizontalOptions = LayoutOptions.Center,
+ AutomationId = "InfoLabel"
+ };
+
+ // Create TestLabel
+ TestLabel = new Label
+ {
+ Text = "Footer is not visible",
+ HorizontalOptions = LayoutOptions.Center,
+ AutomationId = "TestLabel"
+ };
+
+ // Create SafeAreaEdges toggle button for parent Grid
+ ParentSafeAreaToggleButton = new Button
+ {
+ Text = "Toggle Parent SafeArea",
+ HorizontalOptions = LayoutOptions.Center,
+ AutomationId = "ParentSafeAreaToggleButton"
+ };
+ ParentSafeAreaToggleButton.Clicked += OnParentSafeAreaToggleClicked;
+
+ // Create SafeAreaEdges toggle button for child verticalStackLayout
+ ChildSafeAreaToggleButton = new Button
+ {
+ Text = "Toggle Child SafeArea",
+ HorizontalOptions = LayoutOptions.Center,
+ AutomationId = "ChildSafeAreaToggleButton"
+ };
+ ChildSafeAreaToggleButton.Clicked += OnChildSafeAreaToggleClicked;
+
+ // Create SafeAreaEdges status label
+ SafeAreaStatusLabel = new Label
+ {
+ Text = "Parent: Container, Child: Container",
+ HorizontalOptions = LayoutOptions.Center,
+ AutomationId = "SafeAreaStatusLabel"
+ };
+
+ // Add elements to stack layout
+ verticalStackLayout.Add(topMarker);
+ verticalStackLayout.Add(FooterButton);
+ verticalStackLayout.Add(infoLabel);
+ verticalStackLayout.Add(TestLabel);
+ verticalStackLayout.Add(ParentSafeAreaToggleButton);
+ verticalStackLayout.Add(ChildSafeAreaToggleButton);
+ verticalStackLayout.Add(SafeAreaStatusLabel);
+
+ // Bottom marker - positioned at very bottom of grid, used to detect safe area
+ var bottomMarker = new Label
+ {
+ Text = "Bottom Marker",
+ FontSize = 12,
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.End,
+ AutomationId = "BottomMarker"
+ };
+
+ // Create the footer view
+ FooterView = new ContentView
+ {
+ IsVisible = false,
+ AutomationId = "FooterView"
+ };
+
+ // Create the footer grid content
+ var footerGrid = new Grid();
+
+ // Create the gradient background
+ var gradientBoxView = new BoxView
+ {
+ BackgroundColor = Colors.Transparent,
+ Opacity = 1.0,
+ VerticalOptions = LayoutOptions.Fill,
+ Background = new LinearGradientBrush
+ {
+ StartPoint = new Point(0, 0),
+ EndPoint = new Point(0, 1),
+ GradientStops = new GradientStopCollection
+ {
+ new GradientStop { Color = Colors.Transparent, Offset = 0.0f },
+ new GradientStop { Color = Colors.Black, Offset = 1.0f }
+ }
+ }
+ };
+
+ // Create the footer button
+ var footerButton = new Button
+ {
+ Text = "I am the footer",
+ BackgroundColor = Colors.LightGray,
+ Padding = new Thickness(10),
+ AutomationId = "FooterContentButton"
+ };
+
+ // Add elements to footer grid
+ footerGrid.Add(gradientBoxView);
+ footerGrid.Add(footerButton);
+
+ // Set footer grid as content of footer view
+ FooterView.Content = footerGrid;
+
+ // Add elements to main grid
+ Grid.SetRow(verticalStackLayout, 0);
+ Grid.SetRow(bottomMarker, 0);
+ Grid.SetRow(FooterView, 1);
+ MainGrid.Add(verticalStackLayout);
+ MainGrid.Add(bottomMarker);
+ MainGrid.Add(FooterView);
+
+ // Set the grid as the page content
+ Content = MainGrid;
+ }
+
+ void OnFooterButtonClicked(object sender, EventArgs e)
+ {
+ if (!FooterView.IsVisible)
+ {
+ Dispatcher.DispatchAsync(ShowFooter);
+ }
+ else
+ {
+ Dispatcher.DispatchAsync(HideFooter);
+ }
+ }
+
+ async Task ShowFooter()
+ {
+ if (FooterView.IsVisible)
+ {
+ return;
+ }
+
+ var height = FooterView.Measure(FooterView.Width, double.PositiveInfinity).Height;
+ FooterView.TranslationY = height;
+ FooterView.IsVisible = true;
+
+ // This causes deadlock on iOS .NET 10
+ await FooterView.TranslateToAsync(0, 0, AnimationDuration, Easing.CubicInOut);
+
+ TestLabel.Text = "Footer is now visible";
+ }
+
+ async Task HideFooter()
+ {
+ if (!FooterView.IsVisible)
+ {
+ return;
+ }
+
+ await FooterView.TranslateToAsync(0, FooterView.Height, AnimationDuration, Easing.CubicInOut);
+ FooterView.IsVisible = false;
+
+ TestLabel.Text = "Footer is now hidden";
+ }
+
+ void OnParentSafeAreaToggleClicked(object sender, EventArgs e)
+ {
+ // Toggle SafeAreaEdges on the parent Grid between Container and None
+ var currentEdges = MainGrid.SafeAreaEdges;
+ if (currentEdges == new SafeAreaEdges(SafeAreaRegions.None))
+ {
+ MainGrid.SafeAreaEdges = new SafeAreaEdges(SafeAreaRegions.Container);
+ }
+ else
+ {
+ MainGrid.SafeAreaEdges = new SafeAreaEdges(SafeAreaRegions.None);
+ }
+ UpdateStatusLabel();
+ }
+
+ void OnChildSafeAreaToggleClicked(object sender, EventArgs e)
+ {
+ // Toggle SafeAreaEdges on the child verticalStackLayout between Container and None
+ var currentEdges = verticalStackLayout.SafeAreaEdges;
+ if (currentEdges == new SafeAreaEdges(SafeAreaRegions.None))
+ {
+ verticalStackLayout.SafeAreaEdges = new SafeAreaEdges(SafeAreaRegions.Container);
+ }
+ else
+ {
+ verticalStackLayout.SafeAreaEdges = new SafeAreaEdges(SafeAreaRegions.None);
+ }
+ UpdateStatusLabel();
+ }
+
+ void UpdateStatusLabel()
+ {
+ var parentEdges = MainGrid.SafeAreaEdges == new SafeAreaEdges(SafeAreaRegions.None) ? "None" : "Container";
+ var childEdges = verticalStackLayout.SafeAreaEdges == new SafeAreaEdges(SafeAreaRegions.None) ? "None" : "Container";
+ SafeAreaStatusLabel.Text = $"Parent: {parentEdges}, Child: {childEdges}";
+ }
+}
\ No newline at end of file
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue33595.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue33595.cs
new file mode 100644
index 000000000000..57d08e6a9363
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue33595.cs
@@ -0,0 +1,112 @@
+namespace Maui.Controls.Sample.Issues;
+
+[Issue(IssueTracker.Github, 33595, "[net10] iOS 18.6 crashing on navigating to a ContentPage with Padding set and Content set to a Grid with RowDefinitions Star,Auto with ScrollView on row 0", PlatformAffected.iOS)]
+public class Issue33595 : TestShell
+{
+ public Issue33595()
+ {
+ Shell.SetBackgroundColor(this, Colors.Red);
+ }
+ protected override void Init()
+ {
+ AddContentPage(new Issue33595StartPage());
+ }
+}
+
+public class Issue33595StartPage : ContentPage
+{
+ public Issue33595StartPage()
+ {
+ Title = "Issue 33595";
+
+ var navigateButton = new Button
+ {
+ Text = "Go to New Page",
+ AutomationId = "NavigateButton",
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Center
+ };
+
+ navigateButton.Clicked += async (s, e) =>
+ {
+ await Navigation.PushAsync(new Issue33595TargetPage());
+ };
+
+ Content = new VerticalStackLayout
+ {
+ VerticalOptions = LayoutOptions.Center,
+ Children = { navigateButton }
+ };
+ }
+}
+
+public class Issue33595TargetPage : ContentPage
+{
+ public Issue33595TargetPage()
+ {
+ Title = "New Page";
+ Padding = new Thickness(5, 5, 5, 5);
+
+ var grid = new Grid
+ {
+ RowDefinitions =
+ {
+ new RowDefinition(GridLength.Star),
+ new RowDefinition(GridLength.Auto)
+ }
+ };
+
+ // Row 0: ScrollView with content
+ var scrollView = new ScrollView();
+ var stackLayout = new VerticalStackLayout
+ {
+ Padding = new Thickness(16),
+ Spacing = 12,
+ Margin = new Thickness(0, 0, 0, 88)
+ };
+
+ stackLayout.Add(new Label
+ {
+ Text = "Text 2",
+ FontSize = 24
+ });
+
+ stackLayout.Add(new Label
+ {
+ Text = "This is a long text content to simulate the original issue scenario. " +
+ "The page has Padding set on the ContentPage and the content is a Grid " +
+ "with RowDefinitions Star,Auto containing a ScrollView in row 0. " +
+ "This combination caused the app to freeze on iOS 18.6 with .NET 10."
+ });
+
+ scrollView.Content = stackLayout;
+ Grid.SetRow(scrollView, 0);
+ grid.Add(scrollView);
+
+ // Row 1: Grid with Button
+ var bottomGrid = new Grid
+ {
+ Padding = new Thickness(16)
+ };
+
+ var continueButton = new Button
+ {
+ Text = "Continue",
+ HeightRequest = 52,
+ AutomationId = "ContinueButton"
+ };
+
+ bottomGrid.Add(continueButton);
+ Grid.SetRow(bottomGrid, 1);
+ grid.Add(bottomGrid);
+
+ // Label to verify navigation succeeded (placed in the ScrollView content)
+ stackLayout.Add(new Label
+ {
+ Text = "Page loaded successfully",
+ AutomationId = "SuccessLabel"
+ });
+
+ Content = grid;
+ }
+}
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue33934.xaml b/src/Controls/tests/TestCases.HostApp/Issues/Issue33934.xaml
new file mode 100644
index 000000000000..66330e981a03
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue33934.xaml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue33934.xaml.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue33934.xaml.cs
new file mode 100644
index 000000000000..cf8d36dc360c
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue33934.xaml.cs
@@ -0,0 +1,91 @@
+#nullable enable
+
+using System.ComponentModel;
+using System.Diagnostics;
+using Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific;
+using System.Collections.ObjectModel;
+
+namespace Maui.Controls.Sample.Issues;
+
+[Issue(IssueTracker.Github, 33934, "[iOS] TranslateToAsync causes spurious SizeChanged events after animation completion", PlatformAffected.iOS)]
+public partial class Issue33934 : ContentPage
+{
+ ///
+ /// Stores the iteration count from the last opened dialog for test verification.
+ ///
+ public static int LastDialogIterationCount { get; set; }
+
+ public Issue33934()
+ {
+ InitializeComponent();
+ }
+
+ async void OnShowDialogClicked(object? sender, EventArgs e)
+ {
+ var vm = new DialogViewModel();
+ var view = new Issue33934DialogPage { BindingContext = vm };
+
+ // CRITICAL: Set iOS modal presentation style (matches ViewPresenter behavior)
+ view.SetValue(Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific.Page.ModalPresentationStyleProperty, UIModalPresentationStyle.OverFullScreen);
+
+ await Navigation.PushModalAsync(view, animated: false);
+ await vm.WaitForCloseAsync();
+
+ if (Navigation.ModalStack.LastOrDefault() == view)
+ {
+ await Navigation.PopModalAsync(animated: false);
+ }
+
+ // Store iteration count for test verification
+ LastDialogIterationCount = view.IterationCount;
+
+ // Android workaround: fixes touch responsiveness issue after background/foreground cycle
+ await Task.Yield();
+ }
+}
+
+public class DialogViewModel : ViewModelBase
+{
+ public DialogViewModel()
+ {
+ // Create 2 rows with 3 actions each (keep it small enough for BottomSheet)
+ QuickActionRows.Add(new ActionRowModel
+ {
+ AvailableQuickActions = new ObservableCollection
+ {
+ new ActionModel { Title = "Time", Icon = "⏱️" },
+ new ActionModel { Title = "Absence", Icon = "🏖️" },
+ new ActionModel { Title = "Expense", Icon = "💰" }
+ }
+ });
+
+ QuickActionRows.Add(new ActionRowModel
+ {
+ AvailableQuickActions = new ObservableCollection
+ {
+ new ActionModel { Title = "Travel", Icon = "✈️" },
+ new ActionModel { Title = "Invoice", Icon = "📄" },
+ new ActionModel { Title = "Chat", Icon = "💬" }
+ }
+ });
+ }
+
+ public ObservableCollection QuickActionRows { get; } = new();
+}
+
+public class ActionRowModel
+{
+ public ObservableCollection AvailableQuickActions { get; set; } = new();
+}
+
+public class ActionModel
+{
+ public string Title { get; set; } = string.Empty;
+ public string Icon { get; set; } = string.Empty;
+
+ public Command SelectCommand => new Command(() =>
+ {
+ // Just close the dialog when tapped
+ System.Diagnostics.Debug.WriteLine($"Action tapped: {Title}");
+ });
+}
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue33934DialogBase.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue33934DialogBase.cs
new file mode 100644
index 000000000000..132d66a6a2d6
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue33934DialogBase.cs
@@ -0,0 +1,345 @@
+#nullable enable
+
+using Microsoft.Maui;
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Layouts;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Maui.Controls.Sample.Issues;
+
+///
+/// A base page for dialog templates.
+///
+[ContentProperty(nameof(DialogContent))]
+public abstract class Issue33934DialogBase : Issue33934ViewPage
+{
+ ///
+ /// The bindable property definition for the property.
+ ///
+ public static readonly BindableProperty IsClosableProperty = BindableProperty.Create(nameof(IsClosable), typeof(bool), typeof(Issue33934DialogBase), false, propertyChanged: (b, o, v) => ((Issue33934DialogBase)b).OnIsClosableChanged((bool)o, (bool)v));
+
+ ///
+ /// The bindable property definition for the property.
+ ///
+ public static readonly BindableProperty DialogContentProperty = BindableProperty.Create(nameof(DialogContent), typeof(View), typeof(Issue33934DialogBase), propertyChanged: (b, o, v) => ((Issue33934DialogBase)b).OnDialogContentChanged((View?)o, (View?)v));
+
+ readonly Lock backgroundTasksLock = new();
+ readonly List backgroundTasks = new();
+ bool canRunBackgroundTasks = true;
+
+ ///
+ /// Creates a new instance of .
+ ///
+ protected Issue33934DialogBase()
+ {
+ this.Loaded += this.OnLoaded;
+ }
+
+
+ ///
+ /// Gets or sets whether the dialog can be closed by the user.
+ ///
+ public bool IsClosable
+ {
+ get => (bool)this.GetValue(IsClosableProperty);
+ set => this.SetValue(IsClosableProperty, value);
+ }
+
+ ///
+ /// Gets or sets the content of the dialog.
+ ///
+ public View? DialogContent
+ {
+ get => (View?)this.GetValue(DialogContentProperty);
+ set => this.SetValue(DialogContentProperty, value);
+ }
+
+ ///
+ /// Run a background task tied to the lifetime of the dialog.
+ ///
+ ///
+ ///
+ public void RunInBackground(Func runFn, CancellationToken cancellationToken = default)
+ {
+ lock (this.backgroundTasksLock)
+ {
+ CancellationTokenSource cts = cancellationToken != CancellationToken.None
+ ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
+ : new CancellationTokenSource();
+
+ if (!this.canRunBackgroundTasks)
+ {
+ cts.Cancel();
+ }
+
+ Task task = runFn(cts.Token);
+
+ task.ContinueWith(onTaskCompleted, TaskScheduler.FromCurrentSynchronizationContext());
+
+ this.backgroundTasks.Add(new BackgroundTask(task, cts));
+ }
+
+ return;
+
+ void onTaskCompleted(Task t)
+ {
+ this.RemoveCompletedTask(t);
+ if (t.IsFaulted)
+ {
+ throw t.Exception!;
+ }
+ }
+ }
+
+ ///
+ /// Called when property has changed.
+ ///
+ ///
+ ///
+ protected virtual void OnDialogContentChanged(View? oldValue, View? newValue) { }
+
+ ///
+ /// Called when property has changed.
+ ///
+ ///
+ ///
+ protected virtual void OnIsClosableChanged(bool oldValue, bool newValue) { }
+
+ ///
+ protected override void OnDisappearing()
+ {
+ this.StopAndCancelBackgroundTasks();
+ base.OnDisappearing();
+ }
+
+ void OnLoaded(object? sender, EventArgs e) { }
+
+ void RemoveCompletedTask(Task completedTask)
+ {
+ lock (this.backgroundTasksLock)
+ {
+ int index = this.backgroundTasks.FindIndex(t => ReferenceEquals(t.Task, completedTask));
+ if (index >= 0)
+ {
+ BackgroundTask bgTask = this.backgroundTasks[index];
+ this.backgroundTasks.RemoveAt(index);
+ bgTask.Cts.Dispose();
+ }
+ }
+ }
+
+ void StopAndCancelBackgroundTasks()
+ {
+ List tasksToCancel;
+
+ lock (this.backgroundTasksLock)
+ {
+ this.canRunBackgroundTasks = false;
+ tasksToCancel = new List(this.backgroundTasks);
+ this.backgroundTasks.Clear();
+ }
+
+ foreach (BackgroundTask bgTask in tasksToCancel)
+ {
+ try
+ {
+ bgTask.Cts.Cancel();
+ }
+ catch (ObjectDisposedException) { }
+ }
+
+ foreach (BackgroundTask bgTask in tasksToCancel)
+ {
+ try
+ {
+ bgTask.Task.Wait(TimeSpan.FromSeconds(2));
+ }
+ catch (AggregateException) { }
+ finally
+ {
+ bgTask.Cts.Dispose();
+ }
+ }
+ }
+
+ readonly struct BackgroundTask(Task task, CancellationTokenSource cts)
+ {
+ public readonly Task Task = task;
+ public readonly CancellationTokenSource Cts = cts;
+ }
+
+ ///
+ /// Base class for dialog layouts that support transitions.
+ ///
+ protected abstract class TransitionLayout : Layout
+ {
+ View? child;
+ bool isTransitionedIn;
+
+ public View? Child
+ {
+ get => this.child;
+ set
+ {
+ if (this.child is not null)
+ {
+ this.Remove(this.child);
+ }
+ this.child = value;
+ if (this.child is not null)
+ {
+ this.Add(this.child);
+ }
+ }
+ }
+
+ public bool IsTransitionedIn
+ {
+ get => this.isTransitionedIn;
+ set => this.isTransitionedIn = value;
+ }
+ }
+
+ ///
+ /// Layout that positions child at the bottom, sized to its content.
+ ///
+ protected class BottomSheetLayout : TransitionLayout
+ {
+ protected override ILayoutManager CreateLayoutManager()
+ {
+ return new BottomSheetLayoutManager(this);
+ }
+
+ class BottomSheetLayoutManager : ILayoutManager
+ {
+ readonly BottomSheetLayout layout;
+ Rect lastBounds;
+ Rect lastChildBounds;
+ Size lastChildSize;
+
+ public BottomSheetLayoutManager(BottomSheetLayout layout)
+ {
+ this.layout = layout;
+ }
+
+ public Size Measure(double widthConstraint, double heightConstraint)
+ {
+ if (this.layout.Child is null)
+ {
+ return Size.Zero;
+ }
+
+ var childSize = this.layout.Child.Measure(widthConstraint, heightConstraint);
+ return childSize;
+ }
+
+ public Size ArrangeChildren(Rect bounds)
+ {
+ if (this.layout.Child is null)
+ {
+ return Size.Zero;
+ }
+
+ var childSize = this.layout.Child.DesiredSize;
+
+ var childBounds = new Rect(
+ bounds.X,
+ bounds.Bottom - childSize.Height,
+ bounds.Width,
+ childSize.Height
+ );
+
+ // Track changes
+ bool boundsChanged = bounds != this.lastBounds;
+ bool childSizeChanged = childSize != this.lastChildSize;
+ bool childBoundsChanged = childBounds != this.lastChildBounds;
+
+ if (boundsChanged || childSizeChanged || childBoundsChanged)
+ {
+ System.Diagnostics.Debug.WriteLine($"[BottomSheetLayout.ArrangeChildren] CHANGE DETECTED:");
+ System.Diagnostics.Debug.WriteLine($" Received bounds: {bounds.Width}x{bounds.Height} @ ({bounds.X},{bounds.Y})");
+ System.Diagnostics.Debug.WriteLine($" Child DesiredSize: {childSize.Width}x{childSize.Height}");
+ System.Diagnostics.Debug.WriteLine($" Forwarding to child: {childBounds.Width}x{childBounds.Height} @ ({childBounds.X},{childBounds.Y})");
+ System.Diagnostics.Debug.WriteLine($" Child.TranslationY: {this.layout.Child.TranslationY}");
+ System.Diagnostics.Debug.WriteLine($" IsTransitionedIn: {this.layout.IsTransitionedIn}");
+
+ if (boundsChanged)
+ System.Diagnostics.Debug.WriteLine($" → Bounds changed: {this.lastBounds} → {bounds}");
+ if (childSizeChanged)
+ System.Diagnostics.Debug.WriteLine($" → Child size changed: {this.lastChildSize} → {childSize}");
+ if (childBoundsChanged)
+ System.Diagnostics.Debug.WriteLine($" → Child bounds changed: {this.lastChildBounds} → {childBounds}");
+
+ Console.WriteLine($"[BottomSheetLayout] ArrangeChildren - bounds: {bounds.Width}x{bounds.Height}, child: {childSize.Width}x{childSize.Height}");
+
+ this.lastBounds = bounds;
+ this.lastChildSize = childSize;
+ this.lastChildBounds = childBounds;
+ }
+
+ this.layout.Child.Arrange(childBounds);
+
+ if (!this.layout.IsTransitionedIn && this.layout.Child.TranslationY == 0)
+ {
+ this.layout.Child.TranslationY = childSize.Height;
+ }
+
+ return childSize;
+ }
+ }
+ }
+
+ ///
+ /// Layout that fills the entire screen with content.
+ ///
+ protected class FullScreenLayout : TransitionLayout
+ {
+ protected override ILayoutManager CreateLayoutManager()
+ {
+ return new FullScreenLayoutManager(this);
+ }
+
+ class FullScreenLayoutManager : ILayoutManager
+ {
+ readonly FullScreenLayout layout;
+
+ public FullScreenLayoutManager(FullScreenLayout layout)
+ {
+ this.layout = layout;
+ }
+
+ public Size Measure(double widthConstraint, double heightConstraint)
+ {
+ if (this.layout.Child is null)
+ {
+ return Size.Zero;
+ }
+
+ System.Diagnostics.Debug.WriteLine($"[FullScreenLayout.Measure] {DateTime.Now:HH:mm:ss.fff} - Measuring child with constraints: {widthConstraint}x{heightConstraint}");
+ this.layout.Child.Measure(widthConstraint, heightConstraint);
+ return new Size(widthConstraint, heightConstraint);
+ }
+
+ public Size ArrangeChildren(Rect bounds)
+ {
+ if (this.layout.Child is null)
+ {
+ return Size.Zero;
+ }
+
+ this.layout.Child.Arrange(bounds);
+
+ if (!this.layout.IsTransitionedIn && this.layout.Child.TranslationY == 0)
+ {
+ this.layout.Child.TranslationY = bounds.Height;
+ }
+
+ return bounds.Size;
+ }
+ }
+ }
+}
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue33934DialogPage.xaml b/src/Controls/tests/TestCases.HostApp/Issues/Issue33934DialogPage.xaml
new file mode 100644
index 000000000000..14cfea064a46
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue33934DialogPage.xaml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue33934DialogPage.xaml.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue33934DialogPage.xaml.cs
new file mode 100644
index 000000000000..8586c411b2f8
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue33934DialogPage.xaml.cs
@@ -0,0 +1,283 @@
+#nullable enable
+
+using Microsoft.Maui.Controls.Xaml;
+
+namespace Maui.Controls.Sample.Issues;
+
+///
+/// The view for the .
+///
+[XamlCompilation(XamlCompilationOptions.Compile)]
+public partial class Issue33934DialogPage : Issue33934BottomSheetDialog
+{
+ ///
+ /// Creates a new instance of .
+ ///
+ public Issue33934DialogPage()
+ {
+ this.InitializeComponent();
+ }
+
+ private void OnIterationCountLabelClicked(object? sender, EventArgs e)
+ {
+ // When button is clicked, update its text with the final iteration count
+ if (sender is Button button)
+ {
+ button.Text = $"Animation Iterations: {this.IterationCount}";
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether the animation exceeded 2 iterations (indicating spurious SizeChanged events).
+ ///
+ public bool HasExcessiveIterations => this.IterationCount > 2;
+}
+
+public class Issue33934BottomSheetDialog : Issue33934DialogBase
+{
+ ///
+ /// The bindable property definition for the property.
+ ///
+ public static readonly BindableProperty FullScreenThresholdProperty = BindableProperty.Create(nameof(FullScreenThreshold), typeof(double), typeof(Issue33934BottomSheetDialog), 0.66);
+
+ ///
+ /// The bindable property definition for the property.
+ ///
+ public static readonly BindableProperty EnableTransitionProperty = BindableProperty.Create(nameof(EnableTransition), typeof(bool), typeof(Issue33934BottomSheetDialog), true);
+
+ ///
+ /// The bindable property definition for the property.
+ ///
+ public static readonly BindableProperty IterationCountProperty = BindableProperty.Create(nameof(IterationCount), typeof(int), typeof(Issue33934BottomSheetDialog), 0);
+
+ Size targetSize;
+ bool hasStartedTransition;
+
+ ///
+ /// Gets or sets the threshold (0.0-1.0) at which content height triggers full-screen mode.
+ /// Default is 0.66 (66% of available height).
+ ///
+ public double FullScreenThreshold
+ {
+ get => (double)this.GetValue(FullScreenThresholdProperty);
+ set => this.SetValue(FullScreenThresholdProperty, value);
+ }
+
+ ///
+ /// Gets or sets whether the slide-up transition animation is enabled.
+ /// Default is true.
+ ///
+ public bool EnableTransition
+ {
+ get => (bool)this.GetValue(EnableTransitionProperty);
+ set => this.SetValue(EnableTransitionProperty, value);
+ }
+
+ ///
+ /// Gets the number of animation iterations (for detecting spurious SizeChanged events).
+ ///
+ public int IterationCount
+ {
+ get => (int)this.GetValue(IterationCountProperty);
+ private set => this.SetValue(IterationCountProperty, value);
+ }
+
+ ///
+ protected override void OnDialogContentChanged(View? oldValue, View? newValue)
+ {
+ base.OnDialogContentChanged(oldValue, newValue);
+
+ if (newValue is not null)
+ {
+ // Always use layout for bottom sheet positioning, even without transition
+ var layout = new BottomSheetLayout { Child = newValue };
+
+ if (!this.EnableTransition)
+ {
+ // Mark as already transitioned so it doesn't position offscreen
+ layout.IsTransitionedIn = true;
+ }
+
+ this.Content = layout;
+ }
+ }
+
+ ///
+ protected override void OnSizeAllocated(double width, double height)
+ {
+ this.targetSize = new Size(width, height);
+
+ if (this.EnableTransition && this.Content is TransitionLayout && !this.hasStartedTransition)
+ {
+ this.hasStartedTransition = true;
+ base.OnSizeAllocated(width, height);
+ this.RunInBackground(this.TransitionInAsync);
+ }
+ else
+ {
+ base.OnSizeAllocated(width, height);
+ }
+ }
+
+ ///
+ protected override void OnBindingContextChanged()
+ {
+ base.OnBindingContextChanged();
+
+ // Propagate binding context to content
+ View? content = this.DialogContent;
+ if (content is not null)
+ {
+ SetInheritedBindingContext(content, this.BindingContext);
+ }
+ }
+
+ async Task TransitionInAsync(CancellationToken cancellationToken = default)
+ {
+ System.Diagnostics.Debug.WriteLine($"[TransitionInAsync] {DateTime.Now:HH:mm:ss.fff} - STARTING");
+
+ if (this.Content is not TransitionLayout layout || layout.Child is null)
+ {
+ System.Diagnostics.Debug.WriteLine($"[TransitionInAsync] {DateTime.Now:HH:mm:ss.fff} - ABORTED: No layout or child");
+ return;
+ }
+
+ View child = layout.Child;
+ int iterationCount = 0;
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ iterationCount++;
+ System.Diagnostics.Debug.WriteLine($"════ [BottomSheet] ITERATION #{iterationCount} ════");
+ Console.WriteLine($"[BottomSheet] ITERATION #{iterationCount}");
+
+ // Update bindable property to track iterations (for UI test verification)
+ this.IterationCount = iterationCount;
+
+ CancellationTokenSource? restartCts = new CancellationTokenSource();
+
+ void sizeChangedHandler(object? sender, EventArgs e)
+ {
+ var st = new System.Diagnostics.StackTrace(true);
+ System.Diagnostics.Debug.WriteLine($"╔═══ [BottomSheet-SizeChanged] ═══");
+ System.Diagnostics.Debug.WriteLine($"║ {child.Width}x{child.Height}, TY:{child.TranslationY}");
+ System.Diagnostics.Debug.WriteLine($"║ STACK:");
+ System.Diagnostics.Debug.WriteLine(st.ToString());
+ System.Diagnostics.Debug.WriteLine($"╚══════════════════════");
+ Console.WriteLine($"[BottomSheet] SizeChanged!");
+ try
+ { restartCts?.Cancel(); }
+ catch (ObjectDisposedException) { }
+ }
+
+ void bindingContextChangedHandler(object? sender, EventArgs e)
+ {
+ var st = new System.Diagnostics.StackTrace(true);
+ System.Diagnostics.Debug.WriteLine($"╔═══ [BottomSheet-BindingContextChanged] ═══");
+ System.Diagnostics.Debug.WriteLine($"║ STACK:");
+ System.Diagnostics.Debug.WriteLine(st.ToString());
+ System.Diagnostics.Debug.WriteLine($"╚══════════════════════");
+ Console.WriteLine($"[BottomSheet] BindingContextChanged!");
+ try
+ { restartCts?.Cancel(); }
+ catch (ObjectDisposedException) { }
+ }
+
+ child.SizeChanged += sizeChangedHandler;
+ child.BindingContextChanged += bindingContextChangedHandler;
+
+ try
+ {
+ // Yield to let bindings resolve and layout happen
+ await Task.Yield();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await Task.Yield();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Cancel any ongoing animations before starting new one
+ child.CancelAnimations();
+
+ double currentHeight = child.Height;
+
+ // If no valid height yet, wait for size/binding change
+ if (currentHeight <= 0)
+ {
+ System.Diagnostics.Debug.WriteLine($"[BottomSheet-TransitionInAsync] ⏳ Waiting 2s for valid height...");
+ Console.WriteLine($"[BottomSheet] ⏳ Waiting for valid height...");
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, restartCts.Token);
+ await Task.Delay(2000, linkedCts.Token);
+ }
+ else
+ {
+ // Check if content is too large for bottom sheet
+ if (layout is BottomSheetLayout bottomSheetLayout && currentHeight > this.Height * this.FullScreenThreshold)
+ {
+ // Remove child from old parent first
+ bottomSheetLayout.Child = null;
+
+ // Switch to full screen layout
+ var fullScreenLayout = new FullScreenLayout { Child = child };
+ this.Content = fullScreenLayout;
+ layout = fullScreenLayout;
+
+ // Restart to handle new layout
+ child.SizeChanged -= sizeChangedHandler;
+ child.BindingContextChanged -= bindingContextChangedHandler;
+ restartCts?.Dispose();
+ continue;
+ }
+
+ // Ensure positioned offscreen, then animate in
+ System.Diagnostics.Debug.WriteLine($"[BottomSheet-TransitionInAsync] 🎬 Animation START: {currentHeight} → 0");
+ Console.WriteLine($"[BottomSheet] 🎬 Animation START");
+ child.TranslationY = currentHeight;
+
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, restartCts.Token);
+ await child.TranslateToAsync(0, 0, 2000, Easing.SinOut).WaitAsync(linkedCts.Token);
+
+ System.Diagnostics.Debug.WriteLine($"[BottomSheet-TransitionInAsync] ✅ Animation COMPLETE!");
+ Console.WriteLine($"[BottomSheet] ✅ COMPLETE!");
+
+ // Mark as transitioned so future layouts don't reset position
+ layout.IsTransitionedIn = true;
+ }
+
+ // Animation completed successfully
+ System.Diagnostics.Debug.WriteLine($"[BottomSheet-TransitionInAsync] 🎉 EXITING loop!");
+ Console.WriteLine($"[BottomSheet] 🎉 EXIT loop");
+
+ // Update UI to show final iteration count
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ if (this.FindByName("IterationCountLabel") is Label label)
+ {
+ label.Text = $"Animation Iterations: {this.IterationCount}";
+ label.IsVisible = true;
+ }
+ });
+
+ break;
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ System.Diagnostics.Debug.WriteLine($"🔄 [BottomSheet] RESTARTING!");
+ Console.WriteLine($"🔄 [BottomSheet] RESTART!");
+
+ // Size or binding context changed during animation, restart
+ child.SizeChanged -= sizeChangedHandler;
+ child.BindingContextChanged -= bindingContextChangedHandler;
+ restartCts?.Dispose();
+ restartCts = null;
+ }
+ finally
+ {
+ child.SizeChanged -= sizeChangedHandler;
+ child.BindingContextChanged -= bindingContextChangedHandler;
+ restartCts?.Dispose();
+ restartCts = null;
+ }
+ }
+ }
+}
+
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue33934ViewPage.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue33934ViewPage.cs
new file mode 100644
index 000000000000..8a9deae30c63
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue33934ViewPage.cs
@@ -0,0 +1,84 @@
+using Microsoft.Maui.Controls;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Maui.Controls.Sample.Issues;
+
+///
+/// Interface for ViewModels that need async lifecycle events
+///
+public interface ISupportAsyncAppearingEvents
+{
+ Task WillAppearAsync(CancellationToken cancellationToken);
+ Task DidDisappearAsync(CancellationToken cancellationToken);
+}
+
+///
+/// Simplified ViewPage for dialog reproduction - only includes essentials
+///
+public class Issue33934ViewPage : ContentPage
+{
+ protected Issue33934ViewPage()
+ {
+ NavigationPage.SetHasNavigationBar(this, false);
+ }
+
+ ///
+ /// Lifecycle event when page is appearing
+ ///
+ protected override void OnAppearing()
+ {
+ base.OnAppearing();
+ if (this.BindingContext is ISupportAsyncAppearingEvents target)
+ {
+ this.RunInBackground(target.WillAppearAsync);
+ }
+ }
+
+ ///
+ /// Lifecycle event when page is disappearing
+ ///
+ protected override void OnDisappearing()
+ {
+ base.OnDisappearing();
+ if (this.BindingContext is ISupportAsyncAppearingEvents target)
+ {
+ this.RunInBackground(target.DidDisappearAsync);
+ }
+ }
+
+ ///
+ /// Runs a background task tied to the lifetime of this view
+ ///
+ protected void RunInBackground(Func runFn, BackgroundTaskLifetime lifetime = BackgroundTaskLifetime.Disposal, CancellationToken cancellationToken = default)
+ {
+ // Simple fire-and-forget implementation for testing
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await runFn(cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"❌ [RunInBackground] EXCEPTION: {ex}");
+ Console.WriteLine($"❌ [RunInBackground] EXCEPTION: {ex}");
+ throw; // Re-throw so we see it crash
+ }
+ }, cancellationToken);
+ }
+}
+
+public enum BackgroundTaskLifetime
+{
+ ///
+ /// Task runs until the view is disposed
+ ///
+ Disposal,
+
+ ///
+ /// Task runs until the view disappears
+ ///
+ Disappearing
+}
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/ViewModelBase.cs b/src/Controls/tests/TestCases.HostApp/Issues/ViewModelBase.cs
index 42bfb25284c7..ce9a3f0d278b 100644
--- a/src/Controls/tests/TestCases.HostApp/Issues/ViewModelBase.cs
+++ b/src/Controls/tests/TestCases.HostApp/Issues/ViewModelBase.cs
@@ -154,6 +154,15 @@ protected virtual void OnIsBusyChanged()
if (method != null)
IsBusyChanged(this, EventArgs.Empty);
}
+
+ private readonly TaskCompletionSource _closeTaskCompletionSource = new();
+
+ public Task WaitForCloseAsync() => _closeTaskCompletionSource.Task;
+
+ public void Close()
+ {
+ _closeTaskCompletionSource.TrySetResult(true);
+ }
}
public class DelegateCommand : ICommand
diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue28986_ParentChildTest.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue28986_ParentChildTest.cs
new file mode 100644
index 000000000000..4b9997fc215d
--- /dev/null
+++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue28986_ParentChildTest.cs
@@ -0,0 +1,278 @@
+// iOS-only: These tests focus on bottom safe area parent/child independence.
+// Android gesture-nav emulators have 0pt bottom safe area, making every bottom-edge
+// assertion trivially true or skipped — the tests would pass but verify nothing.
+// If Android parent/child SafeAreaEdges coverage is needed, write separate tests
+// using the TOP edge (status bar is always present at ~24-48dp on Android).
+#if IOS
+using NUnit.Framework;
+using UITest.Appium;
+using UITest.Core;
+
+namespace Microsoft.Maui.TestCases.Tests.Issues;
+
+public class Issue28986_ParentChildTest : _IssuesUITest
+{
+ public override string Issue => "SafeAreaEdges independent handling for parent and child controls";
+
+ public Issue28986_ParentChildTest(TestDevice device)
+ : base(device)
+ { }
+
+ // Bottom-specific assertions guard for devices without bottom safe area (e.g. iPad without home indicator).
+ static bool HasBottomSafeArea(double measuredBottomInset) => measuredBottomInset > 2;
+
+ void WaitForText(string elementId, string expectedText, int timeoutSec = 5)
+ {
+ var endTime = DateTime.Now.AddSeconds(timeoutSec);
+ while (DateTime.Now < endTime)
+ {
+ var text = App.WaitForElement(elementId).GetText();
+ if (text == expectedText)
+ return;
+ Thread.Sleep(100);
+ }
+ var finalText = App.WaitForElement(elementId).GetText();
+ Assert.That(finalText, Is.EqualTo(expectedText), $"Timed out waiting for {elementId} text to be '{expectedText}'");
+ }
+
+ [Test, Order(1)]
+ [Category(UITestCategories.SafeAreaEdges)]
+ public void VerifyInitialStateParentTopChildBottom()
+ {
+ // Test: Parent handles TOP, Child handles BOTTOM
+ //
+ // Verify that:
+ // 1. Top indicator is inset from screen top by safe area (parent handles top)
+ // 2. Bottom indicator is inset from screen bottom by safe area (child handles bottom)
+ // 3. Both work independently without conflict
+
+ // Get screen dimensions
+ var parentGridRect = App.WaitForElement("ParentGrid").GetRect();
+ var screenTop = parentGridRect.Y;
+ var screenBottom = parentGridRect.Y + parentGridRect.Height;
+
+ // Verify initial status
+ WaitForText("StatusLabel", "Parent: Top=Container, Bottom=None | Child: Bottom=Container");
+
+ // Measure top indicator position
+ var topIndicatorRect = App.WaitForElement("TopIndicator").GetRect();
+ var topIndicatorTop = topIndicatorRect.Y;
+
+ // Top indicator should be below the screen top (safe area applied)
+ var topInsetFromScreenTop = topIndicatorTop - screenTop;
+ Assert.That(topInsetFromScreenTop, Is.GreaterThan(5),
+ $"Top indicator should be inset from screen top by safe area. " +
+ $"Current inset: {topInsetFromScreenTop}pt (expected >5pt)");
+
+ // Measure bottom indicator position
+ var bottomIndicatorRect = App.WaitForElement("BottomIndicator").GetRect();
+ var bottomIndicatorBottom = bottomIndicatorRect.Y + bottomIndicatorRect.Height;
+
+ // Bottom indicator should be above the screen bottom (safe area applied)
+ var bottomInsetFromScreenBottom = screenBottom - bottomIndicatorBottom;
+ // On devices with bottom safe area (iOS home indicator, Android nav bar), verify meaningful inset.
+ // On gesture-nav Android devices, bottom safe area is correctly 0.
+ if (HasBottomSafeArea(bottomInsetFromScreenBottom))
+ {
+ Assert.That(bottomInsetFromScreenBottom, Is.GreaterThan(5),
+ $"Bottom indicator should be inset from screen bottom by safe area. " +
+ $"Current inset: {bottomInsetFromScreenBottom}pt (expected >5pt)");
+ }
+ else
+ {
+ Assert.That(bottomInsetFromScreenBottom, Is.GreaterThanOrEqualTo(0),
+ $"Bottom indicator should not extend below screen bottom. Inset: {bottomInsetFromScreenBottom}pt");
+ }
+ }
+
+ [Test, Order(2)]
+ [Category(UITestCategories.SafeAreaEdges)]
+ public void VerifyRuntimeSafeAreaChangePreservesPositions()
+ {
+ // Test: Runtime SafeAreaEdges changes are applied correctly without position conflicts
+ //
+ // Scenario:
+ // Step 1: Parent=Bottom=None, Child=Bottom=None (nothing handles bottom)
+ // → Bottom indicator should reach screen bottom
+ // Step 2: Child=Bottom=Container (child takes over bottom handling)
+ // → Bottom indicator should move up (safe area applied)
+ // Step 3: Parent=Bottom=Container (parent also handles bottom)
+ // → Bottom position should match Step 2 (no double padding)
+
+ var parentGridRect = App.WaitForElement("ParentGrid").GetRect();
+ var screenBottom = parentGridRect.Y + parentGridRect.Height;
+
+ // STEP 1: Set Parent: Bottom=None, Child: Bottom=None
+ App.Tap("ToggleParentBottomButton");
+ WaitForText("StatusLabel", "Parent: Top=Container, Bottom=Container | Child: Bottom=Container");
+
+ App.Tap("ToggleParentBottomButton");
+ WaitForText("StatusLabel", "Parent: Top=Container, Bottom=None | Child: Bottom=Container");
+
+ App.Tap("ToggleChildBottomButton");
+ WaitForText("StatusLabel", "Parent: Top=Container, Bottom=None | Child: Bottom=None");
+
+ // Measure bottom indicator position when nothing handles bottom
+ var bottomIndicatorRectNone = App.WaitForElement("BottomIndicator").GetRect();
+ var bottomIndicatorBottomNone = bottomIndicatorRectNone.Y + bottomIndicatorRectNone.Height;
+ var distanceFromScreenBottomNone = screenBottom - bottomIndicatorBottomNone;
+
+ // Bottom should reach close to screen edge
+ Assert.That(distanceFromScreenBottomNone, Is.LessThan(5),
+ "Bottom indicator should reach near screen bottom when both parent and child have SafeAreaEdges=None");
+
+ // STEP 2: Child=Bottom=Container (child handles bottom)
+ App.Tap("ToggleChildBottomButton");
+ WaitForText("StatusLabel", "Parent: Top=Container, Bottom=None | Child: Bottom=Container");
+
+ // Measure bottom indicator position with child handling
+ var bottomIndicatorRectChildHandles = App.WaitForElement("BottomIndicator").GetRect();
+ var bottomIndicatorBottomChildHandles = bottomIndicatorRectChildHandles.Y + bottomIndicatorRectChildHandles.Height;
+ var distanceFromScreenBottomChildHandles = screenBottom - bottomIndicatorBottomChildHandles;
+
+ // Bottom should now be inset by safe area (if device has bottom safe area)
+ // On gesture-nav Android, Container and None produce the same position (both 0)
+ if (HasBottomSafeArea(distanceFromScreenBottomChildHandles))
+ {
+ Assert.That(distanceFromScreenBottomChildHandles, Is.GreaterThan(20),
+ "Bottom indicator should be inset by safe area when child handles bottom");
+ }
+
+ // Record this as the expected position
+ var expectedBottomPosition = bottomIndicatorBottomChildHandles;
+
+ // STEP 3: Parent=Bottom=Container (parent also handles bottom)
+ App.Tap("ToggleParentBottomButton");
+ WaitForText("StatusLabel", "Parent: Top=Container, Bottom=Container | Child: Bottom=Container");
+
+ // Measure bottom indicator position when both parent and child handle bottom
+ var bottomIndicatorRectBothHandle = App.WaitForElement("BottomIndicator").GetRect();
+ var bottomIndicatorBottomBothHandle = bottomIndicatorRectBothHandle.Y + bottomIndicatorRectBothHandle.Height;
+
+ // Key assertion: No double padding
+ // The bottom position should be THE SAME as when only child handled it
+ // If there's double padding, the bottom would be significantly higher (smaller Y)
+ var verticalDifference = Math.Abs(expectedBottomPosition - bottomIndicatorBottomBothHandle);
+ Assert.That(verticalDifference, Is.LessThan(5),
+ $"Bottom indicator position should be nearly identical when parent adds bottom handling " +
+ $"(child already handling). Vertical difference: {verticalDifference}pt. " +
+ $"If difference exceeds safe area size (~34pt), it indicates double padding bug.");
+ }
+
+ [Test, Order(3)]
+ [Category(UITestCategories.SafeAreaEdges)]
+ public void VerifyChildCanHandleWhenParentDoesNot()
+ {
+ // Test: Child safe area handling is NOT blocked by parent's other safe area handling
+ //
+ // Scenario:
+ // Parent: Top=Container, Bottom=None (handles ONLY top)
+ // Child: Bottom=Container (handles ONLY bottom)
+ //
+ // Result: Both should work independently
+
+ // Reset to known state
+ var currentStatus = App.WaitForElement("StatusLabel").GetText();
+
+ // If Parent Bottom is Container, toggle it back to None
+ if (currentStatus?.Contains("Bottom=Container", StringComparison.OrdinalIgnoreCase) == true)
+ {
+ App.Tap("ToggleParentBottomButton");
+ }
+
+ WaitForText("StatusLabel", "Parent: Top=Container, Bottom=None | Child: Bottom=Container");
+
+ var parentGridRect = App.WaitForElement("ParentGrid").GetRect();
+ var screenTop = parentGridRect.Y;
+ var screenBottom = parentGridRect.Y + parentGridRect.Height;
+
+ // Measure top indicator
+ var topIndicatorRect = App.WaitForElement("TopIndicator").GetRect();
+ var topInset = topIndicatorRect.Y - screenTop;
+
+ // Measure bottom indicator
+ var bottomIndicatorRect = App.WaitForElement("BottomIndicator").GetRect();
+ var bottomInset = screenBottom - (bottomIndicatorRect.Y + bottomIndicatorRect.Height);
+
+ // Both should have proper insets
+ Assert.That(topInset, Is.GreaterThan(5),
+ "Parent should handle top safe area");
+
+ // On devices with bottom safe area, verify child handles it independently.
+ // On gesture-nav Android, bottom safe area is correctly 0.
+ if (HasBottomSafeArea(bottomInset))
+ {
+ Assert.That(bottomInset, Is.GreaterThan(5),
+ "Child should handle bottom safe area independently");
+ }
+ else
+ {
+ Assert.That(bottomInset, Is.GreaterThanOrEqualTo(0),
+ "Bottom indicator should not extend below screen bottom");
+ }
+
+ // Key assertion: Child's bottom handling coexists with parent's top handling
+ // They don't conflict or block each other
+ Assert.Pass("Child's bottom safe area handling works independently while parent handles top");
+ }
+
+ [Test, Order(4)]
+ [Category(UITestCategories.SafeAreaEdges)]
+ public void VerifyPositionConsistencyAcrossToggles()
+ {
+ // Test: Toggling SafeAreaEdges on/off produces consistent positions
+ //
+ // Scenario:
+ // 1. Start: Parent=Top=Container, Bottom=None | Child=Bottom=Container
+ // 2. Toggle Child Bottom to None → Bottom should move to screen edge
+ // 3. Toggle Child Bottom back to Container → Bottom should return to original position
+ // 4. Repeat cycle 2 more times to verify consistency
+
+ // Reset to known state
+ var currentStatus = App.WaitForElement("StatusLabel").GetText();
+ if (currentStatus != null && currentStatus.Contains("Bottom=Container", StringComparison.OrdinalIgnoreCase) && currentStatus.Contains("Parent: Top=Container, Bottom=Container", StringComparison.OrdinalIgnoreCase))
+ {
+ App.Tap("ToggleParentBottomButton");
+ }
+
+ WaitForText("StatusLabel", "Parent: Top=Container, Bottom=None | Child: Bottom=Container");
+
+ var parentGridRect = App.WaitForElement("ParentGrid").GetRect();
+ var screenBottom = parentGridRect.Y + parentGridRect.Height;
+
+ // Record baseline bottom indicator position
+ var bottomIndicatorRectBaseline = App.WaitForElement("BottomIndicator").GetRect();
+ var baselineBottomY = bottomIndicatorRectBaseline.Y + bottomIndicatorRectBaseline.Height;
+ var baselineInset = screenBottom - baselineBottomY;
+
+ // Cycle 3 times: None → Container → None → Container
+ for (int i = 0; i < 3; i++)
+ {
+ // Toggle to None
+ App.Tap("ToggleChildBottomButton");
+ WaitForText("StatusLabel", "Parent: Top=Container, Bottom=None | Child: Bottom=None");
+
+ var bottomIndicatorRectNone = App.WaitForElement("BottomIndicator").GetRect();
+ var bottomYNone = bottomIndicatorRectNone.Y + bottomIndicatorRectNone.Height;
+ var insetNone = screenBottom - bottomYNone;
+
+ // Should be close to screen bottom
+ Assert.That(insetNone, Is.LessThan(20),
+ $"Cycle {i + 1}: Bottom should reach screen edge when Child=None");
+
+ // Toggle back to Container
+ App.Tap("ToggleChildBottomButton");
+ WaitForText("StatusLabel", "Parent: Top=Container, Bottom=None | Child: Bottom=Container");
+
+ var bottomIndicatorRectRestored = App.WaitForElement("BottomIndicator").GetRect();
+ var bottomYRestored = bottomIndicatorRectRestored.Y + bottomIndicatorRectRestored.Height;
+ var insetRestored = screenBottom - bottomYRestored;
+
+ // Should return to baseline position
+ Assert.That(insetRestored, Is.EqualTo(baselineInset).Within(5),
+ $"Cycle {i + 1}: Bottom should return to original position when Child=Container. " +
+ $"Original inset: {baselineInset}pt, Current inset: {insetRestored}pt");
+ }
+ }
+}
+#endif
diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32586.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32586.cs
new file mode 100644
index 000000000000..d4db83547792
--- /dev/null
+++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32586.cs
@@ -0,0 +1,256 @@
+#if IOS || ANDROID
+using NUnit.Framework;
+using UITest.Appium;
+using UITest.Core;
+
+namespace Microsoft.Maui.TestCases.Tests.Issues;
+
+public class Issue32586 : _IssuesUITest
+{
+ public override string Issue => "[iOS] Layout issue using TranslateToAsync causes infinite property changed cycle";
+
+ public Issue32586(TestDevice device)
+ : base(device)
+ { }
+
+ void WaitForText(string elementId, string expectedText, int timeoutSec = 5)
+ {
+ var endTime = DateTime.Now.AddSeconds(timeoutSec);
+ while (DateTime.Now < endTime)
+ {
+ var text = App.WaitForElement(elementId).GetText();
+ if (text == expectedText) return;
+ Thread.Sleep(100);
+ }
+ var finalText = App.WaitForElement(elementId).GetText();
+ Assert.That(finalText, Is.EqualTo(expectedText), $"Timed out waiting for {elementId} text to be '{expectedText}'");
+ }
+
+ [Test, Order(1)]
+ [Category(UITestCategories.SafeAreaEdges)]
+ public void VerifyFooterAnimationCompletes()
+ {
+ // The core bug: TranslateToAsync on the footer causes an infinite layout cycle.
+ // If the animation completes and the label updates, the cycle is broken.
+ App.WaitForElement("FooterButton");
+ App.Tap("FooterButton");
+
+ // If the animation is stuck in an infinite loop, this will time out
+ WaitForText("TestLabel", "Footer is now visible", timeoutSec: 10);
+
+ // Verify the footer is actually visible on screen
+ var footerRect = App.WaitForElement("FooterContentButton").GetRect();
+ Assert.That(footerRect.Height, Is.GreaterThan(0), "Footer should be visible with non-zero height");
+
+ // Hide footer and verify
+ App.Tap("FooterButton");
+ WaitForText("TestLabel", "Footer is now hidden", timeoutSec: 10);
+ }
+
+ [Test, Order(2)]
+ [Category(UITestCategories.SafeAreaEdges)]
+ public void VerifyFooterPositionRespectsSafeArea()
+ {
+ // Verifies that the footer reaches the bottom of its container (MainGrid)
+ // when SafeAreaEdges=None is set on both the parent Grid and child layout.
+ // Note: The ContentPage itself still handles safe area, so MainGrid's bottom
+ // is already inset from the screen edge. The footer should fill MainGrid fully.
+
+ // Get container dimensions from the main grid
+ var gridRect = App.WaitForElement("MainGrid").GetRect();
+ var gridBottom = gridRect.Y + gridRect.Height;
+
+ // Step 1: Set both parent and child SafeAreaEdges to None
+ App.Tap("ParentSafeAreaToggleButton");
+ WaitForText("SafeAreaStatusLabel", "Parent: None, Child: Container");
+ App.Tap("ChildSafeAreaToggleButton");
+ WaitForText("SafeAreaStatusLabel", "Parent: None, Child: None");
+
+ // Step 2: Show footer with SafeAreaEdges=None on Grid and StackLayout
+ App.Tap("FooterButton");
+ WaitForText("TestLabel", "Footer is now visible", timeoutSec: 10);
+
+ // Step 3: Measure footer position — should reach MainGrid's bottom edge
+ var footerRect = App.WaitForElement("FooterContentButton").GetRect();
+ var footerBottom = footerRect.Y + footerRect.Height;
+
+ // Footer should reach close to the grid's bottom edge.
+ // The grid's bottom may be inset from screen edge due to ContentPage safe area,
+ // but the footer should fill within the grid without additional insets.
+ var distanceFromGridBottom = gridBottom - footerBottom;
+
+ // On Android, grid.GetRect() may include area behind the system navigation bar,
+ // so the gap is larger (nav bar is ~48dp). On iOS the grid is already inset.
+ var maxAllowedGap = 40;
+#if ANDROID
+ maxAllowedGap = 130; // Account for Android system navigation bar
+#endif
+ Assert.That(distanceFromGridBottom, Is.LessThan(maxAllowedGap),
+ $"Footer bottom ({footerBottom}) should reach near grid bottom ({gridBottom}) " +
+ $"when SafeAreaEdges=None on Grid, but is {distanceFromGridBottom}pt short.");
+ }
+
+ [Test, Order(3)]
+ [Category(UITestCategories.SafeAreaEdges)]
+ public void VerifyRuntimeSafeAreaEdgesChange()
+ {
+ // Reset to initial state in case previous tests left state changes
+ var currentStatus = App.WaitForElement("SafeAreaStatusLabel").GetText();
+ if (currentStatus != "Parent: Container, Child: Container")
+ {
+ // If parent is None, toggle it back to Container
+ if (currentStatus?.Contains("Parent: None", StringComparison.OrdinalIgnoreCase) == true)
+ {
+ App.Tap("ParentSafeAreaToggleButton");
+ }
+ // If child is None, toggle it back to Container
+ currentStatus = App.WaitForElement("SafeAreaStatusLabel").GetText();
+ if (currentStatus?.Contains("Child: None", StringComparison.OrdinalIgnoreCase) == true)
+ {
+ App.Tap("ChildSafeAreaToggleButton");
+ }
+ WaitForText("SafeAreaStatusLabel", "Parent: Container, Child: Container");
+ }
+
+ // Step 1: Default state - Parent Grid handles safe area (Container)
+ var statusLabel = App.WaitForElement("SafeAreaStatusLabel");
+ Assert.That(statusLabel.GetText(), Is.EqualTo("Parent: Container, Child: Container"));
+
+ var topMarkerRect = App.WaitForElement("TopMarker").GetRect();
+ var initialY = topMarkerRect.Y;
+ Assert.That(initialY, Is.GreaterThan(0), "Content should be below safe area when parent handles it");
+
+ // Step 2: Set parent Grid SafeAreaEdges to None
+ App.Tap("ParentSafeAreaToggleButton");
+ WaitForText("SafeAreaStatusLabel", "Parent: None, Child: Container");
+
+ topMarkerRect = App.WaitForElement("TopMarker").GetRect();
+ var childHandlingY = topMarkerRect.Y;
+ Assert.That(childHandlingY, Is.GreaterThan(0), "Child should handle safe area when parent doesn't");
+
+ // Step 3: Set child SafeAreaEdges to None too — content should move under safe area
+ App.Tap("ChildSafeAreaToggleButton");
+ WaitForText("SafeAreaStatusLabel", "Parent: None, Child: None");
+
+ topMarkerRect = App.WaitForElement("TopMarker").GetRect();
+ var noSafeAreaY = topMarkerRect.Y;
+ Assert.That(noSafeAreaY, Is.LessThan(childHandlingY), "Content should move up under safe area when no one handles it");
+
+ // Step 4: Restore parent to Container
+ App.Tap("ParentSafeAreaToggleButton");
+ WaitForText("SafeAreaStatusLabel", "Parent: Container, Child: None");
+
+ topMarkerRect = App.WaitForElement("TopMarker").GetRect();
+ var restoredY = topMarkerRect.Y;
+ Assert.That(restoredY, Is.GreaterThan(noSafeAreaY), "Parent should push content below safe area again");
+
+ // Step 5: Verify UI is still responsive
+ App.Tap("FooterButton");
+ WaitForText("TestLabel", "Footer is now hidden", timeoutSec: 10);
+ }
+
+ [Test, Order(4)]
+ [Category(UITestCategories.SafeAreaEdges)]
+ public void VerifyRotationDuringAnimationPreservesSafeArea()
+ {
+ // Regression test: rotation during an active TranslateToAsync animation
+ // should still update safe area correctly. The Window-level SafeAreaInsets
+ // comparison fix must not suppress genuine rotation-induced changes.
+
+ // Reset to Container state
+ var currentStatus = App.WaitForElement("SafeAreaStatusLabel").GetText();
+ if (currentStatus?.Contains("Parent: None", StringComparison.OrdinalIgnoreCase) == true)
+ App.Tap("ParentSafeAreaToggleButton");
+ currentStatus = App.WaitForElement("SafeAreaStatusLabel").GetText();
+ if (currentStatus?.Contains("Child: None", StringComparison.OrdinalIgnoreCase) == true)
+ App.Tap("ChildSafeAreaToggleButton");
+
+ // Step 1: Record portrait safe area position
+ App.SetOrientationPortrait();
+ Thread.Sleep(1000);
+ var portraitTopY = App.WaitForElement("TopMarker").GetRect().Y;
+ Assert.That(portraitTopY, Is.GreaterThan(0), "Content should be below safe area in portrait");
+
+ // Step 2: Start footer animation (triggers TranslateToAsync)
+ App.Tap("FooterButton");
+
+ // Step 3: Rotate to landscape DURING animation
+ App.SetOrientationLandscape();
+ Thread.Sleep(2000);
+
+ // Step 4: Wait for animation to complete
+ WaitForText("TestLabel", "Footer is now visible", timeoutSec: 10);
+
+ // Step 5: Verify safe area still applies correctly in landscape
+ var landscapeTopY = App.WaitForElement("TopMarker").GetRect().Y;
+ Assert.That(landscapeTopY, Is.GreaterThan(0),
+ "Content should still respect safe area after rotation during animation");
+
+ // Step 6: Rotate back to portrait and verify
+ App.SetOrientationPortrait();
+ Thread.Sleep(2000);
+
+ var restoredTopY = App.WaitForElement("TopMarker").GetRect().Y;
+ Assert.That(restoredTopY, Is.EqualTo(portraitTopY).Within(5),
+ "Safe area should restore to original portrait position after rotation cycle");
+
+ // Cleanup: hide footer
+ App.Tap("FooterButton");
+ WaitForText("TestLabel", "Footer is now hidden", timeoutSec: 10);
+ }
+
+ [Test, Order(5)]
+ [Category(UITestCategories.SafeAreaEdges)]
+ public void VerifyRapidSafeAreaToggleCycling()
+ {
+ // Regression test: rapidly cycling SafeAreaEdges between None and Container
+ // should always produce correct layout. The _safeAreaInvalidated bug fix in
+ // MauiScrollView could unmask missing invalidation paths if any exist.
+
+ // Reset to known state
+ var currentStatus = App.WaitForElement("SafeAreaStatusLabel").GetText();
+ if (currentStatus?.Contains("Parent: None", StringComparison.OrdinalIgnoreCase) == true)
+ App.Tap("ParentSafeAreaToggleButton");
+ currentStatus = App.WaitForElement("SafeAreaStatusLabel").GetText();
+ if (currentStatus?.Contains("Child: None", StringComparison.OrdinalIgnoreCase) == true)
+ App.Tap("ChildSafeAreaToggleButton");
+ WaitForText("SafeAreaStatusLabel", "Parent: Container, Child: Container");
+
+ var containerY = App.WaitForElement("TopMarker").GetRect().Y;
+
+ // Cycle 3 times: Container → None → Container
+ for (int i = 0; i < 3; i++)
+ {
+ // Toggle parent to None
+ App.Tap("ParentSafeAreaToggleButton");
+ WaitForText("SafeAreaStatusLabel", "Parent: None, Child: Container");
+
+ // Toggle child to None
+ App.Tap("ChildSafeAreaToggleButton");
+ WaitForText("SafeAreaStatusLabel", "Parent: None, Child: None");
+
+ var noneY = App.WaitForElement("TopMarker").GetRect().Y;
+ Assert.That(noneY, Is.LessThan(containerY),
+ $"Cycle {i + 1}: Content should be under safe area when both are None");
+
+ // Toggle parent back to Container
+ App.Tap("ParentSafeAreaToggleButton");
+ WaitForText("SafeAreaStatusLabel", "Parent: Container, Child: None");
+
+ // Toggle child back to Container
+ App.Tap("ChildSafeAreaToggleButton");
+ WaitForText("SafeAreaStatusLabel", "Parent: Container, Child: Container");
+
+ var restoredY = App.WaitForElement("TopMarker").GetRect().Y;
+ Assert.That(restoredY, Is.EqualTo(containerY).Within(5),
+ $"Cycle {i + 1}: Content should return to original safe area position");
+ }
+
+ // Verify app is still responsive after rapid cycling
+ App.Tap("FooterButton");
+ WaitForText("TestLabel", "Footer is now visible", timeoutSec: 10);
+ App.Tap("FooterButton");
+ WaitForText("TestLabel", "Footer is now hidden", timeoutSec: 10);
+ }
+}
+#endif
\ No newline at end of file
diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33595.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33595.cs
new file mode 100644
index 000000000000..4f6f31d29da6
--- /dev/null
+++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33595.cs
@@ -0,0 +1,33 @@
+#if IOS
+using NUnit.Framework;
+using UITest.Appium;
+using UITest.Core;
+
+namespace Microsoft.Maui.TestCases.Tests.Issues;
+
+public class Issue33595 : _IssuesUITest
+{
+ public override string Issue => "[net10] iOS 18.6 crashing on navigating to a ContentPage with Padding set and Content set to a Grid with RowDefinitions Star,Auto with ScrollView on row 0";
+
+ public Issue33595(TestDevice device)
+ : base(device)
+ { }
+
+ [Test]
+ [Category(UITestCategories.SafeAreaEdges)]
+ public void VerifyNavigationToPageWithPaddingAndScrollView()
+ {
+ // Tap the navigate button to push the target page
+ App.WaitForElement("NavigateButton");
+ App.Tap("NavigateButton");
+
+ // If the page navigates successfully without crashing/freezing,
+ // we should be able to find the success label and continue button
+ var successLabel = App.WaitForElement("SuccessLabel");
+ Assert.That(successLabel.GetText(), Is.EqualTo("Page loaded successfully"));
+
+ var continueButton = App.WaitForElement("ContinueButton");
+ Assert.That(continueButton, Is.Not.Null);
+ }
+}
+#endif
diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33934.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33934.cs
new file mode 100644
index 000000000000..f73158fc546a
--- /dev/null
+++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33934.cs
@@ -0,0 +1,56 @@
+#if IOS
+using NUnit.Framework;
+using UITest.Appium;
+using UITest.Core;
+
+namespace Microsoft.Maui.TestCases.Tests.Issues;
+
+public class Issue33934 : _IssuesUITest
+{
+ public override string Issue => "[iOS] TranslateToAsync causes spurious SizeChanged events after animation completion";
+
+ public Issue33934(TestDevice device) : base(device) { }
+
+ [Test]
+ [Category(UITestCategories.SafeAreaEdges)]
+ public void BottomSheetAnimationShouldComplete()
+ {
+ // Tester reported: "Due to an infinite layout loop, the bottom sheet animation
+ // does not stop and continues indefinitely."
+ //
+ // This test verifies the animation completes by checking that:
+ // 1. The IterationCountLabel becomes visible (only happens when loop exits)
+ // 2. The iteration count is reasonable (≤ 2)
+ //
+ // If the layout loop is infinite, the label never becomes visible and
+ // the WaitForElement will time out after 15 seconds — a clear failure signal.
+
+ // Step 1: Open the dialog which triggers the bottom sheet animation
+ App.WaitForElement("ShowDialogBtn", timeout: TimeSpan.FromSeconds(10));
+ App.Tap("ShowDialogBtn");
+
+ // Step 2: Wait for the iteration count label to become visible.
+ // This label is ONLY made visible when the animation loop exits successfully.
+ // If the loop is infinite, this will time out — proving the bug exists.
+ var iterationLabel = App.WaitForElement("IterationCountLabel", timeout: TimeSpan.FromSeconds(15));
+ Assert.That(iterationLabel, Is.Not.Null,
+ "IterationCountLabel never became visible — the bottom sheet animation is stuck " +
+ "in an infinite layout loop and never completed.");
+
+ // Step 3: Verify the iteration count is reasonable
+ var labelText = iterationLabel.GetText();
+ Assert.That(labelText, Is.Not.Null, "Label text should not be null");
+
+ var parts = labelText!.Split(':');
+ Assert.That(parts.Length, Is.GreaterThanOrEqualTo(2), $"Unexpected label format: '{labelText}'");
+
+ var countStr = parts[1].Trim();
+ Assert.That(int.TryParse(countStr, out int iterationCount), Is.True,
+ $"Failed to parse iteration count from: '{labelText}'");
+
+ Assert.That(iterationCount, Is.LessThanOrEqualTo(2),
+ $"Animation completed but took {iterationCount} iterations (expected ≤ 2). " +
+ "This indicates spurious SizeChanged events are still triggering unnecessary restarts.");
+ }
+}
+#endif
diff --git a/src/Core/src/Platform/iOS/MauiScrollView.cs b/src/Core/src/Platform/iOS/MauiScrollView.cs
index 1ee688f03567..e5a489c63de3 100644
--- a/src/Core/src/Platform/iOS/MauiScrollView.cs
+++ b/src/Core/src/Platform/iOS/MauiScrollView.cs
@@ -68,9 +68,13 @@ public class MauiScrollView : UIScrollView, IUIViewLifeCycleEvents, ICrossPlatfo
///
bool _safeAreaInvalidated = true;
+ // Cached result of whether a parent MauiView is already applying safe area adjustments.
+ // Null means not yet determined. Invalidated when view hierarchy changes.
+ bool? _parentHandlesSafeArea;
+
///
/// Flag indicating whether this scroll view should apply safe area adjustments to its content.
- /// Only true when not nested in another scroll view and safe area is not empty.
+ /// Only true when not nested in another scroll view, no parent MauiView handles it, and safe area is not empty.
///
bool _appliesSafeAreaAdjustments;
@@ -122,6 +126,20 @@ bool RespondsToSafeArea()
return !(_scrollViewDescendant ??= Superview.GetParentOfType() is not null);
}
+ ///
+ /// Checks if any ancestor MauiView is already applying safe area adjustments.
+ /// When a parent already handles safe area, this scroll view should not double-apply insets,
+ /// which would otherwise cause infinite layout cycles (#33595).
+ ///
+ bool IsParentHandlingSafeArea()
+ {
+ if (_parentHandlesSafeArea.HasValue)
+ return _parentHandlesSafeArea.Value;
+
+ _parentHandlesSafeArea = this.FindParent(x => x is MauiView mv && mv.AppliesSafeAreaAdjustments) is not null;
+ return _parentHandlesSafeArea.Value;
+ }
+
///
/// Called by iOS when the adjusted content inset changes (e.g., when safe area changes).
/// This method invalidates the safe area and triggers a layout update if needed.
@@ -148,8 +166,18 @@ public override void SafeAreaInsetsDidChange()
{
// Note: UIKit invokes LayoutSubviews right after this method
base.SafeAreaInsetsDidChange();
+ _parentHandlesSafeArea = null;
+ _safeAreaInvalidated = true;
+ }
+ ///
+ /// Directly invalidates this view's safe area, forcing re-evaluation on next layout pass.
+ ///
+ internal void InvalidateSafeArea()
+ {
+ _parentHandlesSafeArea = null;
_safeAreaInvalidated = true;
+ SetNeedsLayout();
}
///
@@ -169,7 +197,7 @@ SafeAreaRegions GetSafeAreaRegionForEdge(int edge)
{
return safeAreaPage.GetSafeAreaRegionsForEdge(edge);
}
-
+
return SafeAreaRegions.None; // Default: edge-to-edge content
}
@@ -215,7 +243,7 @@ bool UpdateContentInsetAdjustmentBehavior()
// All edges have the same value, use built-in iOS behavior
// Cache the region value to avoid redundant comparisons
var region = leftRegion;
-
+
ContentInsetAdjustmentBehavior = region switch
{
SafeAreaRegions.Default => UIScrollViewContentInsetAdjustmentBehavior.Automatic, // Default behavior
@@ -328,8 +356,9 @@ bool ValidateSafeArea()
//UpdateKeyboardSubscription();
// If nothing changed, we don't need to do anything
- if (!UpdateContentInsetAdjustmentBehavior())
+ if (UpdateContentInsetAdjustmentBehavior())
{
+ // Edges changed - invalidate and force re-evaluation
InvalidateConstraintsCache();
_safeAreaInvalidated = true;
}
@@ -340,7 +369,7 @@ bool ValidateSafeArea()
}
// Mark the safe area as validated given that we're about to check it
- _safeAreaInvalidated = true;
+ _safeAreaInvalidated = false;
var oldSafeArea = _safeArea;
@@ -356,7 +385,7 @@ bool ValidateSafeArea()
_safeArea = SystemAdjustedContentInset.ToSafeAreaInsets();
var oldApplyingSafeAreaAdjustments = _appliesSafeAreaAdjustments;
- _appliesSafeAreaAdjustments = RespondsToSafeArea() && !_safeArea.IsEmpty;
+ _appliesSafeAreaAdjustments = !IsParentHandlingSafeArea() && RespondsToSafeArea() && !_safeArea.IsEmpty;
if (_systemAdjustedContentInset != SystemAdjustedContentInset)
{
@@ -370,9 +399,11 @@ bool ValidateSafeArea()
InvalidateConstraintsCache();
}
- // Return whether the way safe area interacts with our view has changed
+ // Return whether the way safe area interacts with our view has changed.
+ // Compare at device-pixel resolution to filter sub-pixel noise from animations
+ // that would otherwise trigger infinite layout invalidation cycles (#32586, #33934).
return oldApplyingSafeAreaAdjustments == _appliesSafeAreaAdjustments &&
- (oldSafeArea == _safeArea || !_appliesSafeAreaAdjustments);
+ (oldSafeArea.EqualsAtPixelLevel(_safeArea) || !_appliesSafeAreaAdjustments);
}
UIEdgeInsets SystemAdjustedContentInset
@@ -466,49 +497,40 @@ Size CrossPlatformArrange(CGRect bounds)
contentSize = new Size(width, height);
- // For Right-To-Left (RTL) layouts, we need to adjust the content arrangement and offset
- // to ensure the content is correctly aligned and scrolled. This involves a second layout
- // arrangement with an adjusted starting point and recalculating the content offset.
- if (_previousEffectiveUserInterfaceLayoutDirection != EffectiveUserInterfaceLayoutDirection)
+ bool isDirectionChange = _previousEffectiveUserInterfaceLayoutDirection != EffectiveUserInterfaceLayoutDirection;
+
+ // For Right-To-Left (RTL) layouts, iOS natively handles visual mirroring via
+ // SemanticContentAttribute.ForceRightToLeft. Content should remain at normal (0,0) coordinates.
+ // We only set ContentOffset to position the scroll at the RTL "start" (maximum horizontal offset).
+ // Content at negative X coordinates would be outside the scrollable range and unreachable.
+ if (isDirectionChange)
{
- // In mac platform, Scrollbar is not updated based on FlowDirection, so resetting the scroll indicators
- // It's a native limitation; to maintain platform consistency, a hack fix is applied to show the Scrollbar based on the FlowDirection.
- if (OperatingSystem.IsMacCatalyst() && _previousEffectiveUserInterfaceLayoutDirection is not null)
+ if (EffectiveUserInterfaceLayoutDirection == UIUserInterfaceLayoutDirection.RightToLeft)
{
- bool showsVertical = ShowsVerticalScrollIndicator;
- bool showsHorizontal = ShowsHorizontalScrollIndicator;
+ // In mac platform, Scrollbar is not updated based on FlowDirection, so resetting the scroll indicators.
+ // It's a native limitation; to maintain platform consistency, a hack fix is applied to show the Scrollbar based on the FlowDirection.
+ if (OperatingSystem.IsMacCatalyst() && _previousEffectiveUserInterfaceLayoutDirection is not null)
+ {
+ bool showsVertical = ShowsVerticalScrollIndicator;
+ bool showsHorizontal = ShowsHorizontalScrollIndicator;
- ShowsVerticalScrollIndicator = false;
- ShowsHorizontalScrollIndicator = false;
+ ShowsVerticalScrollIndicator = false;
+ ShowsHorizontalScrollIndicator = false;
- ShowsVerticalScrollIndicator = showsVertical;
- ShowsHorizontalScrollIndicator = showsHorizontal;
- }
+ ShowsVerticalScrollIndicator = showsVertical;
+ ShowsHorizontalScrollIndicator = showsHorizontal;
+ }
- if (EffectiveUserInterfaceLayoutDirection == UIUserInterfaceLayoutDirection.RightToLeft)
- {
var horizontalOffset = contentSize.Width - bounds.Width;
-
- if (SystemAdjustedContentInset == UIEdgeInsets.Zero || ContentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentBehavior.Never)
- {
- CrossPlatformLayout?.CrossPlatformArrange(new Rect(new Point(-horizontalOffset, 0), bounds.Size.ToSize()));
- }
- else
- {
- CrossPlatformLayout?.CrossPlatformArrange(new Rect(new Point(-horizontalOffset, 0), bounds.Size.ToSize()));
- }
-
ContentOffset = new CGPoint(horizontalOffset, 0);
-
}
- else if(_previousEffectiveUserInterfaceLayoutDirection is not null)
+ else if (_previousEffectiveUserInterfaceLayoutDirection is not null)
{
ContentOffset = new CGPoint(0, ContentOffset.Y);
}
}
- // When switching between LTR and RTL, we need to re-arrange and offset content exactly once
- // to avoid cumulative shifts or incorrect offsets on subsequent layouts.
+ // Track the current direction so we can detect future changes.
_previousEffectiveUserInterfaceLayoutDirection = EffectiveUserInterfaceLayoutDirection;
return contentSize;
@@ -548,7 +570,7 @@ Size CrossPlatformMeasure(double widthConstraint, double heightConstraint)
///
/// The available size constraints.
/// The size that fits within the constraints.
-
+
public override CGSize SizeThatFits(CGSize size)
{
if (CrossPlatformLayout is null)
@@ -671,6 +693,7 @@ public override void MovedToWindow()
// Clear cached scroll view descendant status since the view hierarchy may have changed
_scrollViewDescendant = null;
+ _parentHandlesSafeArea = null;
// Mark safe area as invalidated since moving to a new window may change safe area
_safeAreaInvalidated = true;
diff --git a/src/Core/src/Platform/iOS/MauiView.cs b/src/Core/src/Platform/iOS/MauiView.cs
index 06df6c168c19..8319ff4246d6 100644
--- a/src/Core/src/Platform/iOS/MauiView.cs
+++ b/src/Core/src/Platform/iOS/MauiView.cs
@@ -68,6 +68,10 @@ public abstract class MauiView : UIView, ICrossPlatformLayoutBacking, IVisualTre
// otherwise, false. Null means not yet determined.
bool? _scrollViewDescendant;
+ // Cached result of whether a parent MauiView is already handling safe area.
+ // Null means not yet determined. Invalidated when view hierarchy changes.
+ bool? _parentHandlesSafeArea;
+
// Keyboard tracking
CGRect _keyboardFrame = CGRect.Empty;
bool _isKeyboardShowing;
@@ -382,6 +386,42 @@ internal static bool IsSoftInputHandledByParent(UIView view)
}
+ ///
+ /// Returns whether this view is currently applying safe area adjustments to its layout.
+ /// Used by descendant views to avoid double-applying safe area when a parent already handles it.
+ ///
+ internal bool AppliesSafeAreaAdjustments => _appliesSafeAreaAdjustments;
+
+ ///
+ /// Checks if any ancestor MauiView is already applying safe area adjustments for the same edges
+ /// that this view handles. When a parent already handles a specific safe area edge, this view
+ /// should not double-apply insets for that edge — but it may still handle OTHER edges independently.
+ /// This prevents double-padding when parent and child handle the same edges (#33595, #32586),
+ /// while allowing parent and child to handle DIFFERENT edges without conflict (#28986).
+ ///
+ bool IsParentHandlingSafeArea()
+ {
+ if (_parentHandlesSafeArea.HasValue)
+ return _parentHandlesSafeArea.Value;
+
+ // Check if any ancestor MauiView handles any of the SAME edges we handle.
+ // Edge-aware check: parent handling only TOP doesn't block child handling BOTTOM.
+ _parentHandlesSafeArea = this.FindParent(x =>
+ {
+ if (x is not MauiView mv || !mv._appliesSafeAreaAdjustments)
+ return false;
+ // Return true only if parent handles any edge that this view also handles
+ for (int edge = 0; edge < 4; edge++)
+ {
+ if (GetSafeAreaRegionForEdge(edge) != SafeAreaRegions.None &&
+ mv.GetSafeAreaRegionForEdge(edge) != SafeAreaRegions.None)
+ return true;
+ }
+ return false;
+ }) is not null;
+ return _parentHandlesSafeArea.Value;
+ }
+
///
/// Checks if the current measure information is still valid for the given constraints.
/// This optimization avoids redundant measure operations when constraints haven't changed.
@@ -610,11 +650,13 @@ bool ValidateSafeArea()
_safeArea = GetAdjustedSafeAreaInsets();
var oldApplyingSafeAreaAdjustments = _appliesSafeAreaAdjustments;
- _appliesSafeAreaAdjustments = RespondsToSafeArea() && !_safeArea.IsEmpty;
+ _appliesSafeAreaAdjustments = !IsParentHandlingSafeArea() && RespondsToSafeArea() && !_safeArea.IsEmpty;
- // Return whether the way safe area interacts with our view has changed
+ // Return whether the way safe area interacts with our view has changed.
+ // Compare at device-pixel resolution to filter sub-pixel noise from animations
+ // that would otherwise trigger infinite layout invalidation cycles (#32586, #33934).
return oldApplyingSafeAreaAdjustments == _appliesSafeAreaAdjustments &&
- (oldSafeArea == _safeArea || !_appliesSafeAreaAdjustments);
+ (oldSafeArea.EqualsAtPixelLevel(_safeArea) || !_appliesSafeAreaAdjustments);
}
///
@@ -702,9 +744,20 @@ event EventHandler? IUIViewLifeCycleEvents.MovedToWindow
public override void SafeAreaInsetsDidChange()
{
_safeAreaInvalidated = true;
+ _parentHandlesSafeArea = null;
base.SafeAreaInsetsDidChange();
}
+ ///
+ /// Directly invalidates this view's safe area, forcing re-evaluation on next layout pass.
+ ///
+ internal void InvalidateSafeArea()
+ {
+ _safeAreaInvalidated = true;
+ _parentHandlesSafeArea = null;
+ SetNeedsLayout();
+ }
+
///
/// Called when this view is moved to a window (added to or removed from the view hierarchy).
/// This triggers safe area invalidation and any pending ancestor measure invalidations.
@@ -714,6 +767,7 @@ public override void MovedToWindow()
base.MovedToWindow();
_scrollViewDescendant = null;
+ _parentHandlesSafeArea = null;
// Notify any subscribers that this view has been moved to a window
_movedToWindow?.Invoke(this, EventArgs.Empty);
diff --git a/src/Core/src/Platform/iOS/SafeAreaPadding.cs b/src/Core/src/Platform/iOS/SafeAreaPadding.cs
index 5799b4f9a97b..1a1f36a3c3c5 100644
--- a/src/Core/src/Platform/iOS/SafeAreaPadding.cs
+++ b/src/Core/src/Platform/iOS/SafeAreaPadding.cs
@@ -28,6 +28,23 @@ public CGRect InsetRect(CGRect bounds)
public CGRect ToCGRect() =>
new((nfloat)Top, (nfloat)Left, (nfloat)Bottom, (nfloat)Right);
+
+ ///
+ /// Compares two SafeAreaPadding values at device-pixel resolution.
+ /// Sub-pixel differences (e.g., 0.001pt from animation noise) that map to the same
+ /// physical pixel are treated as equal, preventing unnecessary layout invalidation cycles.
+ ///
+ public bool EqualsAtPixelLevel(SafeAreaPadding other)
+ {
+ var scale = (double)UIScreen.MainScreen.Scale;
+ return RoundToPixel(Left, scale) == RoundToPixel(other.Left, scale)
+ && RoundToPixel(Right, scale) == RoundToPixel(other.Right, scale)
+ && RoundToPixel(Top, scale) == RoundToPixel(other.Top, scale)
+ && RoundToPixel(Bottom, scale) == RoundToPixel(other.Bottom, scale);
+ }
+
+ static double RoundToPixel(double value, double scale)
+ => Math.Round(value * scale, MidpointRounding.AwayFromZero);
}
internal static class SafeAreaInsetsExtensions