Skip to content

[Android] Implement Layout.IgnoreSafeAreas support for Android platform #30293

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 18 commits into
base: net10.0
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4026467
Initial plan
Copilot Jun 28, 2025
1b8b2fe
Implement Android safe area support in LayoutViewGroup
Copilot Jun 28, 2025
62090bb
Add basic Android safe area tests and update LayoutStub
Copilot Jun 28, 2025
887b1a6
Move safe area logic from LayoutViewGroup to WrapperView
Copilot Jun 28, 2025
a0f5239
Add safe area logic to both LayoutViewGroup and WrapperView for compr…
Copilot Jun 28, 2025
8023bc0
Remove safe area logic from WrapperView to avoid visual effect conflicts
Copilot Jun 28, 2025
3360ee8
Move safe area handling to WrapperView for layouts with visual effects
Copilot Jun 29, 2025
79a5588
Consolidate safe area logic into AndroidSafeAreaHelper
Copilot Jun 29, 2025
196d17e
Fix NavigationBar positioning by adding fitsSystemWindows to navigati…
Copilot Jun 30, 2025
49d5e79
Fix content positioning issues with safe area implementation
Copilot Jun 30, 2025
e033554
Switch to padding-based safe area implementation for better Android c…
Copilot Jun 30, 2025
43ce5e9
- fix unshipped txt files
PureWeen Jun 30, 2025
c634955
- fix txt file
PureWeen Jun 30, 2025
30b798c
Fix edge-to-edge behavior when IgnoreSafeArea=true by moving fitsSyst…
Copilot Jun 30, 2025
e21d525
Implement modern Android insets handling following Android 15+ best p…
Copilot Jun 30, 2025
c50e9a4
Fix compilation issues in AndroidSafeAreaHelper
Copilot Jul 1, 2025
d88923c
Fix SSL certificate revocation issues by implementing robust HttpClie…
Copilot Jul 1, 2025
a7c8426
Remove all HttpClient changes to revert SSL certificate revocation fix
Copilot Jul 1, 2025
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
2 changes: 1 addition & 1 deletion src/Core/src/Core/ISafeAreaView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
/// </summary>
/// <remarks>
/// This interface may be applied to any ILayout or IContentView.
/// This interface is only recognized on the iOS/Mac Catalyst platforms; other platforms will ignore it.
/// This interface is recognized on iOS/Mac Catalyst and Android platforms.
/// </remarks>
public interface ISafeAreaView
{
Expand Down
167 changes: 167 additions & 0 deletions src/Core/src/Platform/Android/AndroidSafeAreaHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
using System;
using Android.Content;
using Android.Views;
using AndroidX.Core.View;
using Microsoft.Maui.Graphics;

namespace Microsoft.Maui.Platform
{
/// <summary>
/// Modern Android insets handling helper following Android 15+ edge-to-edge best practices.
/// Uses proper inset listener patterns and inset consumption for optimal compatibility.
/// </summary>
internal static class AndroidSafeAreaHelper
{
/// <summary>
/// Sets up modern window insets handling for a view based on whether it should respect safe areas.
/// Uses ViewCompat.setOnApplyWindowInsetsListener for proper inset handling patterns.
/// </summary>
/// <param name="view">The Android view to configure insets handling for</param>
/// <param name="layout">The cross-platform layout to check for safe area preferences</param>
public static void SetupWindowInsetsHandling(View view, ICrossPlatformLayout? layout)
{
if (layout is not ISafeAreaView safeAreaView)
{
// If not a safe area view, clear any existing listener and let insets pass through
ViewCompat.SetOnApplyWindowInsetsListener(view, null);
return;
}

// Set up modern insets listener using ViewCompat for compatibility
ViewCompat.SetOnApplyWindowInsetsListener(view, (v, insets) =>
{
return HandleWindowInsets(v, insets, safeAreaView.IgnoreSafeArea);
});

// Request insets to be applied initially
ViewCompat.RequestApplyInsets(view);
}

/// <summary>
/// Modern window insets handler that follows Android 15+ best practices.
/// Properly consumes insets when handled and passes through when ignored.
/// </summary>
/// <param name="view">The view receiving the insets</param>
/// <param name="insets">The window insets to handle</param>
/// <param name="ignoreSafeArea">Whether to ignore safe areas (allow edge-to-edge)</param>
/// <returns>The remaining insets after processing</returns>
private static WindowInsetsCompat HandleWindowInsets(View view, WindowInsetsCompat insets, bool ignoreSafeArea)
{
if (ignoreSafeArea)
{
// When ignoring safe areas, reset any margins and let content go edge-to-edge
ResetViewMargins(view);
// Don't consume insets - pass them through to allow edge-to-edge behavior
return insets;
}

// When respecting safe areas, apply them as margins for modern, flexible layout
ApplySafeAreaAsMargins(view, insets);

// Return the original insets since we've handled them by applying margins
// This allows proper inset dispatch to children while indicating we've processed them
return insets;
}

/// <summary>
/// Applies safe area insets as margins rather than padding for more flexible layouts.
/// This is the modern approach that works better with complex view hierarchies.
/// </summary>
/// <param name="view">The view to apply margins to</param>
/// <param name="insets">The window insets containing safe area information</param>
private static void ApplySafeAreaAsMargins(View view, WindowInsetsCompat insets)
{
// Get the safe area insets by combining system bars and display cutouts
var systemInsets = insets.GetInsets(WindowInsetsCompat.Type.SystemBars());
var cutoutInsets = insets.GetInsets(WindowInsetsCompat.Type.DisplayCutout());

// Use the maximum of system and cutout insets for comprehensive safe area
var safeInsets = MaxInsets(systemInsets, cutoutInsets);

// Apply as margins which are more flexible than padding for complex layouts
if (view.LayoutParameters is ViewGroup.MarginLayoutParams marginParams)
{
marginParams.SetMargins(
safeInsets.Left,
safeInsets.Top,
safeInsets.Right,
safeInsets.Bottom
);
view.LayoutParameters = marginParams;
}
}

/// <summary>
/// Resets view margins when safe areas should be ignored.
/// </summary>
/// <param name="view">The view to reset margins for</param>
private static void ResetViewMargins(View view)
{
if (view.LayoutParameters is ViewGroup.MarginLayoutParams marginParams)
{
marginParams.SetMargins(0, 0, 0, 0);
view.LayoutParameters = marginParams;
}
}

/// <summary>
/// Gets safe area insets in device-independent units for cross-platform use.
/// </summary>
/// <param name="view">The view to get insets from</param>
/// <param name="context">Android context for unit conversion</param>
/// <returns>Safe area insets in device-independent units, or Thickness.Zero if unavailable</returns>
public static Thickness GetSafeAreaInsets(View view, Context context)
{
var insets = ViewCompat.GetRootWindowInsets(view);
if (insets == null)
return Thickness.Zero;

var systemInsets = insets.GetInsets(WindowInsetsCompat.Type.SystemBars());
var cutoutInsets = insets.GetInsets(WindowInsetsCompat.Type.DisplayCutout());
var safeInsets = MaxInsets(systemInsets, cutoutInsets);

return new Thickness(
context.FromPixels(safeInsets.Left),
context.FromPixels(safeInsets.Top),
context.FromPixels(safeInsets.Right),
context.FromPixels(safeInsets.Bottom)
);
}

/// <summary>
/// Checks if the given layout should handle window insets based on safe area preferences.
/// </summary>
/// <param name="layout">The cross-platform layout to check</param>
/// <returns>True if the layout implements ISafeAreaView</returns>
public static bool ShouldHandleWindowInsets(ICrossPlatformLayout? layout)
{
return layout is ISafeAreaView;
}

/// <summary>
/// Calculates the maximum insets from two Android.Graphics.Insets objects.
/// Uses the built-in Android.Graphics.Insets.Max() method when available.
/// </summary>
/// <param name="first">The first insets object</param>
/// <param name="second">The second insets object</param>
/// <returns>Insets containing the maximum values from both inputs</returns>
private static Android.Graphics.Insets MaxInsets(Android.Graphics.Insets first, Android.Graphics.Insets second)
{
if (OperatingSystem.IsAndroidVersionAtLeast(29))
{
// Use the built-in Max method available in API 29+
return Android.Graphics.Insets.Max(first, second);
}
else
{
// For older Android versions, calculate manually
return Android.Graphics.Insets.Of(
Math.Max(first.Left, second.Left),
Math.Max(first.Top, second.Top),
Math.Max(first.Right, second.Right),
Math.Max(first.Bottom, second.Bottom)
);
}
}
}
}
19 changes: 19 additions & 0 deletions src/Core/src/Platform/Android/LayoutViewGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Android.Util;
using Android.Views;
using Android.Widget;
using AndroidX.Core.View;
using Microsoft.Maui.Graphics;
using ARect = Android.Graphics.Rect;
using Rectangle = Microsoft.Maui.Graphics.Rect;
Expand Down Expand Up @@ -96,6 +97,8 @@ protected override void OnMeasure(int widthMeasureSpec, int heightMeasureSpec)
SetMeasuredDimension((int)platformWidth, (int)platformHeight);
}



