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 +}