Skip to content

Commit 7b5118d

Browse files
author
Copilot CI
committed
Harden safe area state transitions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: de9c7c01-82c4-42fd-9ab7-882279aadd15
1 parent 7ea5fcd commit 7b5118d

17 files changed

Lines changed: 366 additions & 123 deletions

File tree

.github/agents/maui-expert-reviewer.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ Safe area adjustments, keyboard insets, and ancestor hierarchy walks. See `safe-
286286
- CHECK: Prefer a capability-oriented contract such as `ISafeAreaElement` over publishing a numbered interface
287287

288288
#### Platform notes
289-
- **iOS/MacCatalyst**: See `safe-area-ios.instructions.md` for `IsParentHandlingSafeArea`, `EqualsAtPixelLevel`, and the Window Guard anti-pattern. macCatalyst defaults `UseSafeArea` to `true` (unlike iOS where it's `false`).
289+
- **iOS/MacCatalyst**: See `safe-area-ios.instructions.md` for `GetParentHandledSafeAreaEdges`, `EqualsAtPixelLevel`, and the Window Guard anti-pattern. macCatalyst defaults `UseSafeArea` to `true` (unlike iOS where it's `false`).
290290
- **Android**: `WindowInsetsCompat` for keyboard and system bar; `fitsSystemWindows` behavior differs by API level
291291

292292
### 16. Complexity Reduction `[minor]`

.github/instructions/safe-area-ios.instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Local macOS 26+ testing does NOT validate CI behavior. Fixes must pass CI on mac
2323

2424
## Architecture (PR #34024)
2525

26-
**`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.
26+
**`GetParentHandledSafeAreaEdges`** — before applying adjustments, `MauiView`/`MauiScrollView` walk ancestors and resolve each ancestor's current safe-area inputs. Descendants suppress only overlapping edges, so a parent handling `Top` does not block a child handling `Bottom`. Results are cached in `SafeAreaEdges? _parentHandledSafeAreaEdges`; `SafeAreaInsetsDidChange`, `InvalidateSafeArea`, and `MovedToWindow` clear the cache. Property and keyboard changes call `MauiView.InvalidateSafeArea(UIView)` to invalidate the full native subtree.
2727

2828
**`EqualsAtPixelLevel`** — safe area compared at device-pixel resolution to absorb sub-pixel animation noise (`0.0000001pt` during `TranslateToAsync`), preventing oscillation loops (#32586, #33934).
2929

src/Controls/src/Core/BindableObject.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ void ClearValueCore(BindableProperty property, SetterSpecificity specificity)
149149
if (property.CoerceValue != null)
150150
property.CoerceValue(this, newValue);
151151

152-
OnBindablePropertySet(property, original.Value, newValue, changed, changed);
152+
var specificityChanged = original.Key != bpcontext.Values.GetSpecificity();
153+
OnBindablePropertySet(property, original.Value, newValue, changed, changed, specificityChanged);
153154
}
154155

155156
/// <summary>
@@ -680,6 +681,7 @@ void SetValueActual(BindableProperty property, BindablePropertyContext context,
680681
}
681682

682683
context.Values.SetValue(specificity, value);
684+
var specificityChanged = originalSpecificity != context.Values.GetSpecificity();
683685

684686
context.Attributes &= ~BindableContextAttributes.IsDefaultValueCreated;
685687

@@ -697,15 +699,21 @@ void SetValueActual(BindableProperty property, BindablePropertyContext context,
697699
_applying = false;
698700
}
699701

700-
OnBindablePropertySet(property, original, value, !sameValue, true);
702+
OnBindablePropertySet(property, original, value, !sameValue, true, specificityChanged);
701703
}
702704
else
703705
{
704-
OnBindablePropertySet(property, original, value, !sameValue, false);
706+
OnBindablePropertySet(property, original, value, !sameValue, false, specificityChanged);
705707
}
706708
}
707709