// TODO: Possibly reconcile this code with ViewHandlerExtensions.MeasureVirtualView
// If you make changes here please review if those changes should also
// apply to ViewHandlerExtensions.MeasureVirtualView
Expand All @@ -122,6 +125,22 @@ protected override void OnLayout(bool changed, int l, int t, int r, int b)
}
}

protected override void OnAttachedToWindow()
{
base.OnAttachedToWindow();

// Set up modern window insets handling when attached to window
AndroidSafeAreaHelper.SetupWindowInsetsHandling(this, CrossPlatformLayout);
}

protected override void OnDetachedFromWindow()
{
base.OnDetachedFromWindow();

// Clean up insets listener when detached
ViewCompat.SetOnApplyWindowInsetsListener(this, null);
}

public override bool OnTouchEvent(MotionEvent? e)
{
if (InputTransparent)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
android:layout_height="wrap_content"
android:background="?attr/colorPrimary"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
android:fitsSystemWindows="true"
>

<androidx.fragment.app.FragmentContainerView
Expand Down
27 changes: 20 additions & 7 deletions src/Core/src/Platform/Android/WrapperView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
using Android.Content;
using Android.Graphics;
using Android.Views;
using AndroidX.Core.View;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Graphics.Platform;
using APath = Android.Graphics.Path;
using AView = Android.Views.View;
using Rectangle = Microsoft.Maui.Graphics.Rect;

namespace Microsoft.Maui.Platform
{
public partial class WrapperView : PlatformWrapperView
public partial class WrapperView : PlatformWrapperView, IVisualTreeElementProvidable
{
APath _currentPath;
SizeF _lastPathSize;
Expand All @@ -34,12 +36,10 @@ protected override void OnLayout(bool changed, int left, int top, int right, int
if (ChildCount == 0 || GetChildAt(0) is not AView child)
return;

var widthMeasureSpec = MeasureSpecMode.Exactly.MakeMeasureSpec(right - left);
var heightMeasureSpec = MeasureSpecMode.Exactly.MakeMeasureSpec(bottom - top);

child.Measure(widthMeasureSpec, heightMeasureSpec);
child.Layout(0, 0, child.MeasuredWidth, child.MeasuredHeight);
_borderView?.Layout(0, 0, child.MeasuredWidth, child.MeasuredHeight);
// Standard layout for all children - no safe area adjustments in WrapperView
// Safe area handling is done by the LayoutViewGroup itself
child.Layout(0, 0, right - left, bottom - top);
_borderView?.Layout(0, 0, right - left, bottom - top);
}

public override bool DispatchTouchEvent(MotionEvent e)
Expand Down Expand Up @@ -145,6 +145,8 @@ protected override APath GetClipPath(int width, int height)
return _currentPath;
}



public override ViewStates Visibility
{
get => base.Visibility;
Expand Down Expand Up @@ -214,5 +216,16 @@ void CleanupContainerView(AView containerView, Action clearWrapperView)
clearWrapperView.Invoke();
}
}

IVisualTreeElement IVisualTreeElementProvidable.GetElement()
{
// Return the element from the wrapped layout if it exists
if (ChildCount > 0 && GetChildAt(0) is IVisualTreeElementProvidable provider)
{
return provider.GetElement();
}

return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
using System.Linq;
using System.Threading.Tasks;
using Android.Widget;
using AndroidX.Core.View;
using Microsoft.Maui.DeviceTests.Stubs;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform;
using Xunit;
using AView = Android.Views.View;

Expand Down Expand Up @@ -63,5 +65,47 @@ async Task AssertZIndexOrder(IReadOnlyList<AView> children)

Assert.Equal(expected, actual);
}

[Fact(DisplayName = "Layout with IgnoreSafeArea false handles window insets")]
public async Task LayoutIgnoreSafeAreaFalseHandlesWindowInsets()
{
var layout = new LayoutStub();
layout.IgnoreSafeArea = false;

await CreateHandlerAndAddToWindow<LayoutHandler>(layout, (handler) =>
{
var layoutViewGroup = GetNativeLayout(handler);

// Verify that the layout implements ISafeAreaView correctly
Assert.True(layout is ISafeAreaView);
Assert.False(((ISafeAreaView)layout).IgnoreSafeArea);

// Verify that the LayoutViewGroup has the OnApplyWindowInsets method
Assert.NotNull(layoutViewGroup);

return Task.CompletedTask;
});
}

[Fact(DisplayName = "Layout with IgnoreSafeArea true ignores window insets")]
public async Task LayoutIgnoreSafeAreaTrueIgnoresWindowInsets()
{
var layout = new LayoutStub();
layout.IgnoreSafeArea = true;

await CreateHandlerAndAddToWindow<LayoutHandler>(layout, (handler) =>
{
var layoutViewGroup = GetNativeLayout(handler);

// Verify that the layout implements ISafeAreaView correctly
Assert.True(layout is ISafeAreaView);
Assert.True(((ISafeAreaView)layout).IgnoreSafeArea);

// Verify that the LayoutViewGroup has the OnApplyWindowInsets method
Assert.NotNull(layoutViewGroup);

return Task.CompletedTask;
});
}
}
}
4 changes: 2 additions & 2 deletions src/Core/tests/DeviceTests/Stubs/LayoutStub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

namespace Microsoft.Maui.DeviceTests.Stubs
{
public class LayoutStub : StubBase, ILayout
public class LayoutStub : StubBase, ILayout, ISafeAreaView
{
ILayoutManager _layoutManager;

Expand Down Expand Up @@ -80,7 +80,7 @@ public Size CrossPlatformArrange(Rect bounds)

protected virtual ILayoutManager CreateLayoutManager() => new LayoutManagerStub();

public bool IgnoreSafeArea => false;
public bool IgnoreSafeArea { get; set; }

public bool ClipsToBounds { get; set; }

Expand Down