diff --git a/src/Controls/src/Core/BindableObject.cs b/src/Controls/src/Core/BindableObject.cs
index e1b2d409257c..88502e8472a1 100644
--- a/src/Controls/src/Core/BindableObject.cs
+++ b/src/Controls/src/Core/BindableObject.cs
@@ -208,6 +208,19 @@ public bool IsSet(BindableProperty targetProperty)
return bpcontext.Values.GetSpecificity() != SetterSpecificity.DefaultValue;
}
+ ///
+ /// Determines whether a bindable property has been set by a local value, style, binding, or other non-default specificity.
+ /// Unlike IsSet, default-value creation does not count as explicit.
+ ///
+ /// The bindable property to check if a value is explicitly set.
+ /// if the target property exists and has been explicitly set. Otherwise .
+ /// Thrown when is .
+ internal bool IsSetExplicitly(BindableProperty targetProperty)
+ {
+ var bpcontext = GetContext(targetProperty ?? throw new ArgumentNullException(nameof(targetProperty)));
+ return bpcontext is not null && bpcontext.Values.GetSpecificity() != SetterSpecificity.DefaultValue;
+ }
+
///
/// Removes a previously set binding from a bindable property.
diff --git a/src/Controls/src/Core/Border/Border.cs b/src/Controls/src/Core/Border/Border.cs
index a50ac846e062..ed9e32587d50 100644
--- a/src/Controls/src/Core/Border/Border.cs
+++ b/src/Controls/src/Core/Border/Border.cs
@@ -485,6 +485,9 @@ SafeAreaRegions ISafeAreaView2.GetSafeAreaRegionsForEdge(int edge)
///
Thickness ISafeAreaView2.SafeAreaInsets { set { } } // Default no-op implementation for borders
+ ///
+ bool ISafeAreaView2.HasExplicitSafeAreaEdges => IsSetExplicitly(SafeAreaEdgesProperty);
+
///
/// Provides the default value for the property.
///
diff --git a/src/Controls/src/Core/ContentPage/ContentPage.cs b/src/Controls/src/Core/ContentPage/ContentPage.cs
index f98ada09459f..c34fa417c3d3 100644
--- a/src/Controls/src/Core/ContentPage/ContentPage.cs
+++ b/src/Controls/src/Core/ContentPage/ContentPage.cs
@@ -170,6 +170,9 @@ Size IContentView.CrossPlatformMeasure(double widthConstraint, double heightCons
return (this as ICrossPlatformLayout).CrossPlatformMeasure(widthConstraint, heightConstraint);
}
+ ///
+ bool ISafeAreaView2.HasExplicitSafeAreaEdges => IsSetExplicitly(SafeAreaEdgesProperty);
+
///
SafeAreaRegions ISafeAreaView2.GetSafeAreaRegionsForEdge(int edge)
{
diff --git a/src/Controls/src/Core/ContentView/ContentView.cs b/src/Controls/src/Core/ContentView/ContentView.cs
index ea256fa81f69..e59d177552d0 100644
--- a/src/Controls/src/Core/ContentView/ContentView.cs
+++ b/src/Controls/src/Core/ContentView/ContentView.cs
@@ -91,6 +91,9 @@ private protected override string GetDebuggerDisplay()
///
Thickness ISafeAreaView2.SafeAreaInsets { set { } } // Default no-op implementation for content views
+ ///
+ bool ISafeAreaView2.HasExplicitSafeAreaEdges => IsSetExplicitly(SafeAreaEdgesProperty);
+
///
SafeAreaRegions ISafeAreaView2.GetSafeAreaRegionsForEdge(int edge)
{
diff --git a/src/Controls/src/Core/Handlers/Items/Android/Adapters/EmptyViewAdapter.cs b/src/Controls/src/Core/Handlers/Items/Android/Adapters/EmptyViewAdapter.cs
index 449ffeaf72d1..e8ddf4779e38 100644
--- a/src/Controls/src/Core/Handlers/Items/Android/Adapters/EmptyViewAdapter.cs
+++ b/src/Controls/src/Core/Handlers/Items/Android/Adapters/EmptyViewAdapter.cs
@@ -235,14 +235,14 @@ protected RecyclerView.ViewHolder CreateEmptyViewHolder(object content, DataTemp
if (content is not View formsView)
{
// No template, EmptyView is not a Forms View, so just display EmptyView.ToString
- return SimpleViewHolder.FromText(content?.ToString(), context, () => GetWidth(parent), () => GetHeight(parent), ItemsView);
+ return SimpleViewHolder.FromText(content?.ToString(), context, () => GetWidth(parent), () => GetHeight(parent), ItemsView, isEmptyView: true);
}
// EmptyView is a Forms View; display that
- return SimpleViewHolder.FromFormsView(formsView, context, () => GetWidth(parent), () => GetHeight(parent), ItemsView);
+ return SimpleViewHolder.FromFormsView(formsView, context, () => GetWidth(parent), () => GetHeight(parent), ItemsView, isEmptyView: true);
}
- var itemContentView = new SizedItemContentView(parent.Context, () => GetWidth(parent), () => GetHeight(parent));
+ var itemContentView = new EmptyViewContentView(parent.Context, () => GetWidth(parent), () => GetHeight(parent));
return new TemplatedItemViewHolder(itemContentView, template, isSelectionEnabled: false);
}
diff --git a/src/Controls/src/Core/Handlers/Items/Android/SimpleViewHolder.cs b/src/Controls/src/Core/Handlers/Items/Android/SimpleViewHolder.cs
index 848579ca101b..fe572940fb2a 100644
--- a/src/Controls/src/Core/Handlers/Items/Android/SimpleViewHolder.cs
+++ b/src/Controls/src/Core/Handlers/Items/Android/SimpleViewHolder.cs
@@ -27,14 +27,14 @@ public void Recycle(ItemsView itemsView)
itemsView.RemoveLogicalChild(View);
}
- public static SimpleViewHolder FromText(string text, Context context, Func width = null, Func height = null, ItemsView container = null, bool fill = true)
+ public static SimpleViewHolder FromText(string text, Context context, Func width = null, Func height = null, ItemsView container = null, bool fill = true, bool isEmptyView = false)
{
if (fill)
{
// When displaying an EmptyView with Header and Footer, we need to account for the Header and Footer sizes in layout calculations.
// This prevents the EmptyView from occupying the full remaining space.
Label label = new Label() { Text = text, VerticalOptions = LayoutOptions.Center, HorizontalOptions = LayoutOptions.Center };
- SizedItemContentView itemContentControl = new SizedItemContentView(context, width, height);
+ SizedItemContentView itemContentControl = CreateSizedItemContentView(context, width, height, isEmptyView);
itemContentControl.RealizeContent(label, container);
return new SimpleViewHolder(itemContentControl, null);
}
@@ -43,9 +43,9 @@ public static SimpleViewHolder FromText(string text, Context context, Func width, Func height, ItemsView container)
+ public static SimpleViewHolder FromFormsView(View formsView, Context context, Func width, Func height, ItemsView container, bool isEmptyView = false)
{
- var itemContentControl = new SizedItemContentView(context, width, height);
+ var itemContentControl = CreateSizedItemContentView(context, width, height, isEmptyView);
// Make sure the Visual property is available during renderer creation
Internals.PropertyPropagationExtensions.PropagatePropertyChanged(null, formsView, container);
@@ -60,5 +60,12 @@ public static SimpleViewHolder FromFormsView(View formsView, Context context, It
itemContentControl.RealizeContent(formsView, container);
return new SimpleViewHolder(itemContentControl, formsView);
}
+
+ static SizedItemContentView CreateSizedItemContentView(Context context, Func width, Func height, bool isEmptyView)
+ {
+ return isEmptyView
+ ? new EmptyViewContentView(context, width, height)
+ : new SizedItemContentView(context, width, height);
+ }
}
-}
\ No newline at end of file
+}
diff --git a/src/Controls/src/Core/Handlers/Items/Android/SizedItemContentView.cs b/src/Controls/src/Core/Handlers/Items/Android/SizedItemContentView.cs
index 6f9278355833..dc9e3107de96 100644
--- a/src/Controls/src/Core/Handlers/Items/Android/SizedItemContentView.cs
+++ b/src/Controls/src/Core/Handlers/Items/Android/SizedItemContentView.cs
@@ -51,4 +51,12 @@ protected override void OnMeasure(int widthMeasureSpec, int heightMeasureSpec)
static double NormalizeDimension(double value) => value == int.MaxValue ? double.PositiveInfinity : value;
}
+
+ internal class EmptyViewContentView : SizedItemContentView, IMauiRecyclerViewEmptyView
+ {
+ public EmptyViewContentView(Context context, Func width, Func height)
+ : base(context, width, height)
+ {
+ }
+ }
}
diff --git a/src/Controls/src/Core/Layout/Layout.cs b/src/Controls/src/Core/Layout/Layout.cs
index 14e83aa3e1b9..7f7d63261256 100644
--- a/src/Controls/src/Core/Layout/Layout.cs
+++ b/src/Controls/src/Core/Layout/Layout.cs
@@ -411,6 +411,9 @@ private protected override string GetDebuggerDisplay()
///
Thickness ISafeAreaView2.SafeAreaInsets { set { } } // Default no-op implementation for layouts
+ ///
+ bool ISafeAreaView2.HasExplicitSafeAreaEdges => IsSetExplicitly(SafeAreaEdgesProperty);
+
///
SafeAreaRegions ISafeAreaView2.GetSafeAreaRegionsForEdge(int edge)
{
diff --git a/src/Controls/src/Core/Page/Page.cs b/src/Controls/src/Core/Page/Page.cs
index 9ee57d8359ae..95c8571692d3 100644
--- a/src/Controls/src/Core/Page/Page.cs
+++ b/src/Controls/src/Core/Page/Page.cs
@@ -276,6 +276,10 @@ SafeAreaRegions ISafeAreaView2.GetSafeAreaRegionsForEdge(int edge)
}
}
+ ///
+ // Base Page uses legacy IgnoreSafeArea behavior, not the SafeAreaEdges property.
+ bool ISafeAreaView2.HasExplicitSafeAreaEdges => false;
+
///
/// Raised when the children of this page, and thus potentially the layout, have changed.
///
diff --git a/src/Controls/src/Core/ScrollView/ScrollView.cs b/src/Controls/src/Core/ScrollView/ScrollView.cs
index 075b9b9d9734..ac7d843dfe76 100644
--- a/src/Controls/src/Core/ScrollView/ScrollView.cs
+++ b/src/Controls/src/Core/ScrollView/ScrollView.cs
@@ -566,6 +566,9 @@ Thickness ISafeAreaView2.SafeAreaInsets
}
}
+ ///
+ bool ISafeAreaView2.HasExplicitSafeAreaEdges => IsSetExplicitly(SafeAreaEdgesProperty);
+
///
SafeAreaRegions ISafeAreaView2.GetSafeAreaRegionsForEdge(int edge)
{
diff --git a/src/Controls/tests/Core.UnitTests/SafeAreaTests.cs b/src/Controls/tests/Core.UnitTests/SafeAreaTests.cs
index ad0e8f1e5b65..caedfc543dc4 100644
--- a/src/Controls/tests/Core.UnitTests/SafeAreaTests.cs
+++ b/src/Controls/tests/Core.UnitTests/SafeAreaTests.cs
@@ -8,6 +8,16 @@ namespace Microsoft.Maui.Controls.Core.UnitTests
{
public class SafeAreaTests : BaseTestFixture
{
+ public static TheoryData SafeAreaView2Types =>
+ new()
+ {
+ typeof(Grid),
+ typeof(ContentView),
+ typeof(ContentPage),
+ typeof(Border),
+ typeof(ScrollView),
+ };
+
[Fact]
public void GetEdges_DefaultValue_ReturnsDefault()
{
@@ -240,6 +250,81 @@ public void ContentView_ImplementsISafeAreaView()
Assert.IsAssignableFrom(contentView);
}
+ [Theory]
+ [MemberData(nameof(SafeAreaView2Types))]
+ public void HasExplicitSafeAreaEdges_DefaultValueCreationDoesNotCountAsExplicit(Type safeAreaViewType)
+ {
+ var view = CreateSafeAreaBindable(safeAreaViewType);
+ var safeAreaView2 = (ISafeAreaView2)view;
+
+ Assert.False(safeAreaView2.HasExplicitSafeAreaEdges);
+
+ _ = ((ISafeAreaElement)view).SafeAreaEdges;
+
+ Assert.True(view.IsSet(SafeAreaElement.SafeAreaEdgesProperty));
+ Assert.False(safeAreaView2.HasExplicitSafeAreaEdges);
+ }
+
+ [Theory]
+ [MemberData(nameof(SafeAreaView2Types))]
+ public void HasExplicitSafeAreaEdges_ExplicitNoneCountsAsExplicit(Type safeAreaViewType)
+ {
+ var view = CreateSafeAreaBindable(safeAreaViewType);
+
+ view.SetValue(SafeAreaElement.SafeAreaEdgesProperty, SafeAreaEdges.None);
+
+ Assert.True(((ISafeAreaView2)view).HasExplicitSafeAreaEdges);
+ }
+
+ [Fact]
+ public void HasExplicitSafeAreaEdges_StyleValueCountsAsExplicit()
+ {
+ var layout = new Grid
+ {
+ Style = new Style(typeof(Grid))
+ {
+ Setters =
+ {
+ new Setter
+ {
+ Property = Layout.SafeAreaEdgesProperty,
+ Value = SafeAreaEdges.All
+ }
+ }
+ }
+ };
+
+ Assert.True(((ISafeAreaView2)layout).HasExplicitSafeAreaEdges);
+ }
+
+ [Fact]
+ public void HasExplicitSafeAreaEdges_BindingValueCountsAsExplicit()
+ {
+ var layout = new Grid();
+ layout.SetBinding(Layout.SafeAreaEdgesProperty, nameof(SafeAreaBindingContext.Edges));
+ layout.BindingContext = new SafeAreaBindingContext { Edges = SafeAreaEdges.All };
+
+ Assert.True(((ISafeAreaView2)layout).HasExplicitSafeAreaEdges);
+ }
+
+ [Fact]
+ public void HasExplicitSafeAreaEdges_BasePageDoesNotCountLegacySafeAreaAsExplicit()
+ {
+ var page = new Page();
+
+ Assert.False(((ISafeAreaView2)page).HasExplicitSafeAreaEdges);
+ }
+
+ class SafeAreaBindingContext
+ {
+ public SafeAreaEdges Edges { get; set; }
+ }
+
+ static BindableObject CreateSafeAreaBindable(Type safeAreaViewType)
+ {
+ return (BindableObject)Activator.CreateInstance(safeAreaViewType);
+ }
+
[Fact]
public void Page_GetSafeAreaRegionsForEdge_UsesDirectProperty()
{
diff --git a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Android.cs
index 5e31a56dfa71..4976a590d4a0 100644
--- a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Android.cs
+++ b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Android.cs
@@ -2,17 +2,33 @@
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
+using Android.Content;
+using Android.Widget;
+using AndroidX.Core.View;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Handlers.Items;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform;
using Xunit;
+using AInsets = AndroidX.Core.Graphics.Insets;
+using AView = Android.Views.View;
namespace Microsoft.Maui.DeviceTests
{
public partial class CollectionViewTests : ControlsHandlerTestBase
{
+ public static TheoryData SafeAreaItemViewTypes
+ {
+ get
+ {
+ var data = new TheoryData();
+ data.Add(typeof(LayoutViewGroup));
+ data.Add(typeof(ContentViewGroup));
+ return data;
+ }
+ }
+
[Fact]
public async Task PushAndPopPageWithCollectionView()
{
@@ -244,6 +260,216 @@ await InvokeOnMainThreadAsync(() =>
});
}
+ [Theory]
+ [MemberData(nameof(SafeAreaItemViewTypes))]
+ public async Task RecyclerItemWithoutExplicitSafeAreaEdgesDoesNotUseInsetListener(System.Type itemViewType)
+ {
+ SetupBuilder();
+
+ await InvokeOnMainThreadAsync(() =>
+ {
+ var layout = new Grid();
+ var root = CreateRecyclerSafeAreaHierarchy(itemViewType, layout, out var itemView, out _);
+
+ try
+ {
+ Assert.False(((ISafeAreaView2)layout).HasExplicitSafeAreaEdges);
+ Assert.False(MauiWindowInsetListener.ShouldSetMauiWindowInsetListener(itemView));
+ Assert.False(MauiWindowInsetListenerExtensions.TrySetMauiWindowInsetListener(itemView, MauiContext.Context));
+ Assert.Null(MauiWindowInsetListener.FindListenerForView(itemView));
+ }
+ finally
+ {
+ MauiWindowInsetListener.RemoveViewWithLocalListener(root);
+ }
+ });
+ }
+
+ [Theory]
+ [MemberData(nameof(SafeAreaItemViewTypes))]
+ public async Task RecyclerItemWithExplicitSafeAreaEdgesUsesInsetListener(System.Type itemViewType)
+ {
+ SetupBuilder();
+
+ await InvokeOnMainThreadAsync(() =>
+ {
+ var layout = new Grid
+ {
+ SafeAreaEdges = SafeAreaEdges.None
+ };
+ var root = CreateRecyclerSafeAreaHierarchy(itemViewType, layout, out var itemView, out var listener);
+
+ try
+ {
+ Assert.True(((ISafeAreaView2)layout).HasExplicitSafeAreaEdges);
+ Assert.True(MauiWindowInsetListener.ShouldSetMauiWindowInsetListener(itemView));
+ Assert.True(MauiWindowInsetListenerExtensions.TrySetMauiWindowInsetListener(itemView, MauiContext.Context));
+ Assert.Same(listener, MauiWindowInsetListener.FindListenerForView(itemView));
+ }
+ finally
+ {
+ MauiWindowInsetListener.RemoveViewWithLocalListener(root);
+ }
+ });
+ }
+
+ [Theory]
+ [MemberData(nameof(SafeAreaItemViewTypes))]
+ public async Task RecyclerEmptyViewWithoutExplicitSafeAreaEdgesUsesInsetListener(System.Type itemViewType)
+ {
+ SetupBuilder();
+
+ await InvokeOnMainThreadAsync(() =>
+ {
+ var layout = new Grid();
+ var root = CreateRecyclerSafeAreaHierarchy(itemViewType, layout, out var itemView, out var listener, wrapInEmptyView: true);
+
+ try
+ {
+ Assert.False(((ISafeAreaView2)layout).HasExplicitSafeAreaEdges);
+ Assert.True(MauiWindowInsetListener.ShouldSetMauiWindowInsetListener(itemView));
+ Assert.True(MauiWindowInsetListenerExtensions.TrySetMauiWindowInsetListener(itemView, MauiContext.Context));
+ Assert.Same(listener, MauiWindowInsetListener.FindListenerForView(itemView));
+ }
+ finally
+ {
+ MauiWindowInsetListener.RemoveViewWithLocalListener(root);
+ }
+ });
+ }
+
+ [Theory]
+ [MemberData(nameof(SafeAreaItemViewTypes))]
+ public async Task RecyclerItemSafeAreaRefreshAttachesWhenSafeAreaEdgesBecomesExplicit(System.Type itemViewType)
+ {
+ SetupBuilder();
+
+ await InvokeOnMainThreadAsync(() =>
+ {
+ var layout = new Grid();
+ var root = CreateRecyclerSafeAreaHierarchy(itemViewType, layout, out var itemView, out var listener);
+
+ try
+ {
+ Assert.False(MauiWindowInsetListenerExtensions.TrySetMauiWindowInsetListener(itemView, MauiContext.Context));
+
+ layout.SafeAreaEdges = SafeAreaEdges.None;
+
+ Assert.True(MauiWindowInsetListenerExtensions.RefreshMauiWindowInsetListener(itemView, MauiContext.Context));
+ Assert.Same(listener, MauiWindowInsetListener.FindListenerForView(itemView));
+ }
+ finally
+ {
+ MauiWindowInsetListener.RemoveViewWithLocalListener(root);
+ }
+ });
+ }
+
+ [Theory]
+ [MemberData(nameof(SafeAreaItemViewTypes))]
+ public async Task RecyclerItemSafeAreaRefreshResetsWhenSafeAreaEdgesIsCleared(System.Type itemViewType)
+ {
+ SetupBuilder();
+
+ await InvokeOnMainThreadAsync(() =>
+ {
+ var layout = new Grid
+ {
+ SafeAreaEdges = SafeAreaEdges.All
+ };
+ var root = CreateRecyclerSafeAreaHierarchy(itemViewType, layout, out var itemView, out _);
+
+ try
+ {
+ itemView.SetPadding(1, 2, 3, 4);
+ Assert.True(MauiWindowInsetListenerExtensions.TrySetMauiWindowInsetListener(itemView, MauiContext.Context));
+
+ var insets = new WindowInsetsCompat.Builder()
+ .SetInsets(WindowInsetsCompat.Type.SystemBars(), AInsets.Of(0, 20, 0, 0))
+ .Build();
+ ((IHandleWindowInsets)itemView).HandleWindowInsets(itemView, insets);
+ Assert.NotEqual(2, itemView.PaddingTop);
+
+ layout.ClearValue(Layout.SafeAreaEdgesProperty);
+
+ Assert.False(((ISafeAreaView2)layout).HasExplicitSafeAreaEdges);
+ Assert.False(MauiWindowInsetListenerExtensions.RefreshMauiWindowInsetListener(itemView, MauiContext.Context));
+ Assert.Null(MauiWindowInsetListener.FindListenerForView(itemView));
+ Assert.Equal(1, itemView.PaddingLeft);
+ Assert.Equal(2, itemView.PaddingTop);
+ Assert.Equal(3, itemView.PaddingRight);
+ Assert.Equal(4, itemView.PaddingBottom);
+ }
+ finally
+ {
+ MauiWindowInsetListener.RemoveViewWithLocalListener(root);
+ }
+ });
+ }
+
+ [Fact]
+ public async Task RecyclerItemSafeAreaEdgesChangeThroughHandlerAppliesAndResetsPadding()
+ {
+ SetupBuilder();
+
+ Grid itemLayout = null;
+ var collectionView = new CollectionView
+ {
+ ItemsSource = new[] { "Item 1" },
+ ItemTemplate = new DataTemplate(() =>
+ {
+ itemLayout = new Grid
+ {
+ HeightRequest = 60,
+ WidthRequest = 60
+ };
+ itemLayout.Add(new Label { Text = "Item 1" });
+ return itemLayout;
+ }),
+ HeightRequest = 120,
+ WidthRequest = 120
+ };
+ var frame = collectionView.Frame;
+
+ await CreateHandlerAndAddToWindow(collectionView, async handler =>
+ {
+ await WaitForUIUpdate(frame, collectionView);
+ _ = LayoutAndGetViewHolder(handler.PlatformView);
+
+ Assert.NotNull(itemLayout);
+ var itemPlatformView = Assert.IsType(itemLayout.ToPlatform());
+ Assert.NotNull(MauiWindowInsetListener.FindRegisteredListenerForView(itemPlatformView));
+ Assert.False(((ISafeAreaView2)itemLayout).HasExplicitSafeAreaEdges);
+ Assert.Null(MauiWindowInsetListener.FindListenerForView(itemPlatformView));
+
+ itemPlatformView.SetPadding(1, 2, 3, 4);
+ var insets = CreateLeftSystemBarInsetOverlapping(itemPlatformView, 20);
+
+ ViewCompat.DispatchApplyWindowInsets(itemPlatformView, insets);
+ Assert.Equal(1, itemPlatformView.PaddingLeft);
+
+ itemLayout.SafeAreaEdges = SafeAreaEdges.All;
+
+ Assert.True(((ISafeAreaView2)itemLayout).HasExplicitSafeAreaEdges);
+ Assert.NotNull(MauiWindowInsetListener.FindListenerForView(itemPlatformView));
+
+ ViewCompat.DispatchApplyWindowInsets(itemPlatformView, insets);
+ Assert.NotEqual(1, itemPlatformView.PaddingLeft);
+
+ itemLayout.ClearValue(Layout.SafeAreaEdgesProperty);
+
+ Assert.False(((ISafeAreaView2)itemLayout).HasExplicitSafeAreaEdges);
+ Assert.Null(MauiWindowInsetListener.FindListenerForView(itemPlatformView));
+ Assert.Equal(1, itemPlatformView.PaddingLeft);
+ Assert.Equal(2, itemPlatformView.PaddingTop);
+ Assert.Equal(3, itemPlatformView.PaddingRight);
+ Assert.Equal(4, itemPlatformView.PaddingBottom);
+
+ ViewCompat.DispatchApplyWindowInsets(itemPlatformView, insets);
+ Assert.Equal(1, itemPlatformView.PaddingLeft);
+ });
+ }
+
[Fact(DisplayName = "Grouped CollectionView header rebind does not grow logical children")]
public async Task GroupHeaderRebindDoesNotGrowLogicalChildren()
{
@@ -303,7 +529,7 @@ await InvokeOnMainThreadAsync(() =>
var adapter = handler.PlatformView.GetAdapter();
var footerViewType = adapter.GetItemViewType(footerPosition);
- Assert.Equal(ItemViewType.GroupFooter, footerViewType);
+ Assert.Equal(Microsoft.Maui.Controls.Handlers.Items.ItemViewType.GroupFooter, footerViewType);
var footerHolder = adapter.OnCreateViewHolder(handler.PlatformView, footerViewType);
adapter.OnBindViewHolder(footerHolder, footerPosition);
@@ -383,5 +609,67 @@ Rect GetCollectionViewCellBounds(IView cellContent)
return cellContent.ToPlatform().GetParentOfType().GetBoundingBox();
}
+
+ FrameLayout CreateRecyclerSafeAreaHierarchy(System.Type itemViewType, ICrossPlatformLayout layout, out AView itemView, out MauiWindowInsetListener listener, bool wrapInEmptyView = false)
+ {
+ var context = MauiContext.Context;
+ var root = new FrameLayout(context);
+ var recyclerView = new TestRecyclerView(context);
+ itemView = CreateSafeAreaItemView(itemViewType, context, layout);
+
+ root.AddView(recyclerView);
+
+ if (wrapInEmptyView)
+ {
+ var emptyView = new TestRecyclerEmptyView(context);
+ recyclerView.AddView(emptyView);
+ emptyView.AddView(itemView);
+ }
+ else
+ {
+ recyclerView.AddView(itemView);
+ }
+
+ listener = MauiWindowInsetListener.RegisterParentForChildViews(root);
+
+ return root;
+ }
+
+ static AView CreateSafeAreaItemView(System.Type itemViewType, Context context, ICrossPlatformLayout layout)
+ {
+ AView itemView = itemViewType == typeof(LayoutViewGroup)
+ ? new LayoutViewGroup(context)
+ : itemViewType == typeof(ContentViewGroup)
+ ? new ContentViewGroup(context)
+ : throw new System.ArgumentOutOfRangeException(nameof(itemViewType), itemViewType, null);
+
+ ((ICrossPlatformLayoutBacking)itemView).CrossPlatformLayout = layout;
+ return itemView;
+ }
+
+ static WindowInsetsCompat CreateLeftSystemBarInsetOverlapping(AView view, int overlap)
+ {
+ var location = new int[2];
+ view.GetLocationOnScreen(location);
+ var leftInset = System.Math.Max(overlap, location[0] + overlap);
+
+ return new WindowInsetsCompat.Builder()
+ .SetInsets(WindowInsetsCompat.Type.SystemBars(), AInsets.Of(leftInset, 0, 0, 0))
+ .Build();
+ }
+
+ class TestRecyclerView : FrameLayout, IMauiRecyclerView
+ {
+ public TestRecyclerView(Context context) : base(context)
+ {
+ }
+ }
+
+ class TestRecyclerEmptyView : FrameLayout, IMauiRecyclerViewEmptyView
+ {
+ public TestRecyclerEmptyView(Context context) : base(context)
+ {
+ }
+ }
}
}
diff --git a/src/Core/src/Core/IMauiRecyclerView.cs b/src/Core/src/Core/IMauiRecyclerView.cs
index 7fc4632a4326..75c9b0940c4e 100644
--- a/src/Core/src/Core/IMauiRecyclerView.cs
+++ b/src/Core/src/Core/IMauiRecyclerView.cs
@@ -5,4 +5,8 @@ namespace Microsoft.Maui
internal interface IMauiRecyclerView
{
}
+
+ internal interface IMauiRecyclerViewEmptyView
+ {
+ }
}
diff --git a/src/Core/src/Core/ISafeAreaView2.cs b/src/Core/src/Core/ISafeAreaView2.cs
index 6ded20b81480..347b2a32cb2c 100644
--- a/src/Core/src/Core/ISafeAreaView2.cs
+++ b/src/Core/src/Core/ISafeAreaView2.cs
@@ -3,11 +3,13 @@
///
/// Provides functionality for the Page's SafeAreaInsets that may be changed in the future.
///
- ///
- /// This interface is only recognized on the iOS/Mac Catalyst platforms; other platforms will ignore it.
- ///
internal interface ISafeAreaView2
{
+ ///
+ /// Gets whether the view has an explicitly configured SafeAreaEdges value.
+ ///
+ bool HasExplicitSafeAreaEdges { get; }
+
///
/// Internal property for the Page's SafeAreaInsets Thickness that may be changed in the future.
///
diff --git a/src/Core/src/Platform/Android/ContentViewGroup.cs b/src/Core/src/Platform/Android/ContentViewGroup.cs
index ef7a195828d3..2a58cdaaaa09 100644
--- a/src/Core/src/Platform/Android/ContentViewGroup.cs
+++ b/src/Core/src/Platform/Android/ContentViewGroup.cs
@@ -173,6 +173,7 @@ protected override void OnConfigurationChanged(Configuration? newConfig)
///
internal void MarkSafeAreaEdgeConfigurationChanged()
{
+ _isInsetListenerSet = MauiWindowInsetListenerExtensions.RefreshMauiWindowInsetListener(this, _context);
_didSafeAreaEdgeConfigurationChange = true;
// Ensure a layout pass so that OnLayout will trigger InvalidateWindowInsets
RequestLayout();
diff --git a/src/Core/src/Platform/Android/LayoutViewGroup.cs b/src/Core/src/Platform/Android/LayoutViewGroup.cs
index b7e1361299a2..28afa45d7c42 100644
--- a/src/Core/src/Platform/Android/LayoutViewGroup.cs
+++ b/src/Core/src/Platform/Android/LayoutViewGroup.cs
@@ -190,6 +190,7 @@ protected override void OnConfigurationChanged(Configuration? newConfig)
///
internal void MarkSafeAreaEdgeConfigurationChanged()
{
+ _isInsetListenerSet = MauiWindowInsetListenerExtensions.RefreshMauiWindowInsetListener(this, _context);
_didSafeAreaEdgeConfigurationChange = true;
RequestLayout();
}
diff --git a/src/Core/src/Platform/Android/MauiScrollView.cs b/src/Core/src/Platform/Android/MauiScrollView.cs
index 66e3eeed4d06..c45d4ef4494b 100644
--- a/src/Core/src/Platform/Android/MauiScrollView.cs
+++ b/src/Core/src/Platform/Android/MauiScrollView.cs
@@ -385,6 +385,7 @@ protected override void OnConfigurationChanged(Configuration? newConfig)
///
internal void MarkSafeAreaEdgeConfigurationChanged()
{
+ _isInsetListenerSet = MauiWindowInsetListenerExtensions.RefreshMauiWindowInsetListener(this, _context);
_didSafeAreaEdgeConfigurationChange = true;
RequestLayout();
}
diff --git a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs
index 0296cbef62db..57d9ee07c788 100644
--- a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs
+++ b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs
@@ -93,37 +93,26 @@ internal void RegisterView(AView view)
/// The view to find a listener for
/// The local listener if view is in a registered view hierarchy, null otherwise
internal static MauiWindowInsetListener? FindListenerForView(AView view)
+ {
+ if (!ShouldSetMauiWindowInsetListener(view))
+ {
+ return null;
+ }
+
+ return FindRegisteredListenerForView(view);
+ }
+
+ internal static MauiWindowInsetListener? FindRegisteredListenerForView(AView view)
{
// Walk up the view hierarchy looking for a registered view
var parent = view.Parent;
while (parent is not null)
{
- // Skip setting listener on views inside nested scroll containers or AppBarLayout (except MaterialToolbar)
- // We want the layout listener logic to get applied to the MaterialToolbar itself
- // But we don't want any layout listeners to get applied to the children of MaterialToolbar (like the TitleView)
- // CollectionView/CarouselView items are not excluded to enable per-item SafeAreaEdges control.
- // Performance overhead is negligible due to early pass-through for items without insets.
- if (view is not MaterialToolbar &&
- (parent is AppBarLayout || parent is MauiScrollView))
- {
- return null;
- }
-
if (parent is AView parentView)
{
- // Check if this parent view is registered
- // Clean up dead references while searching
- for (int i = _registeredViews.Count - 1; i >= 0; i--)
+ if (FindRegisteredListener(parentView) is MauiWindowInsetListener listener)
{
- var entry = _registeredViews[i];
- if (!entry.View.TryGetTarget(out var registeredView))
- {
- _registeredViews.RemoveAt(i);
- }
- else if (ReferenceEquals(registeredView, parentView))
- {
- return entry.Listener;
- }
+ return listener;
}
}
@@ -133,6 +122,60 @@ internal void RegisterView(AView view)
return null;
}
+ internal static bool ShouldSetMauiWindowInsetListener(AView view)
+ {
+ var parent = view.Parent;
+ var isInsideRecyclerEmptyView = false;
+
+ while (parent is not null)
+ {
+ if (parent is IMauiRecyclerViewEmptyView)
+ {
+ isInsideRecyclerEmptyView = true;
+ }
+
+ // MaterialToolbar needs its own inset handling, so it is exempt from all listener-suppression branches.
+ // Skip listeners for views inside AppBarLayout/MauiScrollView, and for recycler item views
+ // unless SafeAreaEdges was explicitly set.
+ if (view is not MaterialToolbar &&
+ (parent is AppBarLayout ||
+ parent is MauiScrollView ||
+ (parent is IMauiRecyclerView && !isInsideRecyclerEmptyView && !HasExplicitSafeAreaEdges(view))))
+ {
+ return false;
+ }
+
+ parent = parent.Parent;
+ }
+
+ return true;
+ }
+
+ static MauiWindowInsetListener? FindRegisteredListener(AView parentView)
+ {
+ // Check if this parent view is registered. Clean up dead references while searching.
+ for (int i = _registeredViews.Count - 1; i >= 0; i--)
+ {
+ var entry = _registeredViews[i];
+ if (!entry.View.TryGetTarget(out var registeredView))
+ {
+ _registeredViews.RemoveAt(i);
+ }
+ else if (ReferenceEquals(registeredView, parentView))
+ {
+ return entry.Listener;
+ }
+ }
+
+ return null;
+ }
+
+ static bool HasExplicitSafeAreaEdges(AView view)
+ {
+ return view is ICrossPlatformLayoutBacking { CrossPlatformLayout: ISafeAreaView2 safeAreaView } &&
+ safeAreaView.HasExplicitSafeAreaEdges;
+ }
+
///
/// Sets up a view to use this listener for inset handling.
/// This method registers the view and attaches the listener.
@@ -498,7 +541,6 @@ internal static class MauiWindowInsetListenerExtensions
/// The Android context to get the listener from
public static bool TrySetMauiWindowInsetListener(this View view, Context context)
{
- // Check if this view is contained within a registered view first
if (MauiWindowInsetListener.FindListenerForView(view) is MauiWindowInsetListener localListener)
{
ViewCompat.SetOnApplyWindowInsetsListener(view, localListener);
@@ -510,6 +552,37 @@ public static bool TrySetMauiWindowInsetListener(this View view, Context context
return false;
}
+ ///
+ /// Refreshes the MauiWindowInsetListener attached to the specified view after SafeAreaEdges eligibility changes.
+ /// Unlike TrySetMauiWindowInsetListener, this finds the registered parent listener before applying
+ /// eligibility checks so it can detach the listener and reset applied safe areas when the view is
+ /// no longer eligible.
+ ///
+ /// The Android view to refresh the listener on
+ /// The Android context to get the listener from
+ public static bool RefreshMauiWindowInsetListener(this View view, Context context)
+ {
+ var listener = MauiWindowInsetListener.FindRegisteredListenerForView(view);
+ if (listener is null)
+ {
+ ViewCompat.SetOnApplyWindowInsetsListener(view, null);
+ ViewCompat.SetWindowInsetsAnimationCallback(view, null);
+ return false;
+ }
+
+ if (MauiWindowInsetListener.ShouldSetMauiWindowInsetListener(view))
+ {
+ ViewCompat.SetOnApplyWindowInsetsListener(view, listener);
+ ViewCompat.SetWindowInsetsAnimationCallback(view, listener);
+ return true;
+ }
+
+ ViewCompat.SetOnApplyWindowInsetsListener(view, null);
+ ViewCompat.SetWindowInsetsAnimationCallback(view, null);
+ listener.ResetAppliedSafeAreas(view);
+ return false;
+ }
+
///
/// Removes the MauiWindowInsetListener from the specified view and resets its tracked state.
/// This should be called when a view is being detached to ensure proper cleanup.
@@ -523,7 +596,7 @@ public static void RemoveMauiWindowInsetListener(this View view, Context context
ViewCompat.SetWindowInsetsAnimationCallback(view, null);
// Reset view state - prefer local listener if available, otherwise use global
- var listener = MauiWindowInsetListener.FindListenerForView(view);
+ var listener = MauiWindowInsetListener.FindRegisteredListenerForView(view);
listener?.ResetView(view);
}
-}
\ No newline at end of file
+}