-
Notifications
You must be signed in to change notification settings - Fork 1.9k
[Android] Refactor WindowInsetListener to per-view registry with MauiWindowInsetListener #32278
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
Conversation
|
/azp run |
|
Azure Pipelines successfully started running 3 pipeline(s). |
src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellContentFragment.cs
Outdated
Show resolved
Hide resolved
|
/azp run |
|
Azure Pipelines successfully started running 3 pipeline(s). |
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.
Pull Request Overview
This PR refactors the GlobalWindowInsetListener architecture from a single activity-level instance to a local listener pattern attached to specific views. This improves isolation in complex navigation scenarios (modals, flyouts, shell navigation) and eliminates cross-contamination of inset tracking between different UI contexts.
Key changes:
- Introduces per-view local listeners with static registry for better isolation
- Removes activity-level GlobalWindowInsetListener singleton pattern
- Adds registration/unregistration methods for view lifecycle management
- Simplifies code with null-conditional operators and improved formatting
- Converts indentation from spaces to tabs in several files
Reviewed Changes
Copilot reviewed 21 out of 24 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| GlobalWindowInsetListener.cs | Major refactor: adds static registry, view registration/unregistration, local listener setup methods |
| SafeAreaExtensions.cs | Converts spacing to tabs, updates to use FindListenerForView |
| NavigationRootManager.cs | Manages CoordinatorLayout listener lifecycle, uses new local listener pattern |
| MauiAppCompatActivity.cs | Removes activity-level GlobalWindowInsetListener singleton |
| ViewHandler.Android.cs | Updates to use FindListenerForView instead of context extension |
| FlyoutViewHandler.Android.cs | Adds local listener setup/cleanup for flyout views |
| ModalNavigationManager.Android.cs | Removes modal-specific listener, relies on local pattern |
| Shell renderers | Updates to use SetupViewWithLocalListener for CoordinatorLayouts |
| Multiple extension files | Code cleanup: null-conditional operators, whitespace fixes |
src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs
Show resolved
Hide resolved
...ontrols/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs
Show resolved
Hide resolved
|
/azp run |
|
Azure Pipelines successfully started running 3 pipeline(s). |
|
/azp run |
|
Azure Pipelines successfully started running 3 pipeline(s). |
|
/azp run |
|
Azure Pipelines successfully started running 3 pipeline(s). |
PureWeen
left a comment
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.
@copilot can you rename the GlobalWindowInsetListener to just MauiWindowInsetListener?
…egisterParentForChildViews - Rename GlobalWindowInsetListener class to MauiWindowInsetListener for better clarity - Rename GlobalWindowInsetListenerExtensions to MauiWindowInsetListenerExtensions - Update extension methods: TrySetMauiWindowInsetListener, RemoveMauiWindowInsetListener - Add RegisterParentForChildViews static method to register a parent for child insets without attaching listener to parent - Update FlyoutViewHandler to use RegisterParentForChildViews instead of instantiating listener - Update all references across codebase Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com>
- Set SafeAreaEdges = SafeAreaEdges.None on childLayout (VerticalStackLayout) - Set SafeAreaEdges = SafeAreaEdges.None on scrollView (ScrollView) - Set SafeAreaEdges = SafeAreaEdges.None on parentLayout (Grid) - This ensures the test is isolated from safe area inset handling changes Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com>
Reverted formatting changes (indentation, whitespace) from files that had no functional changes: - EditTextExtensions.cs - ViewExtensions.cs - PickerExtensions.cs - SearchViewExtensions.cs - StepperHandlerManager.cs - MauiPageControl.cs - MauiSwipeView.cs - MauiWebView.cs - IHandleWindowInsets.cs - ShellToolbarTracker.cs - ShellFlyoutTemplatedContentRenderer.cs Kept functional change in SafeAreaExtensions.cs (MauiWindowInsetListener.FindListenerForView) while reverting formatting changes. This makes the PR focused on functional changes only, with formatting changes deferred to a separate PR. Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com>
This reverts commit 534c071.
c036163 to
c004621
Compare
|
/azp run |
|
Azure Pipelines successfully started running 3 pipeline(s). |
|
/backport to release/10.0.1xx-sr1 |
|
Started backporting to |
Performance Analysis: FindListenerForView in PR #32278Problem: Nested Loop O(n*m) ComplexityThe current implementation has nested loops creating O(n*m) time complexity: internal static MauiWindowInsetListener? FindListenerForView(AView view)
{
// Walk up the view hierarchy looking for a registered view
var parent = view.Parent;
while (parent is not null) // ← Outer loop: n = hierarchy depth
{
// Skip setting listener on views inside nested scroll containers or AppBarLayout (except MaterialToolbar)
if (view is not MaterialToolbar &&
(parent is AppBarLayout || parent is MauiScrollView || parent is IMauiRecyclerView))
{
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--) // ← Inner loop: m = registry size
{
var entry = _registeredViews[i];
if (!entry.View.TryGetTarget(out var registeredView))
{
_registeredViews.RemoveAt(i);
}
else if (ReferenceEquals(registeredView, parentView))
{
return entry.Listener;
}
}
}
parent = parent.Parent;
}
return null;
}Complexity Analysis:
Why This Matters:
Option 1: ConditionalWeakTable (RECOMMENDED)Best choice: O(n) lookup, automatic cleanup, thread-safe, idiomatic .NET static readonly ConditionalWeakTable<AView, MauiWindowInsetListener> _registeredViews = new();
internal static void SetupViewWithLocalListener(AView view)
{
var listener = new MauiWindowInsetListener(view);
_registeredViews.AddOrUpdate(view, listener);
ViewCompat.SetOnApplyWindowInsetsListener(view, listener);
}
internal static MauiWindowInsetListener? FindListenerForView(AView view)
{
var parent = view.Parent;
while (parent is not null)
{
// Skip setting listener on views inside nested scroll containers or AppBarLayout (except MaterialToolbar)
if (view is not MaterialToolbar &&
(parent is AppBarLayout || parent is MauiScrollView || parent is IMauiRecyclerView))
{
return null;
}
if (parent is AView parentView &&
_registeredViews.TryGetValue(parentView, out var listener))
{
return listener;
}
parent = parent.Parent;
}
return null;
}
internal static void RemoveViewWithLocalListener(AView view)
{
if (_registeredViews.TryGetValue(view, out var listener))
{
listener.Dispose();
_registeredViews.Remove(view);
}
}
// Remove the old ViewEntry record - no longer neededBenefits:
Drawbacks:
Option 2: Dictionary with RuntimeHelpers.GetHashCodeGood choice: O(n) lookup, manual cleanup, lightweight static readonly Dictionary<int, WeakReference<MauiWindowInsetListener>> _registeredViews = new();
static readonly List<(int hash, WeakReference<AView> view)> _cleanupTracker = new();
internal static void SetupViewWithLocalListener(AView view)
{
var listener = new MauiWindowInsetListener(view);
int viewHash = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(view);
_registeredViews[viewHash] = new WeakReference<MauiWindowInsetListener>(listener);
_cleanupTracker.Add((viewHash, new WeakReference<AView>(view)));
ViewCompat.SetOnApplyWindowInsetsListener(view, listener);
}
internal static MauiWindowInsetListener? FindListenerForView(AView view)
{
var parent = view.Parent;
while (parent is not null)
{
if (view is not MaterialToolbar &&
(parent is AppBarLayout || parent is MauiScrollView || parent is IMauiRecyclerView))
{
return null;
}
if (parent is AView parentView)
{
int parentHash = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(parentView);
if (_registeredViews.TryGetValue(parentHash, out var listenerRef) &&
listenerRef.TryGetTarget(out var listener))
{
return listener;
}
}
parent = parent.Parent;
}
return null;
}
internal static void RemoveViewWithLocalListener(AView view)
{
int viewHash = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(view);
if (_registeredViews.TryGetValue(viewHash, out var listenerRef) &&
listenerRef.TryGetTarget(out var listener))
{
listener.Dispose();
}
_registeredViews.Remove(viewHash);
// Periodic cleanup
CleanupDeadReferences();
}
static void CleanupDeadReferences()
{
// Only cleanup periodically (e.g., every 10th removal) to avoid overhead
if (_cleanupTracker.Count < 10 || _cleanupTracker.Count % 10 != 0)
return;
for (int i = _cleanupTracker.Count - 1; i >= 0; i--)
{
var (hash, viewRef) = _cleanupTracker[i];
if (!viewRef.TryGetTarget(out _))
{
_cleanupTracker.RemoveAt(i);
_registeredViews.Remove(hash);
}
}
}Benefits:
Drawbacks:
Option 3: Simple Cache for Last ResultMinimal change: Adds O(1) fast path for common case, keeps existing code static AView? _lastQueriedView;
static MauiWindowInsetListener? _lastFoundListener;
internal static MauiWindowInsetListener? FindListenerForView(AView view)
{
// Quick cache check for repeated lookups on same branch
if (_lastQueriedView != null && IsViewContainedIn(view, _lastQueriedView))
return _lastFoundListener;
var parent = view.Parent;
while (parent is not null)
{
if (view is not MaterialToolbar &&
(parent is AppBarLayout || parent is MauiScrollView || parent is IMauiRecyclerView))
{
_lastQueriedView = null;
_lastFoundListener = null;
return null;
}
if (parent is AView parentView)
{
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))
{
// Cache the result
_lastQueriedView = parentView;
_lastFoundListener = entry.Listener;
return entry.Listener;
}
}
}
parent = parent.Parent;
}
_lastQueriedView = null;
_lastFoundListener = null;
return null;
}
static bool IsViewContainedIn(AView child, AView potentialAncestor)
{
var parent = child.Parent;
while (parent is not null)
{
if (ReferenceEquals(parent, potentialAncestor))
return true;
parent = parent.Parent;
}
return false;
}Benefits:
Drawbacks:
Option 4: Separate Cleanup PassHybrid approach: Keep list structure, decouple cleanup from lookup static readonly List<ViewEntry> _registeredViews = new();
static int _lookupsSinceCleanup = 0;
const int CLEANUP_THRESHOLD = 100;
internal static MauiWindowInsetListener? FindListenerForView(AView view)
{
var parent = view.Parent;
while (parent is not null)
{
if (view is not MaterialToolbar &&
(parent is AppBarLayout || parent is MauiScrollView || parent is IMauiRecyclerView))
{
return null;
}
if (parent is AView parentView)
{
// Fast lookup without cleanup
foreach (var entry in _registeredViews)
{
if (entry.View.TryGetTarget(out var registeredView) &&
ReferenceEquals(registeredView, parentView))
{
return entry.Listener;
}
}
}
parent = parent.Parent;
}
// Periodic cleanup after many lookups
if (++_lookupsSinceCleanup >= CLEANUP_THRESHOLD)
{
CleanupDeadReferences();
_lookupsSinceCleanup = 0;
}
return null;
}
static void CleanupDeadReferences()
{
for (int i = _registeredViews.Count - 1; i >= 0; i--)
{
if (!_registeredViews[i].View.TryGetTarget(out _))
{
_registeredViews.RemoveAt(i);
}
}
}
internal static void RemoveViewWithLocalListener(AView view)
{
for (int i = _registeredViews.Count - 1; i >= 0; i--)
{
if (_registeredViews[i].View.TryGetTarget(out var registeredView) &&
ReferenceEquals(registeredView, view))
{
_registeredViews[i].Listener?.Dispose();
_registeredViews.RemoveAt(i);
break;
}
}
// Explicit cleanup on removal
CleanupDeadReferences();
}Benefits:
Drawbacks:
Performance Comparison
RecommendationUse Option 1: ConditionalWeakTable This is the best solution because:
The current implementation likely works fine with small registry sizes (< 10 views), but for foundational infrastructure that runs on every layout pass, the optimization is worthwhile and eliminates a potential performance cliff as apps scale. Migration NotesIf choosing Option 1 (ConditionalWeakTable):
Breaking Changes: None - API surface remains identical |
mattleibow
left a comment
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.
Overall I think it is good.
Improved implementation of WindowInsetListener for per-view layout based on PR #31898
Comparison with PR #31898
After analyzing PR #31898, I've developed my own implementation approach and then compared it with the existing PR. Here's a detailed comparison:
Original PR Approach:
My Improvements Over Original:
?) on fields in Core project where nullable is enabled#nullable disabledirective (Shell fragments)IsViewContainedInwith early termination when encountering another CoordinatorLayout - this prevents incorrect listener lookup when layouts are nestedIsImeAnimationhelper method - reduces code duplication and improves readability.ToArray()instead ofnew List<>()for better performance in ResetAllViews - eliminates unnecessary list allocationWhy Not Use the Existing PR
While PR #31898 provides a solid foundation, my implementation offers several key improvements:
Key Changes
Renamed Class and Methods
New API
Test Fixes
Implementation Plan
Security Summary
✅ No security vulnerabilities detected by CodeQL analysis
Build Status
✅ All builds successful
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.
Fixes
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.