From 1eb6e64f37e1e073aedbd00bd4dcb6cd78bf007d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 31 Jul 2025 00:04:42 +0000 Subject: [PATCH 01/10] Initial plan From 8097856bbf9afc3f595180e9deeb6fe1c66aef17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 31 Jul 2025 00:27:30 +0000 Subject: [PATCH 02/10] Add Android SafeAreaEdges implementation for ContentViewGroup and LayoutViewGroup Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- .../src/Platform/Android/ContentViewGroup.cs | 121 ++++++++++++++++++ .../src/Platform/Android/LayoutViewGroup.cs | 121 ++++++++++++++++++ .../src/Platform/Android/SafeAreaPadding.cs | 69 ++++++++++ 3 files changed, 311 insertions(+) create mode 100644 src/Core/src/Platform/Android/SafeAreaPadding.cs diff --git a/src/Core/src/Platform/Android/ContentViewGroup.cs b/src/Core/src/Platform/Android/ContentViewGroup.cs index fc290a3a4d93..ca4d8b0ae439 100644 --- a/src/Core/src/Platform/Android/ContentViewGroup.cs +++ b/src/Core/src/Platform/Android/ContentViewGroup.cs @@ -4,6 +4,7 @@ using Android.Runtime; using Android.Util; using Android.Views; +using AndroidX.Core.View; using Microsoft.Maui.Graphics; using Microsoft.Maui.Graphics.Platform; @@ -13,10 +14,13 @@ public class ContentViewGroup : PlatformContentViewGroup, ICrossPlatformLayoutBa { IBorderStroke? _clip; readonly Context _context; + SafeAreaPadding _safeArea = SafeAreaPadding.Empty; + bool _safeAreaInvalidated = true; public ContentViewGroup(Context context) : base(context) { _context = context; + SetupWindowInsetsHandling(); } public ContentViewGroup(IntPtr javaReference, JniHandleOwnership transfer) : base(javaReference, transfer) @@ -24,21 +28,35 @@ public ContentViewGroup(IntPtr javaReference, JniHandleOwnership transfer) : bas var context = Context; ArgumentNullException.ThrowIfNull(context); _context = context; + SetupWindowInsetsHandling(); } public ContentViewGroup(Context context, IAttributeSet attrs) : base(context, attrs) { _context = context; + SetupWindowInsetsHandling(); } public ContentViewGroup(Context context, IAttributeSet attrs, int defStyleAttr) : base(context, attrs, defStyleAttr) { _context = context; + SetupWindowInsetsHandling(); } public ContentViewGroup(Context context, IAttributeSet attrs, int defStyleAttr, int defStyleRes) : base(context, attrs, defStyleAttr, defStyleRes) { _context = context; + SetupWindowInsetsHandling(); + } + + void SetupWindowInsetsHandling() + { + ViewCompat.SetOnApplyWindowInsetsListener(this, (view, insets) => + { + _safeAreaInvalidated = true; + RequestLayout(); + return insets; + }); } public ICrossPlatformLayout? CrossPlatformLayout @@ -56,6 +74,103 @@ Graphics.Size CrossPlatformArrange(Graphics.Rect bounds) return CrossPlatformLayout?.CrossPlatformArrange(bounds) ?? Graphics.Size.Zero; } + bool RespondsToSafeArea() + { + return CrossPlatformLayout is ISafeAreaView2; + } + + SafeAreaRegions GetSafeAreaRegionForEdge(int edge) + { + if (CrossPlatformLayout is ISafeAreaView2 safeAreaPage) + { + return safeAreaPage.GetSafeAreaRegionsForEdge(edge); + } + + // Fallback to legacy ISafeAreaView behavior + if (CrossPlatformLayout is ISafeAreaView sav) + { + return sav.IgnoreSafeArea ? SafeAreaRegions.None : SafeAreaRegions.Container; + } + + return SafeAreaRegions.None; + } + + static double GetSafeAreaForEdge(SafeAreaRegions safeAreaRegion, double originalSafeArea) + { + // Edge-to-edge content - no safe area padding + if (safeAreaRegion == SafeAreaRegions.None) + return 0; + + // All other regions respect safe area in some form + // This includes: + // - Default: Platform default behavior + // - All: Obey all safe area insets + // - SoftInput: Always pad for keyboard/soft input + // - Container: Content flows under keyboard but stays out of bars/notch + // - Any combination of the above flags + return originalSafeArea; + } + + Graphics.Rect AdjustForSafeArea(Graphics.Rect bounds) + { + ValidateSafeArea(); + + if (_safeArea.IsEmpty) + return bounds; + + return new Graphics.Rect( + bounds.X + _safeArea.Left, + bounds.Y + _safeArea.Top, + bounds.Width - _safeArea.HorizontalThickness, + bounds.Height - _safeArea.VerticalThickness); + } + + SafeAreaPadding GetAdjustedSafeAreaInsets() + { + // Get WindowInsets if available + var rootView = RootView; + if (rootView == null) + return SafeAreaPadding.Empty; + + var windowInsets = ViewCompat.GetRootWindowInsets(rootView); + if (windowInsets == null) + return SafeAreaPadding.Empty; + + var baseSafeArea = windowInsets.ToSafeAreaInsets(); + + // Apply safe area selectively per edge based on SafeAreaRegions + if (CrossPlatformLayout is ISafeAreaView2) + { + var left = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(0), baseSafeArea.Left); + var top = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(1), baseSafeArea.Top); + var right = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(2), baseSafeArea.Right); + var bottom = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(3), baseSafeArea.Bottom); + + return new SafeAreaPadding(left, right, top, bottom); + } + + // Legacy ISafeAreaView handling + if (CrossPlatformLayout is ISafeAreaView sav && sav.IgnoreSafeArea) + { + return SafeAreaPadding.Empty; + } + + return baseSafeArea; + } + + bool ValidateSafeArea() + { + if (!_safeAreaInvalidated) + return true; + + _safeAreaInvalidated = false; + + var oldSafeArea = _safeArea; + _safeArea = GetAdjustedSafeAreaInsets(); + + return oldSafeArea == _safeArea; + } + protected override void OnMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (CrossPlatformLayout is null) @@ -96,6 +211,12 @@ protected override void OnLayout(bool changed, int left, int top, int right, int var destination = _context.ToCrossPlatformRectInReferenceFrame(left, top, right, bottom); + // Apply safe area adjustments if needed + if (RespondsToSafeArea()) + { + destination = AdjustForSafeArea(destination); + } + CrossPlatformArrange(destination); } diff --git a/src/Core/src/Platform/Android/LayoutViewGroup.cs b/src/Core/src/Platform/Android/LayoutViewGroup.cs index 6b4f40dad1d2..995426747e0c 100644 --- a/src/Core/src/Platform/Android/LayoutViewGroup.cs +++ b/src/Core/src/Platform/Android/LayoutViewGroup.cs @@ -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; @@ -15,12 +16,15 @@ public class LayoutViewGroup : ViewGroup, ICrossPlatformLayoutBacking, IVisualTr { readonly ARect _clipRect = new(); readonly Context _context; + SafeAreaPadding _safeArea = SafeAreaPadding.Empty; + bool _safeAreaInvalidated = true; public bool InputTransparent { get; set; } public LayoutViewGroup(Context context) : base(context) { _context = context; + SetupWindowInsetsHandling(); } public LayoutViewGroup(IntPtr javaReference, JniHandleOwnership transfer) : base(javaReference, transfer) @@ -28,21 +32,35 @@ public LayoutViewGroup(IntPtr javaReference, JniHandleOwnership transfer) : base var context = Context; ArgumentNullException.ThrowIfNull(context); _context = context; + SetupWindowInsetsHandling(); } public LayoutViewGroup(Context context, IAttributeSet attrs) : base(context, attrs) { _context = context; + SetupWindowInsetsHandling(); } public LayoutViewGroup(Context context, IAttributeSet attrs, int defStyleAttr) : base(context, attrs, defStyleAttr) { _context = context; + SetupWindowInsetsHandling(); } public LayoutViewGroup(Context context, IAttributeSet attrs, int defStyleAttr, int defStyleRes) : base(context, attrs, defStyleAttr, defStyleRes) { _context = context; + SetupWindowInsetsHandling(); + } + + void SetupWindowInsetsHandling() + { + ViewCompat.SetOnApplyWindowInsetsListener(this, (view, insets) => + { + _safeAreaInvalidated = true; + RequestLayout(); + return insets; + }); } public bool ClipsToBounds { get; set; } @@ -62,6 +80,103 @@ Graphics.Size CrossPlatformArrange(Graphics.Rect bounds) return CrossPlatformLayout?.CrossPlatformArrange(bounds) ?? Graphics.Size.Zero; } + bool RespondsToSafeArea() + { + return CrossPlatformLayout is ISafeAreaView2; + } + + SafeAreaRegions GetSafeAreaRegionForEdge(int edge) + { + if (CrossPlatformLayout is ISafeAreaView2 safeAreaPage) + { + return safeAreaPage.GetSafeAreaRegionsForEdge(edge); + } + + // Fallback to legacy ISafeAreaView behavior + if (CrossPlatformLayout is ISafeAreaView sav) + { + return sav.IgnoreSafeArea ? SafeAreaRegions.None : SafeAreaRegions.Container; + } + + return SafeAreaRegions.None; + } + + static double GetSafeAreaForEdge(SafeAreaRegions safeAreaRegion, double originalSafeArea) + { + // Edge-to-edge content - no safe area padding + if (safeAreaRegion == SafeAreaRegions.None) + return 0; + + // All other regions respect safe area in some form + // This includes: + // - Default: Platform default behavior + // - All: Obey all safe area insets + // - SoftInput: Always pad for keyboard/soft input + // - Container: Content flows under keyboard but stays out of bars/notch + // - Any combination of the above flags + return originalSafeArea; + } + + Graphics.Rect AdjustForSafeArea(Graphics.Rect bounds) + { + ValidateSafeArea(); + + if (_safeArea.IsEmpty) + return bounds; + + return new Graphics.Rect( + bounds.X + _safeArea.Left, + bounds.Y + _safeArea.Top, + bounds.Width - _safeArea.HorizontalThickness, + bounds.Height - _safeArea.VerticalThickness); + } + + SafeAreaPadding GetAdjustedSafeAreaInsets() + { + // Get WindowInsets if available + var rootView = RootView; + if (rootView == null) + return SafeAreaPadding.Empty; + + var windowInsets = ViewCompat.GetRootWindowInsets(rootView); + if (windowInsets == null) + return SafeAreaPadding.Empty; + + var baseSafeArea = windowInsets.ToSafeAreaInsets(); + + // Apply safe area selectively per edge based on SafeAreaRegions + if (CrossPlatformLayout is ISafeAreaView2) + { + var left = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(0), baseSafeArea.Left); + var top = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(1), baseSafeArea.Top); + var right = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(2), baseSafeArea.Right); + var bottom = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(3), baseSafeArea.Bottom); + + return new SafeAreaPadding(left, right, top, bottom); + } + + // Legacy ISafeAreaView handling + if (CrossPlatformLayout is ISafeAreaView sav && sav.IgnoreSafeArea) + { + return SafeAreaPadding.Empty; + } + + return baseSafeArea; + } + + bool ValidateSafeArea() + { + if (!_safeAreaInvalidated) + return true; + + _safeAreaInvalidated = false; + + var oldSafeArea = _safeArea; + _safeArea = GetAdjustedSafeAreaInsets(); + + return oldSafeArea == _safeArea; + } + // TODO: Possibly reconcile this code with ViewHandlerExtensions.MeasureVirtualView // If you make changes here please review if those changes should also // apply to ViewHandlerExtensions.MeasureVirtualView @@ -108,6 +223,12 @@ protected override void OnLayout(bool changed, int l, int t, int r, int b) var destination = _context.ToCrossPlatformRectInReferenceFrame(l, t, r, b); + // Apply safe area adjustments if needed + if (RespondsToSafeArea()) + { + destination = AdjustForSafeArea(destination); + } + CrossPlatformArrange(destination); if (ClipsToBounds) diff --git a/src/Core/src/Platform/Android/SafeAreaPadding.cs b/src/Core/src/Platform/Android/SafeAreaPadding.cs new file mode 100644 index 000000000000..faf5e1f03167 --- /dev/null +++ b/src/Core/src/Platform/Android/SafeAreaPadding.cs @@ -0,0 +1,69 @@ +using System; +using Android.Graphics; +using AndroidX.Core.Graphics; +using AndroidX.Core.View; + +namespace Microsoft.Maui.Platform; + +internal readonly record struct SafeAreaPadding(double Left, double Right, double Top, double Bottom) +{ + public static SafeAreaPadding Empty { get; } = new(0, 0, 0, 0); + + public bool IsEmpty { get; } = Left == 0 && Right == 0 && Top == 0 && Bottom == 0; + public double HorizontalThickness { get; } = Left + Right; + public double VerticalThickness { get; } = Top + Bottom; + + public Rect InsetRect(Rect bounds) + { + if (IsEmpty) + { + return bounds; + } + + return new Rect( + (int)(bounds.Left + Left), + (int)(bounds.Top + Top), + (int)(bounds.Right - Right), + (int)(bounds.Bottom - Bottom)); + } + + public Insets ToInsets() => + Insets.Of((int)Left, (int)Top, (int)Right, (int)Bottom); +} + +internal static class WindowInsetsExtensions +{ + public static SafeAreaPadding ToSafeAreaInsets(this WindowInsetsCompat insets) + { + // Get system bars insets (status bar, navigation bar) + var systemBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars()); + + // Get display cutout insets if available (API 28+) + var displayCutout = insets.GetInsets(WindowInsetsCompat.Type.DisplayCutout()); + + // Combine insets, taking the maximum for each edge + return new( + Math.Max(systemBars.Left, displayCutout.Left), + Math.Max(systemBars.Right, displayCutout.Right), + Math.Max(systemBars.Top, displayCutout.Top), + Math.Max(systemBars.Bottom, displayCutout.Bottom) + ); + } + + public static SafeAreaPadding ToSafeAreaInsetsWithKeyboard(this WindowInsetsCompat insets) + { + // Get base safe area insets + var safeArea = insets.ToSafeAreaInsets(); + + // Get keyboard insets if available (API 30+) + var keyboard = insets.GetInsets(WindowInsetsCompat.Type.Ime()); + + // For keyboard, we only care about the bottom inset and take the maximum + return new( + safeArea.Left, + safeArea.Right, + safeArea.Top, + Math.Max(safeArea.Bottom, keyboard.Bottom) + ); + } +} \ No newline at end of file From 2bef5826c7ccfdde5efa75718941260f4719b818 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 31 Jul 2025 00:30:54 +0000 Subject: [PATCH 03/10] Add enhanced keyboard support and Android tests for SafeAreaEdges Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- .../Issues/SafeAreaAndroidTest.xaml | 137 +++++++++++++++++ .../Issues/SafeAreaAndroidTest.xaml.cs | 97 ++++++++++++ .../Tests/Issues/Issue28986Android.cs | 141 ++++++++++++++++++ .../src/Platform/Android/ContentViewGroup.cs | 26 +++- .../src/Platform/Android/LayoutViewGroup.cs | 26 +++- .../src/Platform/Android/SafeAreaPadding.cs | 9 ++ 6 files changed, 422 insertions(+), 14 deletions(-) create mode 100644 src/Controls/tests/TestCases.HostApp/Issues/SafeAreaAndroidTest.xaml create mode 100644 src/Controls/tests/TestCases.HostApp/Issues/SafeAreaAndroidTest.xaml.cs create mode 100644 src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue28986Android.cs diff --git a/src/Controls/tests/TestCases.HostApp/Issues/SafeAreaAndroidTest.xaml b/src/Controls/tests/TestCases.HostApp/Issues/SafeAreaAndroidTest.xaml new file mode 100644 index 000000000000..3ce594c20d2f --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/SafeAreaAndroidTest.xaml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + +