maui icon indicating copy to clipboard operation
maui copied to clipboard

[.NET10] SafeArea Epic

Open PureWeen opened this issue 7 months ago • 3 comments
trafficstars

📐 Proposal: SafeAreaInsets Markup Extension for .NET MAUI

Summary

Introduce a markup extension to make it easy to apply safe area insets to layout padding and margin in .NET MAUI applications. This approach avoids introducing new controls or layout behaviors and enables developers to apply platform-specific insets declaratively and flexibly.


Motivation

  • MAUI currently lacks a simple, declarative way to apply safe area insets per side.
  • Existing options like IgnoreSafeArea are coarse and often require nested layouts.
  • A markup extension provides XAML-friendly, composable access to platform insets without requiring changes to the layout system.

Design Goals

  • ✅ Declarative: usable directly in Padding or Margin
  • ✅ Composable: works alongside static offsets
  • ✅ Flexible: side-specific inset application

Core Proposal

SafeAreaInsetsExtension Markup Extension

A markup extension that returns a Thickness representing safe area padding for the selected sides.

Enum: SafeAreaSides

[Flags]
public enum SafeAreaSides
{
    None = 0,
    Left = 1,
    Top = 2,
    Right = 4,
    Bottom = 8,
    All = Left | Top | Right | Bottom
}

Markup Extension API

public class SafeAreaInsetsExtension
{
    public SafeAreaSides Sides { get; set; } = SafeAreaSides.None;

    public Thickness Offset { get; set; } = new Thickness(0);

    public Thickness ProvideValue(IServiceProvider serviceProvider);
}

Behavior

  • For each specified side, the extension fetches the current platform safe area inset.
  • It adds the corresponding value from Offset to allow fine-tuning.
  • If a side is not included in Sides, only the Offset is applied for that side.

Usage Examples

Apply safe area to the top and bottom only:

<Grid Padding="{SafeAreaInsets Sides=Top|Bottom}" />

Add 5px static padding to the left, and safe area padding to the top:

<Grid Padding="{SafeAreaInsets Sides=Top, Offset='5,,0,0'}" />

Add 5px static padding to the left, right, bottom, and safe area padding to the top:

<Grid Padding="{SafeAreaInsets Sides=Top, Offset='5'}" />

Apply all safe areas:

<ContentView Padding="{SafeAreaInsets Sides=All}" />

Platform Behavior

Platform Notes
iOS/macOS Applies top/bottom/left/right safe insets (status bar, home indicator, etc.)
Android Maps to WindowInsetsCompat.getInsets(Type.systemBars()) by default
Keyboard Not included in base spec (see below)

Optional Next Step: SafeAreaGroups for Advanced Inset Control

To support more granular control (e.g., include keyboard insets or gesture areas), the extension could later support specifying inset groups.

Future Enum: SafeAreaGroup

[Flags]
public enum SafeAreaGroup
{
    None = 0,
    SystemBars = 1,
    Cutouts = 2,
    IME = 4,
    Gestures = 8,
    All = SystemBars | Cutouts | IME | Gestures,
    Default = SystemBars | Cutouts
}

Extension API (Optional Future)

public SafeAreaGroup Groups { get; set; } = SafeAreaGroup.Default;

public SafeAreaGroup? TopGroup { get; set; }
public SafeAreaGroup? BottomGroup { get; set; }
public SafeAreaGroup? LeftGroup { get; set; }
public SafeAreaGroup? RightGroup { get; set; }

Example Usage (Optional Future)

<Grid Padding="{SafeAreaInsets Sides=Top|Bottom,
                                  TopGroup=Cutouts,
                                  BottomGroup=IME,
                                  Offset='0,,0,'}" />

Alternate Paths To Explore

  • Just create a SafeAreaInsets property on controls that willl host children controls (ScrollView, ContentView, Layout, etc...). Markup extentions might be too hard to make work since you can apply them everywhere.
  • Just make IgnoreSafeArea work on Android for now and explore a better option in .NET 11

PureWeen avatar Apr 14 '25 19:04 PureWeen

and you can apply this to elements at any level, right ?

I like the proposal, this was actually the very first suggestion that was made to implement safe area

Some remarks...

  • ProvideValue should be an implicit interface implementation (not public)
  • Sides=Top|Bottom should be Sides='Top,Bottom' (that's how we do flags in XAML)

not sure about the 'future' part. I'd split the feature in 2, the obvious (let's do it), and keep the idea for future extensions in the future.

this is an easy grab for anyone who want to start contributing. It'll require a XamlC counterpart, but @simonrozsival or I will take care of it later

StephaneDelcroix avatar Jun 11 '25 09:06 StephaneDelcroix

One thing that a markup extension will do is prevent this support from happening in C#. Sure, there can be another way in C#, but this spec just supports XAML.

I do like the alternative where:

  • IgnoreSafeArea is where the MAUI framework just does it all for you, but if you don't like it
  • then IContainerView.SafeAreaInsets will give you the option for full control

The core proposal is mostly OK because I think most users don't care about the number and more the "this part of the screen I want to...". However, it is XAML only and the way it is structured is Padding="{VALUE}" seems to indicate that this is a real value, however, it is more calculated during layout depending on where it is in the UI.

Also, it is really hard to use since there will be no or limited intellisense.

If the view moves, the safe area moves and thus the padding changes. Having the SafeAreaInsets property does mean that you will have to update yourself if you want a calculated value. But at least you know it is happening.

However, either way feels like we are abusing the padding property. I think a slightly better way would be with a separate propert[y|ies]:

<Grid Padding="20" 
      SafeAreaBehavior.Top="None"
      SafeAreaBehavior.Left="SystemBars,Cutouts"
      SafeAreaBehavior.Bottom="All" />

The way I see this is:

  • no matter what, there will be a padding of 20 or a min 20 padding
  • the top will be be free for me to put a button
  • the bottom is protected and MAUI will make sure there is a space

As a result, I can have all of the spec without caring about the actual value no messing with other properties that may have been animated or chnaged based on some other feature (like a11y). If your UI scales, you don't want your padding to be based on the safe areas.

This implementation does not really take away from the first two points:

  • IgnoreSafeAreas can be a switch to turn it onn or off, and this controls it.
  • the safe area insets is still super useful if you need the actual numbers. This also can tie into that the inset is now no longer "everything" but just what you specified in the behaviors.

I realise the IgnoreSafeAreas propoerty i not in the nice attached property, but we can always move it and obsolete things - or we can keep them like that. It could be SafeAreaBehavior.Ignore="True"...

Maybe the IgnoreSafeAreas can be a markup extension such that:

<Grid IgnoreSafeAreas="{SafeAreaBehavior Top='SystemBars,Cutout', Left=All, Bottom=SystemBars}" />

If we make the property type for SafeAreaBehavior.Top be a more complex type (just like with GridLength), it may be able to be extended in future (not sure if additional data is needed besides behavior)

We can make the property attached so it is all in a single place (or not), but the property type can be

enum SafeAreaComponent
{
    // pretend tha safe area is not there and put content directly in the space
    // this is the same as IgnoreSafeAreas=true (i think)
    None = 0,

    // don't put content in the status and nav bar areas
    SystemBars = 1,

    // don't put content under the cutout or notch
    Cutouts = 2,

    // don't put content under the keyboard
    IME = 4, // optional

    // kee the gesture areas free so the user does not try tap something that would cause the OS to respond
    Gestures = 8,

    // don't put anything in any area that the OS may have something else in
    // this is the same as IgnoreSafeArea=false
    All = SystemBars | Cutouts | IME | Gestures,

    // don't put content under the status, nav and cutout
    // this is what most users would want... maybe... 
    Default = SystemBars | Cutouts
}

mattleibow avatar Jun 11 '25 18:06 mattleibow

How's the value provider gonna provide values dynamically? Would it return some kind of binding?

What would be the need of having Top,Bottom,Left,Right here? It seems an overkill to me: I usually want to avoid the system bars no matter where they are. So I'd go with a new property like SafeAreaPadding with a new type SafeAreaTypes.

What about ScrollView, I guess the padding applies to the scrollable area, right? What about CollectionView?

Regarding the keyboard, we should also do something to auto-scroll to the focused field after messing up with the layout while the keyboard was showing:

  • https://github.com/nalu-development/nalu/blob/main/Source/Nalu.Maui.Core/Platforms/Android/SoftKeyboardManager.Android.cs#L55
  • https://github.com/nalu-development/nalu/blob/main/Source/Nalu.Maui.Core/Platforms/iOS/SoftKeyboardManager.iOS.cs#L330-L351

