diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index b8f6e9a890a0..d0d99c2b8373 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -138,6 +138,26 @@ When working with public API changes:
- **Use `dotnet format analyzers`** if having trouble
- **If files are incorrect**: Revert all changes, then add only the necessary new API entries
+**π¨ CRITICAL: `#nullable enable` must be line 1**
+
+Every `PublicAPI.Unshipped.txt` file starts with `#nullable enable` (often BOM-prefixed: `ο»Ώ#nullable enable`) on the **first line**. If this line is moved or removed, the analyzer treats it as a declared API symbol and emits **RS0017** errors.
+
+**Never sort these files with plain `sort`** β the BOM bytes (`0xEF 0xBB 0xBF`) sort after ASCII characters under `LC_ALL=C`, pushing `#nullable enable` to the bottom of the file.
+
+When resolving merge conflicts or adding entries, use this safe pattern that preserves line 1:
+```bash
+for f in $(git diff --name-only --diff-filter=U | grep "PublicAPI.Unshipped.txt"); do
+ # Extract and preserve the #nullable enable line (with or without BOM)
+ HEADER=$(head -1 "$f" | grep -o '.*#nullable enable' || echo '#nullable enable')
+ # Strip conflict markers, remove all #nullable lines, sort+dedup the API entries
+ grep -v '^<<<<<<\|^======\|^>>>>>>\|#nullable enable' "$f" | LC_ALL=C sort -u | sed '/^$/d' > /tmp/api_fix.txt
+ # Reassemble: header first, then sorted entries
+ printf '%s\n' "$HEADER" > "$f"
+ cat /tmp/api_fix.txt >> "$f"
+ git add "$f"
+done
+```
+
### Branching
- `main` - For bug fixes without API changes
- `net10.0` - For new features and API changes
@@ -181,10 +201,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/eng/pipelines/ci-device-tests.yml b/eng/pipelines/ci-device-tests.yml
index 1547831fb642..d33cf5b4e7e4 100644
--- a/eng/pipelines/ci-device-tests.yml
+++ b/eng/pipelines/ci-device-tests.yml
@@ -5,6 +5,7 @@ trigger:
- release/*
- net*.0
- inflight/*
+ - darc-*
tags:
include:
- '*'
diff --git a/eng/pipelines/ci-uitests.yml b/eng/pipelines/ci-uitests.yml
index c3b6ace38f43..7e16e1733d75 100644
--- a/eng/pipelines/ci-uitests.yml
+++ b/eng/pipelines/ci-uitests.yml
@@ -5,6 +5,7 @@ trigger:
- release/*
- net*.0
- inflight/*
+ - darc-*
tags:
include:
- '*'
diff --git a/eng/pipelines/ci.yml b/eng/pipelines/ci.yml
index 58b1ba5dbb67..bc962be9a192 100644
--- a/eng/pipelines/ci.yml
+++ b/eng/pipelines/ci.yml
@@ -281,7 +281,25 @@ stages:
timeout: 120
testCategory: MultiProject
- # TODO: macOSTemplates and AOT template categories
+ # TODO: macOSTemplates category
+
+ - name: win_aot_tests
+ ${{ if eq(variables['Build.DefinitionName'], 'maui-pr') }}:
+ pool: ${{ parameters.WindowsPool.public }}
+ runAsPublic: true
+ ${{ else }}:
+ pool: ${{ parameters.WindowsPool.internal }}
+ runAsPublic: false
+ timeout: 120
+ testCategory: AOT
+ - name: mac_aot_tests
+ ${{ if eq(variables['Build.DefinitionName'], 'maui-pr') }}:
+ pool: ${{ parameters.MacOSPool.public }}
+ ${{ else }}:
+ pool: ${{ parameters.MacOSPool.internal }}
+ timeout: 240
+ testCategory: AOT
+
- name: mac_runandroid_tests
${{ if eq(variables['Build.DefinitionName'], 'maui-pr') }}:
pool: ${{ parameters.AndroidPoolLinux }}
diff --git a/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets b/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets
index 6eee58090437..0059c276f1a6 100644
--- a/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets
+++ b/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets
@@ -358,6 +358,20 @@
+
+
+
+
+
+
+
false
diff --git a/src/Controls/src/Core/Platform/Android/MultiPageFragmentStateAdapter.cs b/src/Controls/src/Core/Platform/Android/MultiPageFragmentStateAdapter.cs
index d2d1ce913f74..9d93a163817f 100644
--- a/src/Controls/src/Core/Platform/Android/MultiPageFragmentStateAdapter.cs
+++ b/src/Controls/src/Core/Platform/Android/MultiPageFragmentStateAdapter.cs
@@ -7,11 +7,7 @@
namespace Microsoft.Maui.Controls.Platform
{
- internal class MultiPageFragmentStateAdapter<[DynamicallyAccessedMembers(BindableProperty.DeclaringTypeMembers
-#if NET8_0 // IL2091
- | BindableProperty.ReturnTypeMembers
-#endif
- )] T> : FragmentStateAdapter where T : Page
+ internal class MultiPageFragmentStateAdapter<[DynamicallyAccessedMembers(BindableProperty.DeclaringTypeMembers | BindableProperty.ReturnTypeMembers)] T> : FragmentStateAdapter where T : Page
{
MultiPage _page;
readonly IMauiContext _context;
diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/LabelNotTruncatedWithMaxLines.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/LabelNotTruncatedWithMaxLines.png
new file mode 100644
index 000000000000..dbeca9afe4c7
Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/LabelNotTruncatedWithMaxLines.png differ
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/Issue34120.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue34120.cs
new file mode 100644
index 000000000000..5b083542ab64
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue34120.cs
@@ -0,0 +1,81 @@
+namespace Maui.Controls.Sample.Issues;
+
+[Issue(IssueTracker.Github, 34120, "Label text truncated in ScrollView when MaxLines is set", PlatformAffected.Android)]
+public class Issue34120 : ContentPage
+{
+ // Reproduces the N3_Navigation layout: horizontal ScrollView with BindableLayout,
+ // 200Γ200 Border cards, Image (HeightRequest=120), and a Label with MaxLines=2.
+ record Issue34120MonkeyItem(string Name, string ImageUrl);
+
+ public Issue34120()
+ {
+ // Long names ("Golden Snub-nosed Monkey", "Tonkin Snub-nosed Monkey") are the ones
+ // that triggered truncation; "Baboon" is a short-name reference card.
+ var monkeys = new List
+ {
+ new("Golden Snub-nosed Monkey", "golden.jpg"),
+ new("Baboon", "papio.jpg"),
+ new("Tonkin Snub-nosed Monkey", "bluemonkey.jpg"),
+ new("Howler Monkey", "alouatta.jpg"),
+ new("Squirrel Monkey", "saimiri.jpg"),
+ };
+
+ var itemTemplate = new DataTemplate(() =>
+ {
+ var image = new Image
+ {
+ Aspect = Aspect.AspectFit,
+ HeightRequest = 120,
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Center,
+ };
+ image.SetBinding(Image.SourceProperty, "ImageUrl");
+
+ var nameLabel = new Label
+ {
+ FontSize = 14,
+ FontAttributes = FontAttributes.Bold,
+ BackgroundColor = Color.FromArgb("#AAFFFFFF"),
+ TextColor = Colors.Black,
+ Padding = new Thickness(4, 2),
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Center,
+ HorizontalTextAlignment = TextAlignment.Center,
+ LineBreakMode = LineBreakMode.WordWrap,
+ MaxLines = 2,
+ };
+ nameLabel.SetBinding(Label.TextProperty, "Name");
+ nameLabel.SetBinding(Label.AutomationIdProperty, "Name");
+
+ var card = new Border
+ {
+ Padding = new Thickness(10),
+ Stroke = Colors.LightGray,
+ StrokeThickness = 1,
+ WidthRequest = 200,
+ HeightRequest = 200,
+ BackgroundColor = Colors.White,
+ Content = new VerticalStackLayout
+ {
+ Spacing = 10,
+ Children = { image, nameLabel }
+ }
+ };
+ return card;
+ });
+
+ var horizontalStack = new HorizontalStackLayout
+ {
+ Spacing = 15,
+ Padding = new Thickness(5),
+ };
+ BindableLayout.SetItemTemplate(horizontalStack, itemTemplate);
+ BindableLayout.SetItemsSource(horizontalStack, monkeys);
+
+ Content = new ScrollView
+ {
+ Orientation = ScrollOrientation.Horizontal,
+ Content = horizontalStack,
+ };
+ }
+}
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.Mac.Tests/snapshots/mac/LabelNotTruncatedWithMaxLines.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/LabelNotTruncatedWithMaxLines.png
new file mode 100644
index 000000000000..b87fca4e362c
Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/LabelNotTruncatedWithMaxLines.png differ
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/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34120.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34120.cs
new file mode 100644
index 000000000000..3635c562e89d
--- /dev/null
+++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34120.cs
@@ -0,0 +1,21 @@
+using NUnit.Framework;
+using UITest.Appium;
+using UITest.Core;
+
+namespace Microsoft.Maui.TestCases.Tests.Issues;
+
+public class Issue34120 : _IssuesUITest
+{
+ public override string Issue => "Label text truncated in ScrollView when MaxLines is set";
+
+ public Issue34120(TestDevice device) : base(device) { }
+
+ [Test]
+ [Category(UITestCategories.Label)]
+ public void LabelNotTruncatedWithMaxLines()
+ {
+ // Wait for the page to load, then verify labels are not truncated.
+ App.WaitForElement("Golden Snub-nosed Monkey");
+ VerifyScreenshot();
+ }
+}
diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/LabelNotTruncatedWithMaxLines.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/LabelNotTruncatedWithMaxLines.png
new file mode 100644
index 000000000000..845d3f890d05
Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/LabelNotTruncatedWithMaxLines.png differ
diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LabelNotTruncatedWithMaxLines.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LabelNotTruncatedWithMaxLines.png
new file mode 100644
index 000000000000..b70efeacc5f7
Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LabelNotTruncatedWithMaxLines.png differ
diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/LabelNotTruncatedWithMaxLines.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/LabelNotTruncatedWithMaxLines.png
new file mode 100644
index 000000000000..85976d230f66
Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/LabelNotTruncatedWithMaxLines.png differ
diff --git a/src/Core/src/Handlers/Label/LabelHandler.Android.cs b/src/Core/src/Handlers/Label/LabelHandler.Android.cs
index d70b114984d7..e60e9017d408 100644
--- a/src/Core/src/Handlers/Label/LabelHandler.Android.cs
+++ b/src/Core/src/Handlers/Label/LabelHandler.Android.cs
@@ -22,9 +22,12 @@ public override Size GetDesiredSize(double widthConstraint, double heightConstra
// Android TextView reports full available width instead of actual text width when
// text wraps to multiple lines, causing incorrect positioning for non-Fill alignments.
+ // We narrow the desired width to the widest rendered line, but only when that narrowing
+ // won't cause re-wrapping that exceeds MaxLines and truncates visible text.
if (VirtualView.HorizontalLayoutAlignment != Primitives.LayoutAlignment.Fill &&
PlatformView?.Layout is Layout layout &&
- layout.LineCount > 1)
+ layout.LineCount > 1 &&
+ PlatformView.Ellipsize == null)
{
float maxLineWidth = 0;
for (int i = 0; i < layout.LineCount; i++)
@@ -38,7 +41,33 @@ public override Size GetDesiredSize(double widthConstraint, double heightConstra
{
var actualWidth = Context.FromPixels(maxLineWidth + PlatformView.PaddingLeft + PlatformView.PaddingRight);
if (actualWidth < size.Width)
+ {
+ // When MaxLines is constrained, verify that narrowing doesn't cause the text
+ // to re-wrap into more lines than MaxLines allows (which would truncate text).
+ // Re-measure at exactly the pixel width the view will be arranged at.
+ if (PlatformView.MaxLines != int.MaxValue)
+ {
+ var narrowedPx = (int)Context.ToPixels(actualWidth);
+
+ // AtMost mirrors how the layout pass constrains width, ensuring the
+ // re-measurement reflects the same wrapping behaviour the view will
+ // experience when arranged at actualWidth.
+ PlatformView.Measure(
+ MeasureSpecMode.AtMost.MakeMeasureSpec(narrowedPx),
+ MeasureSpecMode.Unspecified.MakeMeasureSpec(0));
+
+ // Fail-safe: if Layout is null after re-measurement we cannot verify
+ // that truncation won't occur, so return the original size.
+ var measuredLayout = PlatformView.Layout;
+
+ if (measuredLayout is null || measuredLayout.LineCount > PlatformView.MaxLines)
+ {
+ return size; // Narrowing causes truncation (or unverifiable); return original size
+ }
+ }
+
return new Size(actualWidth, size.Height);
+ }
}
}
diff --git a/src/Core/src/Handlers/TimePicker/TimePickerHandler.Android.cs b/src/Core/src/Handlers/TimePicker/TimePickerHandler.Android.cs
index 3cbf4126f033..b67518027ae6 100644
--- a/src/Core/src/Handlers/TimePicker/TimePickerHandler.Android.cs
+++ b/src/Core/src/Handlers/TimePicker/TimePickerHandler.Android.cs
@@ -156,7 +156,23 @@ void OnDialogDismiss(object? sender, EventArgs e)
HidePickerDialog();
}
- bool Use24HourView => VirtualView != null && (DateFormat.Is24HourFormat(PlatformView?.Context)
- && VirtualView.Format == "t" || VirtualView.Format == "HH:mm");
+ // "HH" (uppercase) is the .NET 24-hour specifier; "hh" (lowercase) is 12-hour.
+ // Case-sensitive Ordinal comparison is required to distinguish between them.
+ internal static bool IsCustom24HourFormat(string? format) =>
+ !string.IsNullOrEmpty(format) && format.Contains("HH", StringComparison.Ordinal);
+
+ bool Use24HourView
+ {
+ get
+ {
+ if (VirtualView is null || string.IsNullOrEmpty(VirtualView.Format))
+ return false;
+
+ if (VirtualView.Format == "t")
+ return DateFormat.Is24HourFormat(PlatformView?.Context);
+
+ return IsCustom24HourFormat(VirtualView.Format);
+ }
+ }
}
}
\ No newline at end of file
diff --git a/src/Core/src/Platform/Android/PlatformTouchGraphicsView.cs b/src/Core/src/Platform/Android/PlatformTouchGraphicsView.cs
index 04e5e5e30703..16744f1a48d6 100644
--- a/src/Core/src/Platform/Android/PlatformTouchGraphicsView.cs
+++ b/src/Core/src/Platform/Android/PlatformTouchGraphicsView.cs
@@ -89,7 +89,7 @@ public void TouchesMoved(PointF[] points)
{
if (!_dragStarted)
{
- if (points.Length == 1)
+ if (points.Length == 1 && _lastMovedViewPoints.Length > 0)
{
float deltaX = _lastMovedViewPoints[0].X - points[0].X;
float deltaY = _lastMovedViewPoints[0].Y - points[0].Y;
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
diff --git a/src/Core/tests/DeviceTests/Handlers/TimePicker/TimePickerHandlerTests.Android.cs b/src/Core/tests/DeviceTests/Handlers/TimePicker/TimePickerHandlerTests.Android.cs
index f08bf9b2a9bf..46e2fea7994b 100644
--- a/src/Core/tests/DeviceTests/Handlers/TimePicker/TimePickerHandlerTests.Android.cs
+++ b/src/Core/tests/DeviceTests/Handlers/TimePicker/TimePickerHandlerTests.Android.cs
@@ -11,6 +11,24 @@ namespace Microsoft.Maui.DeviceTests
{
public partial class TimePickerHandlerTests
{
+ [Theory(DisplayName = "IsCustom24HourFormat detects HH patterns correctly")]
+ [InlineData("HH:mm", true)]
+ [InlineData("HH:mm:ss", true)]
+ [InlineData("HH.mm", true)]
+ [InlineData("HH-mm-ss", true)]
+ [InlineData("hh:mm", false)]
+ [InlineData("hh:mm tt", false)]
+ [InlineData("h:mm", false)]
+ [InlineData("H:mm", false)]
+ [InlineData("t", false)]
+ [InlineData("T", false)]
+ [InlineData("", false)]
+ [InlineData(null, false)]
+ public void IsCustom24HourFormatDetectsCorrectly(string format, bool expected)
+ {
+ Assert.Equal(expected, TimePickerHandler.IsCustom24HourFormat(format));
+ }
+
[Fact(DisplayName = "CharacterSpacing Initializes Correctly")]
public async Task CharacterSpacingInitializesCorrectly()
{
diff --git a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/AOTTemplateTest.cs b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/AOTTemplateTest.cs
index d571dd125081..8ded0f18f133 100644
--- a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/AOTTemplateTest.cs
+++ b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/AOTTemplateTest.cs
@@ -1,4 +1,4 @@
-ο»Ώnamespace Microsoft.Maui.IntegrationTests;
+namespace Microsoft.Maui.IntegrationTests;
[Trait("Category", "AOT")]
public class AOTTemplateTest : BaseTemplateTests
@@ -6,6 +6,8 @@ public class AOTTemplateTest : BaseTemplateTests
public AOTTemplateTest(IntegrationTestFixture fixture, ITestOutputHelper output) : base(fixture, output) { }
[Theory]
+ [InlineData("maui", $"{DotNetCurrent}-android", "android-arm64")]
+ [InlineData("maui", $"{DotNetCurrent}-android", "android-x64")]
[InlineData("maui", $"{DotNetCurrent}-ios", "ios-arm64")]
[InlineData("maui", $"{DotNetCurrent}-ios", "iossimulator-arm64")]
[InlineData("maui", $"{DotNetCurrent}-ios", "iossimulator-x64")]
@@ -18,6 +20,7 @@ public void PublishNativeAOT(string id, string framework, string runtimeIdentifi
SetTestIdentifier(id, framework, runtimeIdentifier);
bool isWindowsFramework = framework.Contains("windows", StringComparison.OrdinalIgnoreCase);
bool isApplePlatform = framework.Contains("ios", StringComparison.OrdinalIgnoreCase) || framework.Contains("maccatalyst", StringComparison.OrdinalIgnoreCase);
+ bool isAndroidPlatform = framework.Contains("android", StringComparison.OrdinalIgnoreCase);
if (isApplePlatform && !TestEnvironment.IsMacOS)
if (true) return; // Skip: "Publishing a MAUI iOS/macOS app with NativeAOT is only supported on a host MacOS system."
@@ -31,17 +34,41 @@ public void PublishNativeAOT(string id, string framework, string runtimeIdentifi
Assert.True(DotnetInternal.New(id, projectDir, DotNetCurrent, output: _output),
$"Unable to create template {id}. Check test output for errors.");
- var extendedBuildProps = isWindowsFramework ? PrepareNativeAotBuildPropsWindows(runtimeIdentifier) : PrepareNativeAotBuildProps();
+ // For Android-only builds on Linux, modify the csproj to only target Android
+ // This avoids restore failures due to missing iOS/macCatalyst workloads
+ if (isAndroidPlatform && !TestEnvironment.IsMacOS && !TestEnvironment.IsWindows)
+ {
+ OnlyAndroid(projectFile);
+ }
+
+ var extendedBuildProps = isWindowsFramework
+ ? PrepareNativeAotBuildPropsWindows(runtimeIdentifier)
+ : isAndroidPlatform
+ ? PrepareNativeAotBuildPropsAndroid()
+ : PrepareNativeAotBuildProps();
+
+ // Disable code signing for Apple platforms (no signing certificate available in CI)
+ if (isApplePlatform)
+ {
+ AddNoCodeSigningProps(extendedBuildProps);
+ }
string binLogFilePath = $"publish-{DateTime.UtcNow.ToFileTimeUtc()}.binlog";
Assert.True(DotnetInternal.Build(projectFile, "Release", framework: framework, properties: extendedBuildProps, runtimeIdentifier: runtimeIdentifier, binlogPath: binLogFilePath, output: _output),
$"Project {Path.GetFileName(projectFile)} failed to build. Check test output/attachments for errors.");
var actualWarnings = BuildWarningsUtilities.ReadNativeAOTWarningsFromBinLog(binLogFilePath);
- actualWarnings.AssertNoWarnings();
+ var expectedWarnings = isAndroidPlatform
+ ? BuildWarningsUtilities.ExpectedNativeAOTWarningsAndroid
+ : isWindowsFramework
+ ? BuildWarningsUtilities.ExpectedNativeAOTWarningsWindows
+ : BuildWarningsUtilities.ExpectedNativeAOTWarnings;
+ actualWarnings.AssertWarnings(expectedWarnings);
}
[Theory]
+ [InlineData("maui", $"{DotNetCurrent}-android", "android-arm64")]
+ [InlineData("maui", $"{DotNetCurrent}-android", "android-x64")]
[InlineData("maui", $"{DotNetCurrent}-ios", "ios-arm64")]
[InlineData("maui", $"{DotNetCurrent}-ios", "iossimulator-arm64")]
[InlineData("maui", $"{DotNetCurrent}-ios", "iossimulator-x64")]
@@ -54,6 +81,7 @@ public void PublishNativeAOTRootAllMauiAssemblies(string id, string framework, s
// This test follows the following guide: https://devblogs.microsoft.com/dotnet/creating-aot-compatible-libraries/#publishing-a-test-application-for-aot
bool isWindowsFramework = framework.Contains("windows", StringComparison.OrdinalIgnoreCase);
bool isApplePlatform = framework.Contains("ios", StringComparison.OrdinalIgnoreCase) || framework.Contains("maccatalyst", StringComparison.OrdinalIgnoreCase);
+ bool isAndroidPlatform = framework.Contains("android", StringComparison.OrdinalIgnoreCase);
if (isApplePlatform && !TestEnvironment.IsMacOS)
if (true) return; // Skip: "Publishing a MAUI iOS/macOS app with NativeAOT is only supported on a host MacOS system."
@@ -67,7 +95,25 @@ public void PublishNativeAOTRootAllMauiAssemblies(string id, string framework, s
Assert.True(DotnetInternal.New(id, projectDir, DotNetCurrent, output: _output),
$"Unable to create template {id}. Check test output for errors.");
- var extendedBuildProps = isWindowsFramework ? PrepareNativeAotBuildPropsWindows(runtimeIdentifier) : PrepareNativeAotBuildProps();
+ // For Android-only builds on Linux, modify the csproj to only target Android
+ // This avoids restore failures due to missing iOS/macCatalyst workloads
+ if (isAndroidPlatform && !TestEnvironment.IsMacOS && !TestEnvironment.IsWindows)
+ {
+ OnlyAndroid(projectFile);
+ }
+
+ var extendedBuildProps = isWindowsFramework
+ ? PrepareNativeAotBuildPropsWindows(runtimeIdentifier)
+ : isAndroidPlatform
+ ? PrepareNativeAotBuildPropsAndroid()
+ : PrepareNativeAotBuildProps();
+
+ // Disable code signing for Apple platforms (no signing certificate available in CI)
+ if (isApplePlatform)
+ {
+ AddNoCodeSigningProps(extendedBuildProps);
+ }
+
FileUtilities.ReplaceInFile(projectFile,
"",
"""
@@ -95,9 +141,11 @@ public void PublishNativeAOTRootAllMauiAssemblies(string id, string framework, s
$"Project {Path.GetFileName(projectFile)} failed to build. Check test output/attachments for errors.");
var actualWarnings = BuildWarningsUtilities.ReadNativeAOTWarningsFromBinLog(binLogFilePath);
- var expectedWarnings = isWindowsFramework && BuildWarningsUtilities.ExpectedNativeAOTWarningsWindows != null
- ? BuildWarningsUtilities.ExpectedNativeAOTWarningsWindows
- : BuildWarningsUtilities.ExpectedNativeAOTWarnings;
+ var expectedWarnings = isAndroidPlatform
+ ? BuildWarningsUtilities.ExpectedNativeAOTWarningsAndroid
+ : isWindowsFramework
+ ? BuildWarningsUtilities.ExpectedNativeAOTWarningsWindows
+ : BuildWarningsUtilities.ExpectedNativeAOTWarnings;
actualWarnings.AssertWarnings(expectedWarnings);
}
@@ -109,8 +157,7 @@ private List PrepareNativeAotBuildProps()
"PublishAotUsingRuntimePack=true", // TODO: This parameter will become obsolete https://github.com/dotnet/runtime/issues/87060 in net9
"_IsPublishing=true", // This makes 'dotnet build -r iossimulator-x64' equivalent to 'dotnet publish -r iossimulator-x64'
"IlcTreatWarningsAsErrors=false",
- "TrimmerSingleWarn=false",
- "_RequireCodeSigning=false" // This is required to build the iOS app without a signing key
+ "TrimmerSingleWarn=false"
};
return extendedBuildProps;
}
@@ -132,4 +179,37 @@ private List PrepareNativeAotBuildPropsWindows(string runtimeIdentifier)
};
return extendedBuildProps;
}
+
+ private List PrepareNativeAotBuildPropsAndroid()
+ {
+ var extendedBuildProps = new List(BuildProps)
+ {
+ "PublishAot=true",
+ "PublishAotUsingRuntimePack=true",
+ "_IsPublishing=true",
+ "IlcTreatWarningsAsErrors=false",
+ "TrimmerSingleWarn=false"
+ };
+
+ var ndkRoot = Environment.GetEnvironmentVariable("ANDROID_NDK_ROOT");
+ if (!string.IsNullOrEmpty(ndkRoot))
+ {
+ // Quote and escape the NDK path to avoid argument splitting when it contains spaces.
+ var ndkRootEscaped = ndkRoot.Replace("\"", "\\\"", StringComparison.Ordinal);
+ extendedBuildProps.Add($"AndroidNdkDirectory=\"{ndkRootEscaped}\"");
+ }
+
+ return extendedBuildProps;
+ }
+
+ ///
+ /// Adds properties to disable code signing for Apple platforms.
+ /// This is required when building without a signing certificate (e.g., in CI environments).
+ ///
+ private static void AddNoCodeSigningProps(List buildProps)
+ {
+ buildProps.Add("EnableCodeSigning=false");
+ buildProps.Add("_RequireCodeSigning=false");
+ }
+
}
diff --git a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/Utilities/BuildWarningsUtilities.cs b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/Utilities/BuildWarningsUtilities.cs
index 707bde08350f..50ec5e3c3033 100644
--- a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/Utilities/BuildWarningsUtilities.cs
+++ b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/Utilities/BuildWarningsUtilities.cs
@@ -1,4 +1,4 @@
-ο»Ώusing System.IO;
+using System.IO;
using System.Linq;
using Microsoft.Build.Framework;
using Microsoft.Build.Logging.StructuredLogger;
@@ -172,6 +172,44 @@ public static void AssertWarnings(this List actualWarnings, Lis
// These might be different from iOS/Mac warnings due to platform-specific implementations
private static readonly List expectedNativeAOTWarningsWindows = new();
+ // Android baseline warnings to ensure no new warnings are introduced
+ private static readonly List expectedNativeAOTWarningsAndroid = new()
+ {
+ new WarningsPerFile
+ {
+ File = "Xamarin.Android.Common.targets",
+ WarningsPerCode = new List
+ {
+ new WarningsPerCode
+ {
+ Code = "XA1040",
+ Messages = new List
+ {
+ "The NativeAOT runtime on Android is an experimental feature and not yet suitable for production use. File issues at: https://github.com/dotnet/android/issues",
+ }
+ },
+ }
+ },
+ new WarningsPerFile
+ {
+ File = "ILC",
+ WarningsPerCode = new List
+ {
+ new WarningsPerCode
+ {
+ Code = "IL3050",
+ Messages = new List
+ {
+ "Microsoft.Android.Runtime.ManagedTypeManager.g__MakeGenericType|4_1(Type,Type[]): Using member 'System.Type.MakeGenericType(Type[])' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. The native code for this instantiation might not be available at runtime.",
+ "Android.Runtime.JNIEnv.MakeArrayType(Type): Using member 'System.Type.MakeArrayType()' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. The code for an array of the specified type might not be available.",
+ "Android.Runtime.JNINativeWrapper.CreateDelegate(Delegate): Using member 'System.Reflection.Emit.DynamicMethod.DynamicMethod(String,Type,Type[],Type,Boolean)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. Creating a DynamicMethod requires dynamic code.",
+ "Java.Interop.JavaConvert.g__MakeGenericType|2_0(Type,Type[]): Using member 'System.Type.MakeGenericType(Type[])' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. The native code for this instantiation might not be available at runtime.",
+ }
+ },
+ }
+ },
+ };
+
public static List ExpectedNativeAOTWarnings
{
get => expectedNativeAOTWarnings;
@@ -182,6 +220,11 @@ public static List ExpectedNativeAOTWarningsWindows
get => expectedNativeAOTWarningsWindows;
}
+ public static List ExpectedNativeAOTWarningsAndroid
+ {
+ get => expectedNativeAOTWarningsAndroid;
+ }
+
#region Utility methods for generating the list of expected warnings
// Use this method to regenerate warnings found in a .binlog file at 'binLogFilePath'.