708-
private protected virtual void OnBindablePropertySet(BindableProperty property, object original, object value, bool didChange, bool willFirePropertyChanged)
710+
private protected virtual void OnBindablePropertySet(
711+
BindableProperty property,
712+
object original,
713+
object value,
714+
bool didChange,
715+
bool willFirePropertyChanged,
716+
bool specificityChanged)
709717
{
710718
if (willFirePropertyChanged)
711719
{

src/Controls/src/Core/Border/Border.cs

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -469,17 +469,7 @@ void UpdateStrokeShape()
469469
/// <inheritdoc cref="ISafeAreaViewStrategy.GetSafeAreaRegionsForEdge"/>
470470
SafeAreaRegions ISafeAreaViewStrategy.GetSafeAreaRegionsForEdge(int edge)
471471
{
472-
// Use direct property
473-
var regionForEdge = SafeAreaEdges.GetEdge(edge);
474-
475-
if (regionForEdge == SafeAreaRegions.Default)
476-
{
477-
// If no safe area edges are set, return None
478-
return SafeAreaRegions.None;
479-
}
480-
481-
// For Border, return as-is
482-
return regionForEdge;
472+
return SafeAreaViewStrategy.GetSafeAreaRegionsForElement((ISafeAreaElement)this, edge);
483473
}
484474

485475
/// <inheritdoc cref="ISafeAreaElement.HasExplicitSafeAreaEdges"/>

src/Controls/src/Core/ContentPage/ContentPage.cs

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -173,36 +173,29 @@ Size IContentView.CrossPlatformMeasure(double widthConstraint, double heightCons
173173
/// <inheritdoc cref="ISafeAreaElement.HasExplicitSafeAreaEdges"/>
174174
bool ISafeAreaElement.HasExplicitSafeAreaEdges => SafeAreaElement.IsSafeAreaEdgesSet(this);
175175

176-
/// <inheritdoc cref="ISafeAreaViewStrategy.GetSafeAreaRegionsForEdge"/>
177-
SafeAreaRegions ISafeAreaViewStrategy.GetSafeAreaRegionsForEdge(int edge)
176+
/// <inheritdoc cref="ISafeAreaElement.SafeAreaEdges"/>
177+
SafeAreaEdges ISafeAreaElement.SafeAreaEdges
178178
{
179-
// Check if the developer has explicitly set SafeAreaEdges
180-
if (SafeAreaElement.IsSafeAreaEdgesSet(this))
179+
get
181180
{
182-
// Developer has explicitly set SafeAreaEdges, use it directly
183-
return SafeAreaEdges.GetEdge(edge);
184-
}
185-
181+
var configuredEdges = SafeAreaEdges;
182+
if (SafeAreaElement.IsSafeAreaEdgesSet(this))
183+
return configuredEdges;
186184

187185
#if IOS || MACCATALYST
188-
189-
// Developer hasn't set SafeAreaEdges, fall back to legacy IgnoreSafeArea behavior
190-
var ignoreSafeArea = ((ISafeAreaView)this).IgnoreSafeArea;
191-
if (ignoreSafeArea)
192-
{
193-
return SafeAreaRegions.None; // If legacy says "ignore", return None (edge-to-edge)
194-
}
195-
else
196-
{
197-
return SafeAreaRegions.Container; // If legacy says "don't ignore", return Container
198-
}
199-
186+
return ((ISafeAreaView)this).IgnoreSafeArea
187+
? SafeAreaEdges.None
188+
: SafeAreaEdges.Container;
200189
#else
201-
202-
// Default to None (edge-to-edge) for consistent behavior across all platforms
203-
return SafeAreaRegions.None;
204-
190+
return SafeAreaEdges.None;
205191
#endif
192+
}
193+
}
194+
195+
/// <inheritdoc cref="ISafeAreaViewStrategy.GetSafeAreaRegionsForEdge"/>
196+
SafeAreaRegions ISafeAreaViewStrategy.GetSafeAreaRegionsForEdge(int edge)
197+
{
198+
return SafeAreaViewStrategy.GetSafeAreaRegionsForElement((ISafeAreaElement)this, edge);
206199
}
207200

208201
SafeAreaEdges ISafeAreaElement.GetDefaultSafeAreaEdges()

src/Controls/src/Core/ContentView/ContentView.cs

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,17 +94,7 @@ private protected override string GetDebuggerDisplay()
9494
/// <inheritdoc cref="ISafeAreaViewStrategy.GetSafeAreaRegionsForEdge"/>
9595
SafeAreaRegions ISafeAreaViewStrategy.GetSafeAreaRegionsForEdge(int edge)
9696
{
97-
// Use direct property
98-
var regionForEdge = SafeAreaEdges.GetEdge(edge);
99-
100-
if (regionForEdge == SafeAreaRegions.Default)
101-
{
102-
// If no safe area edges are set, return None
103-
return SafeAreaRegions.None;
104-
}
105-
106-
// For ContentView, return the region directly
107-
return regionForEdge;
97+
return SafeAreaViewStrategy.GetSafeAreaRegionsForElement((ISafeAreaElement)this, edge);
10898
}
10999
}
110100
}