albyrock87 avatar Jun 16 '25 11:06 albyrock87

@mattleibow @PureWeen

Can we also in this perhaps look at two additional attached properties that allows us to apply padding and margin to elements that match the insets for that edge? Something like a ApplySafeAreaPadding and ApplySafeAreaMargin that use the same flags setup as what you're suggesting. That way you can more easily manage the content you're wanting to bleed into the safe area.

E.g. for a sheet coming out from the bottom you want the sheet background itself to ignore the insets (using IgnoreSafeArea), but you'd also want the content of the sheet to have a bottom padding so it doesn't remain hidden behind platform elements in the insets. That would then be achieved with:

<Grid SafeAreaGuides.IgnoreSafeArea="None,None,None,All" SafeAreaGuides.ApplySafeAreaPadding="None,None,None,All" />

Something like this, conceptually;

public static readonly BindableProperty ApplySafeAreaMarginProperty =
	BindableProperty.CreateAttached(
	  "ApplySafeAreaMargin",
	  typeof(typeof(SafeAreaGroup[])),
	  typeof(SafeAreaGuides),
	  false,
	  propertyChanged: OnSafeAreaChanged
	);

public static readonly BindableProperty ApplySafeAreaPaddingProperty =
	BindableProperty.CreateAttached(
	  "ApplySafeAreaPadding",
	  typeof(typeof(SafeAreaGroup[])),
	  typeof(SafeAreaGuides),
	  false,
	  propertyChanged: OnSafeAreaChanged
	);

public static bool GetApplySafeAreaMargin(BindableObject view) =>
	(SafeAreaGroup[])view.GetValue(ApplySafeAreaMarginProperty);

public static void SetApplySafeAreaMargin(BindableObject view, SafeAreaGroup[] value) =>
	view.SetValue(ApplySafeAreaMarginProperty, value);

public static bool GetApplySafeAreaPadding(BindableObject view) =>
	(SafeAreaGroup[])view.GetValue(ApplySafeAreaPaddingProperty);

public static void SetApplySafeAreaPadding(BindableObject view, SafeAreaGroup[] value) =>
	view.SetValue(ApplySafeAreaPaddingProperty, value);

private static void OnSafeAreaChanged(BindableObject bindable, object oldValue, object newValue)
{
	if (bindable is not Layout layout || newValue is not SafeAreaGroup[] applySafeArea) 
		return;

	if (!applySafeArea) 
		return;
  
#if IOS
	  if (!UIDevice.CurrentDevice.CheckSystemVersion(11, 0)) 
		return;

	  var insets = UIApplication.SharedApplication.Delegate.GetWindow().SafeAreaInsets;
	  var shouldApplyMargins = GetApplySafeAreaMargin(layout);

	  // Todo: replace with an actual check if these should be set or not.
	  var left = shouldApplyMargins == Left ? insets.Left : 0;
	  var top =shouldApplyMargins == Top ? insets.Top : 0;
	  var right = shouldApplyMargins == Right ? insets.Right : 0;
	  var bottom = shouldApplyMargins == Bottom ? insets.Bottom : 0;
	  
          // Add that margin to any margin we manually applied on the element.
	  layout.Margin = new Thickness(layout.Margin.Left + left, layout.Margin.Top + top,
		layout.Margin.Right + right, layout.Margin.Bottom + bottom);
	  
	  var shouldApplyPaddings = GetApplySafeAreaPadding(layout);

	  // Todo: replace with an actual check if these should be set or not.
	  left = shouldApplyPaddings == Left ? insets.Left : 0;
	  top = shouldApplyPaddings == Top ? insets.Top : 0;
	  right = shouldApplyPaddings == Right ? insets.Right : 0;
	  bottom = shouldApplyPaddings == Bottom ? insets.Bottom : 0;

          // Add that padding to any padding we manually applied on the element.
	  layout.Padding = new Thickness(layout.Padding.Left + left, layout.Padding.Top + top,
		layout.Padding.Right + right, layout.Padding.Bottom + bottom);
#endif
}

sthewissen avatar Jun 23 '25 09:06 sthewissen

@mattleibow @PureWeen

Can we also in this perhaps look at two additional attached properties that allows us to apply padding and margin to elements that match the insets for that edge? Something like a ApplySafeAreaPadding and ApplySafeAreaMargin that use the same flags setup as what you're suggesting. That way you can more easily manage the content you're wanting to bleed into the safe area.

