-
Notifications
You must be signed in to change notification settings - Fork 2k
[Android] Fix increasing bottom gap in CollectionView while scrolling #35457
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| using System; | ||
| using System.Collections.Concurrent; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using Android.Content; | ||
|
|
@@ -91,20 +92,40 @@ | |
| /// Must be called on UI thread. | ||
| /// </summary> | ||
| /// <param name="view">The view to find a listener for</param> | ||
| /// <param name="applyRecyclerViewGate"> | ||
| /// When true (attachment only), skips RecyclerView data-item views with default SafeAreaEdges | ||
| /// to prevent recycled views from retaining stale inset-derived padding (#34634/#34635). | ||
| /// Reset/cleanup callers must pass false (the default) so they can always find listeners | ||
| /// to clear stale padding regardless of the currently bound element's SafeAreaEdges. | ||
| /// </param> | ||
| /// <returns>The local listener if view is in a registered view hierarchy, null otherwise</returns> | ||
| internal static MauiWindowInsetListener? FindListenerForView(AView view) | ||
| internal static MauiWindowInsetListener? FindListenerForView(AView view, bool applyRecyclerViewGate = false) | ||
| { | ||
| // 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. | ||
| // But we don't want any layout listeners to get applied to the children of MaterialToolbar (like the TitleView). | ||
| if (view is not MaterialToolbar && | ||
| (parent is AppBarLayout || parent is MauiScrollView)) | ||
| (parent is AppBarLayout || | ||
| parent is MauiScrollView)) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| // Attachment gate (applyRecyclerViewGate=true only): skip RecyclerView data-item views with default | ||
| // SafeAreaEdges to prevent recycled item views from carrying stale inset-derived padding (#34634/#34635). | ||
| // EmptyView items (IsShowingEmptyView=true) are exempt — they are never recycled across data rows | ||
| // so they safely receive the listener without risk of stale-padding accumulation. | ||
| // Reset/cleanup callers use the default applyRecyclerViewGate=false so they always find the listener | ||
| // needed to clear any previously applied padding when a view is detached or SafeAreaEdges changes. | ||
| if (applyRecyclerViewGate && | ||
| view is not MaterialToolbar && | ||
| parent is IMauiRecyclerView mauiRecyclerView && | ||
| !mauiRecyclerView.IsShowingEmptyView && | ||
|
Check failure on line 127 in src/Core/src/Platform/Android/MauiWindowInsetListener.cs
|
||
| !HasExplicitSafeAreaEdges(view)) | ||
| { | ||
| return null; | ||
| } | ||
|
|
@@ -133,6 +154,34 @@ | |
| return null; | ||
| } | ||
|
|
||
| // Per-element-type cache of SafeAreaEdges default values. | ||
| // The default value returned by ISafeAreaElement.SafeAreaEdgesDefaultValueCreator() is constant per | ||
| // implementing type (e.g. Layout -> Container, Border/ContentView/ContentPage -> None, ScrollView -> Default). | ||
| // The cache is therefore append-only and never needs invalidation. Caching by AView would be unsafe | ||
| // because RecyclerView rebinds the same AView to different MAUI elements during scrolling; caching | ||
| // by Type is independent of any individual instance. | ||
| static readonly ConcurrentDictionary<Type, SafeAreaEdges> _safeAreaEdgesDefaults = new(); | ||
|
|
||
| static bool HasExplicitSafeAreaEdges(AView view) | ||
| { | ||
| if (view is not ICrossPlatformLayoutBacking { CrossPlatformLayout: ISafeAreaElement safeAreaElement }) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| // Hoist the current value into a local: SafeAreaEdges is a BindableProperty getter that may lazily | ||
| // initialize state on first read, so we read it exactly once. | ||
| var current = safeAreaElement.SafeAreaEdges; | ||
| var defaultEdges = _safeAreaEdgesDefaults.GetOrAdd( | ||
| safeAreaElement.GetType(), | ||
| static (_, element) => element.SafeAreaEdgesDefaultValueCreator(), | ||
| safeAreaElement); | ||
|
|
||
| // Use the typed IEquatable<SafeAreaEdges>.Equals to stay independent of operator overloads | ||
| // in case SafeAreaEdges evolves (e.g. into a flags-style struct) in the future. | ||
| return !current.Equals(defaultEdges); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Sets up a view to use this listener for inset handling. | ||
| /// This method registers the view and attaches the listener. | ||
|
|
@@ -474,8 +523,9 @@ | |
| /// <param name="context">The Android context to get the listener from</param> | ||
| 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) | ||
| // applyRecyclerViewGate=true: skip RecyclerView data-item views with default SafeAreaEdges | ||
| // to prevent recycled views from accumulating stale inset-derived padding (#34634/#34635). | ||
| if (MauiWindowInsetListener.FindListenerForView(view, applyRecyclerViewGate: true) is MauiWindowInsetListener localListener) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [major] SafeArea/Android listener lifecycle — Because default RecyclerView item roots are skipped only during |
||
| { | ||
| ViewCompat.SetOnApplyWindowInsetsListener(view, localListener); | ||
| ViewCompat.SetWindowInsetsAnimationCallback(view, localListener); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[critical] Compile correctness — This explicit implementation targets
Microsoft.Maui.IMauiRecyclerView.IsShowingEmptyView, but the CoreIMauiRecyclerViewinterface is still empty. This will not compile (IMauiRecyclerViewdoes not contain a definition forIsShowingEmptyView), and the read inMauiWindowInsetListenerhas the same problem. Add the member to the Core interface or avoid reading it through that interface.