-
Notifications
You must be signed in to change notification settings - Fork 2k
[Android] Fix AppBar flicker on CheckBox/Switch toggle with Material 3 #35181
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
Merged
kubaflo
merged 14 commits into
dotnet:inflight/current
from
Dhivya-SF4094:appbar_flicker
May 22, 2026
Merged
Changes from 11 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
9a6509c
fix for AppBarLayout flicker
Dhivya-SF4094 b8afde5
Fix for Hide/Add Scrollview dynamically
Dhivya-SF4094 50af1af
Updated MauiRecyclerView.cs and MauiScrollView.cs
Dhivya-SF4094 cabf60f
Removed debug lines
Dhivya-SF4094 b1941b2
Refactored the fix
Dhivya-SF4094 02ed8f6
Removed unwanted import
Dhivya-SF4094 8de8817
Fix for CarouselviewPage maintains previous page Appbar lift
Dhivya-SF4094 46644fd
Renamed method name
Dhivya-SF4094 620b4da
Merge branch 'main' into appbar_flicker
kubaflo b888198
Addressed review concern.
Dhivya-SF4094 5585cda
Updated fix in MauiScrollView and MauiRecyclerView
Dhivya-SF4094 feea83d
Added test case
Dhivya-SF4094 c80b5ee
Addressed AI summary
Dhivya-SF4094 cf19550
Merge branch 'inflight/current' into appbar_flicker
kubaflo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
242 changes: 242 additions & 0 deletions
242
src/Core/src/Platform/Android/AppbarLayoutExtensions.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
| using System; | ||
| using System.Runtime.CompilerServices; | ||
| using Android.Graphics; | ||
| using Android.Views; | ||
| using Google.Android.Material.AppBar; | ||
|
|
||
| namespace Microsoft.Maui.Platform | ||
| { | ||
| // Manages pinning the lift-on-scroll target of a MAUI navigation AppBarLayout | ||
| // to a specific scrollable view (e.g. MauiScrollView or RecyclerView). | ||
| // Shared between MauiScrollView and MauiRecyclerView to avoid duplicating | ||
| // the same ancestor-walk / attach / detach logic. | ||
| // | ||
| // When the scrollable view is inside a CarouselView/ViewPager2, adjacent | ||
| // off-screen pages are pre-cached and their views stay attached without | ||
| // receiving visibility callbacks during page swipes. A ViewTreeObserver | ||
| // scroll-changed listener detects these transitions and transfers the | ||
| // lift target to whichever page's scrollable view is currently on-screen. | ||
| // | ||
| // Per-view state is stored in a ConditionalWeakTable so that it is | ||
| // automatically cleaned up when the View is garbage-collected. | ||
| internal static class AppbarLayoutExtensions | ||
| { | ||
| static readonly ConditionalWeakTable<View, AppBarLiftState> s_stateTable = new(); | ||
|
|
||
| sealed class AppBarLiftState | ||
| { | ||
| public AppBarLayout? LiftOnScrollAppBar; | ||
| public ScrollChangedListener? ScrollListener; | ||
| public readonly Rect VisibleRect = new(); | ||
| } | ||
|
|
||
| internal static void TrySetAppBarLiftTargetIfOnScreen(this View view) | ||
| { | ||
| // Guard: the view may have detached or been hidden between Post() and execution. | ||
| if (view.Handle == IntPtr.Zero || !view.IsAttachedToWindow || view.Visibility != ViewStates.Visible) | ||
| { | ||
| return; | ||
|
Dhivya-SF4094 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| var state = s_stateTable.GetOrCreateValue(view); | ||
|
|
||
| // When inside a CarouselView, ViewPager2 pre-loads adjacent off-screen pages, | ||
| // so their ScrollViews also attach. Only the on-screen page's ScrollView should | ||
| // claim the lift target. GetGlobalVisibleRect returns false if the view is | ||
| // entirely outside the clipped viewport (e.g. a pre-loaded carousel page). | ||
| if (!view.GetGlobalVisibleRect(state.VisibleRect)) | ||
| { | ||
| // Off-screen — start listening for parent scroll changes so we | ||
| // can claim the lift target when the page scrolls into view. | ||
| StartListeningForParentScrollChanges(view, state); | ||
| return; | ||
| } | ||
|
|
||
| TrySetAppBarLiftTarget(view, state); | ||
|
|
||
| // Listen for parent scroll changes to detect when we go off-screen | ||
| // (e.g. user swipes to another carousel page). | ||
| StartListeningForParentScrollChanges(view, state); | ||
| } | ||
|
|
||
| internal static void ClearAppBarLiftTarget(this View view) | ||
| { | ||
| if (!s_stateTable.TryGetValue(view, out var state)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| StopListeningForParentScrollChanges(view, state); | ||
| ClearAppBarLiftTargetCore(view, state); | ||
| } | ||
|
|
||
| static void ClearAppBarLiftTargetCore(View view, AppBarLiftState state) | ||
| { | ||
| if (state.LiftOnScrollAppBar is null) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| // Only clear if we're still the current target; avoid stomping on another scroll view | ||
| // that may have been set as the target after us. | ||
| if (state.LiftOnScrollAppBar.LiftOnScrollTargetViewId == view.Id) | ||
| { | ||
| state.LiftOnScrollAppBar.LiftOnScrollTargetViewId = View.NoId; | ||
| } | ||
|
|
||
| state.LiftOnScrollAppBar = null; | ||
| } | ||
|
|
||
| static void TrySetAppBarLiftTarget(View view, AppBarLiftState state) | ||
| { | ||
| // Single ancestor walk: find the AppBarLayout while also checking | ||
| // whether a MauiScrollView ancestor exists (which should own the | ||
| // lift target instead of this view). | ||
| var appBar = FindAppBarLayout(view, out bool hasAncestorScrollView); | ||
| if (hasAncestorScrollView || appBar is null) | ||
| { | ||
| return; | ||
| } | ||
|
Dhivya-SF4094 marked this conversation as resolved.
|
||
|
|
||
| if (view.Id == View.NoId) | ||
| { | ||
| // LiftOnScrollTargetViewId requires a non-NoId view id. | ||
| // Intentionally assigning a generated id here; the view will | ||
| // keep this id for the rest of its lifetime, which is fine | ||
| // because MauiScrollView / MauiRecyclerView are not looked up | ||
| // by id by any other host code. | ||
| view.Id = View.GenerateViewId(); | ||
| } | ||
|
|
||
| state.LiftOnScrollAppBar = appBar; | ||
| appBar.LiftOnScrollTargetViewId = view.Id; | ||
|
|
||
| // Force the AppBar to reflect this view's current scroll position. | ||
| // After a carousel swipe or visibility toggle the AppBar may be stuck | ||
| // in the wrong state; CanScrollVertically(-1) is true when the view | ||
| // has been scrolled down from the top. | ||
| appBar.SetLifted(view.CanScrollVertically(-1)); | ||
| } | ||
|
|
||
| static void OnParentScrollChanged(View view, AppBarLiftState state) | ||
| { | ||
| if (view.Handle == IntPtr.Zero || !view.IsAttachedToWindow || view.Visibility != ViewStates.Visible) | ||
| { | ||
|
Dhivya-SF4094 marked this conversation as resolved.
|
||
| return; | ||
| } | ||
|
|
||
| bool isOnScreen = view.GetGlobalVisibleRect(state.VisibleRect); | ||
| bool ownsTarget = state.LiftOnScrollAppBar is not null; | ||
|
|
||
| if (isOnScreen && !ownsTarget) | ||
| { | ||
| TrySetAppBarLiftTarget(view, state); | ||
| } | ||
| else if (!isOnScreen && ownsTarget) | ||
| { | ||
| // Release without stopping the listener — we still need to | ||
| // detect when the carousel swipes back to this page. | ||
| ClearAppBarLiftTargetCore(view, state); | ||
| } | ||
| } | ||
|
|
||
| static void StartListeningForParentScrollChanges(View view, AppBarLiftState state) | ||
| { | ||
| if (state.ScrollListener is not null) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| var observer = view.ViewTreeObserver; | ||
| if (observer is null || !observer.IsAlive) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| state.ScrollListener = new ScrollChangedListener(view, state); | ||
| observer.AddOnScrollChangedListener(state.ScrollListener); | ||
| } | ||
|
Dhivya-SF4094 marked this conversation as resolved.
|
||
|
|
||
| static void StopListeningForParentScrollChanges(View view, AppBarLiftState state) | ||
| { | ||
| if (state.ScrollListener is null) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| var observer = view.ViewTreeObserver; | ||
| if (observer is not null && observer.IsAlive) | ||
| { | ||
| observer.RemoveOnScrollChangedListener(state.ScrollListener); | ||
| } | ||
|
|
||
| state.ScrollListener.Dispose(); | ||
| state.ScrollListener = null; | ||
| } | ||
|
|
||
| static AppBarLayout? FindAppBarLayout(View view, out bool hasAncestorScrollView) | ||
| { | ||
| // Single ancestor walk that both checks for a MauiScrollView ancestor | ||
| // (which should own the lift target instead) AND finds the AppBarLayout. | ||
| // NavigationPage uses Resource.Id.navigationlayout_appbar, but Shell creates | ||
| // its AppBarLayout programmatically without an ID, so we match any AppBarLayout. | ||
| // | ||
| // Nested-host note: Shell-inside-NavigationPage (or vice versa) is not a | ||
| // supported MAUI configuration, so there is only ever one relevant AppBarLayout | ||
| // in the ancestor/sibling chain for any given scroll view. The walk returns the | ||
| // first one found, which is the correct one for all supported layouts. | ||
| hasAncestorScrollView = false; | ||
| var parent = view.Parent; | ||
|
|
||
| while (parent is View parentView) | ||
| { | ||
| if (parentView is MauiScrollView) | ||
| { | ||
| hasAncestorScrollView = true; | ||
| return null; | ||
| } | ||
|
|
||
| // Stop the MauiScrollView check once we reach the AppBarLayout level — | ||
| // anything above that isn't "inside the page". | ||
| if (parentView is AppBarLayout directAppBar) | ||
| { | ||
| return directAppBar; | ||
| } | ||
|
|
||
| if (parentView is ViewGroup group) | ||
| { | ||
| for (int i = 0; i < group.ChildCount; i++) | ||
| { | ||
| if (group.GetChildAt(i) is AppBarLayout siblingAppBar) | ||
| { | ||
| return siblingAppBar; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| parent = parentView.Parent; | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| // Lightweight Java-side listener that forwards ViewTreeObserver scroll | ||
| // changes back to the static extension for carousel page-change detection. | ||
| sealed class ScrollChangedListener : Java.Lang.Object, ViewTreeObserver.IOnScrollChangedListener | ||
| { | ||
| readonly View _view; | ||
| readonly AppBarLiftState _state; | ||
|
|
||
| public ScrollChangedListener(View view, AppBarLiftState state) | ||
| { | ||
| _view = view; | ||
| _state = state; | ||
| } | ||
|
|
||
| public void OnScrollChanged() | ||
| { | ||
| OnParentScrollChanged(_view, _state); | ||
| } | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.