src/Controls/src/Core/Element/Element.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -691,16 +691,26 @@ protected virtual void OnParentSet()
691691
}
692692

693693
HashSet<string> _pendingHandlerUpdatesFromBPSet = new HashSet<string>();
694-
private protected override void OnBindablePropertySet(BindableProperty property, object original, object value, bool changed, bool willFirePropertyChanged)
694+
private protected override void OnBindablePropertySet(
695+
BindableProperty property,
696+
object original,
697+
object value,
698+
bool changed,
699+
bool willFirePropertyChanged,
700+
bool specificityChanged)
695701
{
696702
if (willFirePropertyChanged)
697703
{
698704
_pendingHandlerUpdatesFromBPSet.Add(property.PropertyName);
699705
}
700706

701-
base.OnBindablePropertySet(property, original, value, changed, willFirePropertyChanged);
707+
base.OnBindablePropertySet(property, original, value, changed, willFirePropertyChanged, specificityChanged);
702708
_pendingHandlerUpdatesFromBPSet.Remove(property.PropertyName);
703-
UpdateHandlerValue(property.PropertyName, changed);
709+
// Equal SafeAreaEdges values can still change setter specificity and Android listener eligibility.
710+
var handlerValueChanged =
711+
changed ||
712+
(specificityChanged && ReferenceEquals(property, SafeAreaElement.SafeAreaEdgesProperty));
713+
UpdateHandlerValue(property.PropertyName, handlerValueChanged);
704714

705715
}
706716

src/Controls/src/Core/InputView/InputView.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,9 +315,15 @@ string ITextInput.Text
315315
set => SetValue(TextProperty, value, SetterSpecificity.FromHandler);
316316
}
317317

318-
private protected override void OnBindablePropertySet(BindableProperty property, object original, object value, bool changed, bool willFirePropertyChanged)
318+
private protected override void OnBindablePropertySet(
319+
BindableProperty property,
320+
object original,
321+
object value,
322+
bool changed,
323+
bool willFirePropertyChanged,
324+
bool specificityChanged)
319325
{
320-
base.OnBindablePropertySet(property, original, value, changed, willFirePropertyChanged);
326+
base.OnBindablePropertySet(property, original, value, changed, willFirePropertyChanged, specificityChanged);
321327

322328
// When the same CursorPosition or SelectionLength value is re-set, the bindable property
323329
// system detects no change (changed=false) and does not fire PropertyChanged, so the

src/Controls/src/Core/Layout/Layout.cs

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -415,33 +415,12 @@ private protected override string GetDebuggerDisplay()
415415
/// <inheritdoc cref="ISafeAreaViewStrategy.GetSafeAreaRegionsForEdge"/>
416416
SafeAreaRegions ISafeAreaViewStrategy.GetSafeAreaRegionsForEdge(int edge)
417417
{
418-
// Use direct property first
419-
var regionForEdge = SafeAreaEdges.GetEdge(edge);
420-
421-
if (regionForEdge == SafeAreaRegions.Default)
422-
{
423418
#pragma warning disable CS0618 // Type or member is obsolete
424-
if (IgnoreSafeArea)
425-
{
426-
return SafeAreaRegions.None;
427-
}
428-
#pragma warning restore CS0618 // Type or member is obsolete
429-
430-
return SafeAreaRegions.Container;
431-
}
432-
433-
#pragma warning disable CS0618 // Type or member is obsolete
434-
// By default this is false so if someone sets this to true, we return None for all edges
435419
if (IgnoreSafeArea)
436-
{
437-
// If IgnoreSafeArea is set, we return None for all edges
438420
return SafeAreaRegions.None;
439-
}
440421
#pragma warning restore CS0618 // Type or member is obsolete
441422

442-
// For Layout views, never return the old Default - return None instead
443-
// (since Default no longer exists in the new enum)
444-
return regionForEdge;
423+
return SafeAreaViewStrategy.GetSafeAreaRegionsForElement((ISafeAreaElement)this, edge);
445424
}
446425
}
447426
}

src/Controls/src/Core/SafeAreaElement.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ static object SafeAreaEdgesDefaultValueCreator(BindableObject bindable)
4343
/// <remarks>
4444
/// The type must use <see cref="SafeAreaEdgesProperty"/> as the backing store for its safe area property.
4545
/// </remarks>
46+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="bindable"/> is <see langword="null"/>.</exception>
4647
public static bool IsSafeAreaEdgesSet(BindableObject bindable)
4748
{
4849
if (bindable is null)

0 commit comments

Comments
 (0)