Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b4dddab
[iOS] Fix infinite layout cycle with pixel-level comparison + window …
github-actions[bot] Feb 26, 2026
ecb824e
Add UITests for SafeArea infinite layout cycle fixes
github-actions[bot] Feb 26, 2026
d5c54b8
Address review: simplify RoundToPixel, restrict iOS-only tests
github-actions[bot] Mar 3, 2026
4ad996b
Generalize SafeAreaEdges tests to work on Android
github-actions[bot] Mar 3, 2026
a16059b
Fix macCatalyst safe area double-padding when custom TitleBar reposit…
github-actions[bot] Mar 4, 2026
410172b
Add safe area debugging instructions from PR #34024 lessons
github-actions[bot] Mar 4, 2026
9e96d0e
Replace Window Guard with IsParentHandlingSafeArea to fix infinite la…
github-actions[bot] Mar 4, 2026
eb2be69
Make IsParentHandlingSafeArea edge-aware to fix parent/child independ…
github-actions[bot] Mar 4, 2026
2b3ee3e
Update stale comments in ViewHandler.iOS.cs to reflect parent hierarc…
github-actions[bot] Mar 4, 2026
f4f41a0
Remove untested InvalidateDescendantSafeAreas from ViewHandler.iOS.cs
github-actions[bot] Mar 4, 2026
859bd86
Fix filename typo, update PR description, and update instruction docs
github-actions[bot] Mar 4, 2026
40f94e5
Trim instruction files: remove README.md, slim safe-area-debugging to…
github-actions[bot] Mar 4, 2026
3824dfe
Condense safe-area-debugging instructions for minimal token usage
github-actions[bot] Mar 4, 2026
b7ad25c
Scope safe-area instructions to iOS/macCatalyst in title
github-actions[bot] Mar 4, 2026
4720d7e
Rename safe-area instructions to ios-specific, tighten applyTo
github-actions[bot] Mar 4, 2026
d86e439
Fix RTL ScrollView: remove negative-offset arrange, rely on iOS nativ…
github-actions[bot] Mar 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,17 @@ git commit -m "Fix: Description of the change"
2. Exception: If the user's instructions explicitly include pushing, proceed without asking.

### Documentation

- Update XML documentation for public APIs
- Follow existing code documentation patterns
- Update relevant docs in `docs/` folder when needed

**Platform-Specific Documentation:**
- `.github/instructions/safe-area-ios.instructions.md` - Safe area investigation (iOS/macCatalyst)
- `.github/instructions/uitests.instructions.md` - UI test guidelines (includes safe area testing section)
- `.github/instructions/android.instructions.md` - Android handler implementation
- `.github/instructions/xaml-unittests.instructions.md` - XAML unit test guidelines

### Opening PRs

All PRs are required to have this at the top of the description:
Expand Down
34 changes: 34 additions & 0 deletions .github/instructions/safe-area-ios.instructions.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions .github/instructions/uitests.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Maui.Controls.Sample.Issues.Issue28986_ParentChildTest"
Title="Issue28986 - Parent Child SafeArea">

<Grid x:Name="ParentGrid"
BackgroundColor="#FFEB3B"
SafeAreaEdges="None,Container,None,None"
AutomationId="ParentGrid">

<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>

<!-- Top Indicator -->
<Label Grid.Row="0"
Text="↑ Parent handles TOP safe area ↑"
BackgroundColor="#FF5722"
TextColor="White"
HorizontalTextAlignment="Center"
AutomationId="TopIndicator"/>

<!-- Middle Content Area -->
<VerticalStackLayout Grid.Row="1"
BackgroundColor="#00BCD4"
VerticalOptions="Center"
Spacing="20"
Padding="20">

<Label Text="SafeAreaEdges Independent Handling Demo"
FontSize="20"
FontAttributes="Bold"
HorizontalTextAlignment="Center"
AutomationId="TitleLabel"/>

<Label x:Name="StatusLabel"
Text="Parent: Top=Container, Bottom=None | Child: Bottom=Container"
HorizontalTextAlignment="Center"
AutomationId="StatusLabel"/>

<Button Text="Toggle Parent Top SafeArea"
Clicked="OnToggleParentTop"
HorizontalOptions="Center"
AutomationId="ToggleParentTopButton"/>

<Button Text="Toggle Parent Bottom SafeArea"
Clicked="OnToggleParentBottom"
HorizontalOptions="Center"
AutomationId="ToggleParentBottomButton"/>

<Button Text="Toggle Child Bottom SafeArea"
Clicked="OnToggleChildBottom"
HorizontalOptions="Center"
AutomationId="ToggleChildBottomButton"/>

<Label Text="Expected behavior:"
FontAttributes="Bold"
Margin="0,20,0,0"
AutomationId="ExpectedLabel"/>

<Label Text="• Top indicator stays below notch/status bar (parent handles top)&#x0a;• Bottom indicator stays above home indicator (child handles bottom)&#x0a;• Both work INDEPENDENTLY - no conflict!&#x0a;• Runtime changes to parent do NOT disrupt child's handling&#x0a;• NO DOUBLE PADDING when both parent and child handle same edge"
FontSize="12"
AutomationId="ExpectedDetailsLabel"/>

</VerticalStackLayout>

<!-- Bottom Indicator -->
<Grid Grid.Row="2"
BackgroundColor="#9C27B0"
x:Name="ChildGrid"
SafeAreaEdges="None,None,None,Container"
AutomationId="ChildGrid">

<Label Text="↓ Child handles BOTTOM safe area ↓"
BackgroundColor="#8BC34A"
HorizontalTextAlignment="Center"
AutomationId="BottomIndicator"/>

</Grid>
</Grid>
</ContentPage>
Original file line number Diff line number Diff line change
@@ -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}";
}
}
Loading
Loading