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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
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
1 change: 1 addition & 0 deletions eng/pipelines/ci-device-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ trigger:
- release/*
- net*.0
- inflight/*
- darc-*
tags:
include:
- '*'
Expand Down
1 change: 1 addition & 0 deletions eng/pipelines/ci-uitests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ trigger:
- release/*
- net*.0
- inflight/*
- darc-*
tags:
include:
- '*'
Expand Down
20 changes: 19 additions & 1 deletion eng/pipelines/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,20 @@
</ItemGroup>
</Target>

<!--
Workaround for Android SDK bug: Microsoft.Android.Sdk.ILLink.targets uses %(RootMode) without
fully qualifying it as %(TrimmerRootAssembly.RootMode), which causes MSB4096 when user-defined
TrimmerRootAssembly items don't have the RootMode metadata.
See: https://github.com/dotnet/android/issues/10758
-->
<Target Name="_MauiFixTrimmerRootAssemblyMetadata"
BeforeTargets="PrepareForILLink"
Condition="'$(UsingAndroidNETSdk)' == 'true'">
<ItemGroup>
<TrimmerRootAssembly Update="@(TrimmerRootAssembly)" Condition="'%(TrimmerRootAssembly.RootMode)' == ''" RootMode="All" />
</ItemGroup>
</Target>

<Target Name="_MauiSetWinUIDefaultsForPublishAot" BeforeTargets="PrepareForBuild" Condition="'$(PublishAot)' == 'true' and '$(_MauiTargetPlatformIsWindows)' == 'True'">
<PropertyGroup>
<DebugSymbols Condition="'$(DebugSymbols)' == ''">false</DebugSymbols>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> _page;
readonly IMauiContext _context;
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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