E.g. for a sheet coming out from the bottom you want the sheet background itself to ignore the insets (using IgnoreSafeArea), but you'd also want the content of the sheet to have a bottom padding so it doesn't remain hidden behind platform elements in the insets. That would then be achieved with:

Something like this, conceptually;

public static readonly BindableProperty ApplySafeAreaMarginProperty = BindableProperty.CreateAttached( "ApplySafeAreaMargin", typeof(typeof(SafeAreaGroup[])), typeof(SafeAreaGuides), false, propertyChanged: OnSafeAreaChanged );

public static readonly BindableProperty ApplySafeAreaPaddingProperty = BindableProperty.CreateAttached( "ApplySafeAreaPadding", typeof(typeof(SafeAreaGroup[])), typeof(SafeAreaGuides), false, propertyChanged: OnSafeAreaChanged );

public static bool GetApplySafeAreaMargin(BindableObject view) => (SafeAreaGroup[])view.GetValue(ApplySafeAreaMarginProperty);

public static void SetApplySafeAreaMargin(BindableObject view, SafeAreaGroup[] value) => view.SetValue(ApplySafeAreaMarginProperty, value);

public static bool GetApplySafeAreaPadding(BindableObject view) => (SafeAreaGroup[])view.GetValue(ApplySafeAreaPaddingProperty);

public static void SetApplySafeAreaPadding(BindableObject view, SafeAreaGroup[] value) => view.SetValue(ApplySafeAreaPaddingProperty, value);

private static void OnSafeAreaChanged(BindableObject bindable, object oldValue, object newValue) { if (bindable is not Layout layout || newValue is not SafeAreaGroup[] applySafeArea) return;

if (!applySafeArea) return;

#if IOS if (!UIDevice.CurrentDevice.CheckSystemVersion(11, 0)) return;

var insets = UIApplication.SharedApplication.Delegate.GetWindow().SafeAreaInsets;
var shouldApplyMargins = GetApplySafeAreaMargin(layout);

// Todo: replace with an actual check if these should be set or not.
var left = shouldApplyMargins == Left ? insets.Left : 0;
var top =shouldApplyMargins == Top ? insets.Top : 0;
var right = shouldApplyMargins == Right ? insets.Right : 0;
var bottom = shouldApplyMargins == Bottom ? insets.Bottom : 0;

      // Add that margin to any margin we manually applied on the element.
layout.Margin = new Thickness(layout.Margin.Left + left, layout.Margin.Top + top,
  layout.Margin.Right + right, layout.Margin.Bottom + bottom);

var shouldApplyPaddings = GetApplySafeAreaPadding(layout);

// Todo: replace with an actual check if these should be set or not.
left = shouldApplyPaddings == Left ? insets.Left : 0;
top = shouldApplyPaddings == Top ? insets.Top : 0;
right = shouldApplyPaddings == Right ? insets.Right : 0;
bottom = shouldApplyPaddings == Bottom ? insets.Bottom : 0;

      // Add that padding to any padding we manually applied on the element.
layout.Padding = new Thickness(layout.Padding.Left + left, layout.Padding.Top + top,
  layout.Padding.Right + right, layout.Padding.Bottom + bottom);

#endif }

  1. In theory could you achieve this with Nested Layouts?
<ContentView SafeAreaGuides.IgnoreSafeArea="None,None,None,All" >
<Grid SafeAreaGuides.IgnoreSafeArea="None,None,None,None">
  1. the plan is to add more APIs to this down the road as we see a need. One other approach we're looking at is just exposing the raw values of these SafeAreas. So, you'd be able to just Query Window for them or we'd have a DynamicResource, this way users would just have a raw value they could use and apply to anything

PureWeen avatar Jun 23 '25 15:06 PureWeen

  1. In theory could you achieve this with Nested Layouts? <ContentView SafeAreaGuides.IgnoreSafeArea="None,None,None,All" > <Grid SafeAreaGuides.IgnoreSafeArea="None,None,None,None">
  2. the plan is to add more APIs to this down the road as we see a need. One other approach we're looking at is just exposing the raw values of these SafeAreas. So, you'd be able to just Query Window for them or we'd have a DynamicResource, this way users would just have a raw value they could use and apply to anything

True, but I was always taught to have as little nesting of elements as possible ;-) Re: providing a DynamicResource that represents the insets, you'd still always have to use that and then set things like Padding/Margin on a nested element to make it additive, right? I just feel like the overall need for nesting all sorts of UI elements for something as simple as a Margin or Padding could be more minimized.

sthewissen avatar Jun 23 '25 17:06 sthewissen

Suggestion for Extension to Support AppShell

Considering the current SafeAreaGuides.IgnoreSafeArea proposal for .NET 10, it would be valuable to include specific AppShell support in the implementation. AppShell is a fundamental component in MAUI's navigation architecture and presents unique challenges related to safe area management.

Context and Need

AppShell in .NET MAUI maps to native components like UITabBarController on iOS and BottomNavigationView on Android. Currently, when edge-to-edge is enabled, there is inconsistent behavior between AppShell and regular pages, where AppShell still adds content insets while regular pages may not.

Implementation Proposal

The SafeAreaGuides.IgnoreSafeArea attached property should work natively with the following AppShell components:

1. ShellContent and ContentPage

<Shell>
    <TabBar>
        <Tab>
            <ShellContent SafeAreaGuides.IgnoreSafeArea="All">
                <ContentPage Title="Main Page" />
            </ShellContent>
        </Tab>
    </TabBar>
</Shell>

2. FlyoutItem and FlyoutHeader

<Shell>
    <FlyoutItem SafeAreaGuides.IgnoreSafeArea="Top,Bottom">
        <Tab Title="Menu Item" />
    </FlyoutItem>
    <Shell.FlyoutHeader>
        <Grid SafeAreaGuides.IgnoreSafeArea="Top">
            <!-- Header content -->
        </Grid>
    </Shell.FlyoutHeader>
</Shell>

3. Modals and Page Presentation

A specific identified problem is that modals presented through AppShell don't fill the entire screen when edge-to-edge is enabled. The implementation should ensure that:

await Shell.Current.GoToAsync("//modal", animate: true);

Respects the IgnoreSafeArea settings defined on modal pages.

Benefits of Integration

  1. Visual Consistency: Ensure all parts of the application using AppShell have consistent safe area behavior
  2. Granular Control: Allow developers to individually control how different Shell elements (TabBar, FlyoutMenu, pages) interact with safe areas
  3. Future Preparation: With Android 16 completely removing the edge-to-edge opt-out option, AppShell needs to be prepared to manage safe areas natively

carloshenriquecarniatto avatar Jun 24 '25 22:06 carloshenriquecarniatto

  1. In theory could you achieve this with Nested Layouts?

  2. the plan is to add more APIs to this down the road as we see a need. One other approach we're looking at is just exposing the raw values of these SafeAreas. So, you'd be able to just Query Window for them or we'd have a DynamicResource, this way users would just have a raw value they could use and apply to anything

True, but I was always taught to have as little nesting of elements as possible ;-) Re: providing a DynamicResource that represents the insets, you'd still always have to use that and then set things like Padding/Margin on a nested element to make it additive, right? I just feel like the overall need for nesting all sorts of UI elements for something as simple as a Margin or Padding could be more minimized.

yea, we definitely want to add more APIs later, so, just checking that I understood your scenario and this would at least allow a way for now.

True, but I was always taught to have as little nesting of elements as possible ;-) Re: providing a DynamicResource that represents the insets, you'd still always have to use that and then set things like Padding/Margin on a nested element to make it additive, right? I just feel like the overall need for nesting all sorts of UI elements for something as simple as a Margin or Padding could be more minimized.

Yes, not super sure how we'd expose this. One thought would just be doing it similar to android like you could just query Window for these values and then we could just have some event that fires if these changes. At which point you can do whatever you want with them. That's most likely more of a net 11 time frame but it's an option for the future.

PureWeen avatar Jun 30 '25 21:06 PureWeen

just exposing the raw values of these SafeAreas.

Yes please, this is the fastest and shortest code and path, and also the best option to layout our screen as we want. No more mix nor SafeAreaBehavior please. Let those "helpers" go in the toolkit.

3 simple TopSystemBar, BottomSystemBar and TopNotch rectangle properties and we're done.

Btw what are the current workarounds for net9 ?

softlion avatar Aug 09 '25 17:08 softlion