diff --git a/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs b/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs index 4d65a66e44b0..ce85271f1f04 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs @@ -8,8 +8,10 @@ using AndroidX.RecyclerView.Widget; using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Controls.Platform; +using Microsoft.Maui.Platform; using Microsoft.Maui.Graphics; using ARect = Android.Graphics.Rect; +using AView = Android.Views.View; using AViewCompat = AndroidX.Core.View.ViewCompat; namespace Microsoft.Maui.Controls.Handlers.Items @@ -47,6 +49,7 @@ public class MauiRecyclerView : Recycler SimpleItemTouchHelperCallback _itemTouchHelperCallback; WeakNotifyPropertyChangedProxy _layoutPropertyChangedProxy; PropertyChangedEventHandler _layoutPropertyChanged; + Java.Lang.IRunnable _setAppBarLiftTargetRunnable; ~MauiRecyclerView() => _layoutPropertyChangedProxy?.Unsubscribe(); @@ -59,6 +62,73 @@ public MauiRecyclerView(Context context, Func getItemsLayout, Func _itemsUpdateScrollObserver = new DataChangeObserver(AdjustScrollForItemUpdate); } + protected override void OnAttachedToWindow() + { + base.OnAttachedToWindow(); + + if (RuntimeFeature.IsMaterial3Enabled) + { + PostTrySetAppBarLiftTargetIfOnScreen(); + } + } + + protected override void OnDetachedFromWindow() + { + // Clean up AppBar listener while the ViewTreeObserver is still valid. + if (RuntimeFeature.IsMaterial3Enabled) + { + ClearAppBarLiftTargetAndPendingPost(); + } + + base.OnDetachedFromWindow(); + } + + protected override void OnVisibilityChanged(AView changedView, ViewStates visibility) + { + base.OnVisibilityChanged(changedView, visibility); + + if (changedView != this) + { + return; + } + + if (!RuntimeFeature.IsMaterial3Enabled) + { + return; + } + + if (visibility == ViewStates.Visible) + { + PostTrySetAppBarLiftTargetIfOnScreen(); + } + else + { + ClearAppBarLiftTargetAndPendingPost(); + } + } + + void PostTrySetAppBarLiftTargetIfOnScreen() + { + var runnable = GetOrCreateSetAppBarLiftTargetRunnable(); + RemoveCallbacks(runnable); + Post(runnable); + } + + void ClearAppBarLiftTargetAndPendingPost() + { + if (_setAppBarLiftTargetRunnable is not null) + { + RemoveCallbacks(_setAppBarLiftTargetRunnable); + } + + this.ClearAppBarLiftTarget(); + } + + Java.Lang.IRunnable GetOrCreateSetAppBarLiftTargetRunnable() + { + return _setAppBarLiftTargetRunnable ??= new Java.Lang.Runnable(() => this.TrySetAppBarLiftTargetIfOnScreen()); + } + public virtual void TearDownOldElement(TItemsView oldElement) { // Stop listening for layout property changes diff --git a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt index 1c9236b03dd8..9e3830e2ecfa 100644 --- a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -13,6 +13,10 @@ override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? property ~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.OnTouchEvent(Android.Views.MotionEvent e) -> bool ~override Microsoft.Maui.Controls.Handlers.Items.RecyclerViewScrollListener.OnScrollStateChanged(AndroidX.RecyclerView.Widget.RecyclerView recyclerView, int newState) -> void ~override Microsoft.Maui.Controls.Handlers.Items.SelectableItemsViewAdapter.IsSelectionEnabled(Android.Views.ViewGroup parent, int viewType) -> bool +override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.OnAttachedToWindow() -> void +override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.OnDetachedFromWindow() -> void +override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void +~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.OnVisibilityChanged(Android.Views.View changedView, Android.Views.ViewStates visibility) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.OnHiddenChanged(bool hidden) -> void override Microsoft.Maui.Controls.SwipeItemView.IsEnabledCore.get -> bool ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void diff --git a/src/Core/src/Platform/Android/AppbarLayoutExtensions.cs b/src/Core/src/Platform/Android/AppbarLayoutExtensions.cs new file mode 100644 index 000000000000..b0d3694bd9ce --- /dev/null +++ b/src/Core/src/Platform/Android/AppbarLayoutExtensions.cs @@ -0,0 +1,263 @@ +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 s_stateTable = new(); + + sealed class AppBarLiftState + { + public AppBarLayout? LiftOnScrollAppBar; + public ScrollChangedListener? ScrollListener; + public ViewTreeObserver? ScrollListenerObserver; + 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.IsAlive() || !view.IsAttachedToWindow || view.Visibility != ViewStates.Visible) + { + return; + } + + var state = s_stateTable.GetOrCreateValue(view); + + // Single ancestor walk up front — bail early if no AppBarLayout exists. + // This avoids registering a ViewTreeObserver listener on pages without an app bar. + var appBar = FindAppBarLayout(view, out bool hasAncestorScrollView); + if (hasAncestorScrollView || appBar is null) + { + return; + } + + // 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)) + { + SetAppBarLiftTarget(view, state, appBar); + } + + // Listen for parent scroll changes to detect carousel page transitions. + // We only reach here when an AppBarLayout was found above. + 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) + { + // Ancestor walk to 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; + } + + SetAppBarLiftTarget(view, state, appBar); + } + + static void SetAppBarLiftTarget(View view, AppBarLiftState state, AppBarLayout appBar) + { + 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.IsAlive() || !view.IsAttachedToWindow || view.Visibility != ViewStates.Visible) + { + 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); + state.ScrollListenerObserver = observer; + observer.AddOnScrollChangedListener(state.ScrollListener); + } + + static void StopListeningForParentScrollChanges(View view, AppBarLiftState state) + { + if (state.ScrollListener is null) + { + return; + } + + // Remove from the same observer instance used when adding. + var observer = state.ScrollListenerObserver; + if (observer is not null && observer.IsAlive) + { + observer.RemoveOnScrollChangedListener(state.ScrollListener); + } + else + { + // Fallback for safety if observer rotated between add/remove. + observer = view.ViewTreeObserver; + if (observer is not null && observer.IsAlive) + { + observer.RemoveOnScrollChangedListener(state.ScrollListener); + } + } + + state.ScrollListener.Dispose(); + state.ScrollListener = null; + state.ScrollListenerObserver = 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); + } + } + } +} diff --git a/src/Core/src/Platform/Android/MauiScrollView.cs b/src/Core/src/Platform/Android/MauiScrollView.cs index bc92251800b6..66e3eeed4d06 100644 --- a/src/Core/src/Platform/Android/MauiScrollView.cs +++ b/src/Core/src/Platform/Android/MauiScrollView.cs @@ -25,6 +25,7 @@ public class MauiScrollView : NestedScrollView, IScrollBarView, NestedScrollView ScrollBarVisibility _horizontalScrollVisibility; bool _didSafeAreaEdgeConfigurationChange = true; bool _isInsetListenerSet; + Java.Lang.IRunnable? _setAppBarLiftTargetRunnable; internal float LastX { get; set; } internal float LastY { get; set; } @@ -62,10 +63,30 @@ public override void OnAttachedToWindow() { base.OnAttachedToWindow(); _isInsetListenerSet = MauiWindowInsetListenerExtensions.TrySetMauiWindowInsetListener(this, _context); + + if (RuntimeFeature.IsMaterial3Enabled) + { + // Pin the MAUI navigation AppBarLayout's lift-on-scroll target to this NestedScrollView. + // Otherwise AppBarLayout auto-detects the outer FragmentContainerView as the scrolling target, + // and its ViewTreeObserver-driven shouldLift() check evaluates canScrollVertically() on the + // container (which is always 0), causing the lifted state to flip on every layout pass + // triggered by sibling views (e.g. CheckBox/Switch state animations) and producing a + // visible scrolledContainerColor flicker. + // Use Post() to defer until layout is complete — when this ScrollView is inside + // a CarouselView, adjacent off-screen pages also attach and we need to verify + // the view is actually on-screen before claiming the lift target. + PostTrySetAppBarLiftTargetIfOnScreen(); + } } protected override void OnDetachedFromWindow() { + // Clean up AppBar listener while the ViewTreeObserver is still valid. + if (RuntimeFeature.IsMaterial3Enabled) + { + ClearAppBarLiftTargetAndPendingPost(); + } + base.OnDetachedFromWindow(); if (_isInsetListenerSet) MauiWindowInsetListenerExtensions.RemoveMauiWindowInsetListener(this, _context); @@ -74,6 +95,52 @@ protected override void OnDetachedFromWindow() _didSafeAreaEdgeConfigurationChange = true; } + protected override void OnVisibilityChanged(View changedView, ViewStates visibility) + { + base.OnVisibilityChanged(changedView, visibility); + + if (changedView != this) + { + return; + } + + if (!RuntimeFeature.IsMaterial3Enabled) + { + return; + } + + if (visibility == ViewStates.Visible) + { + PostTrySetAppBarLiftTargetIfOnScreen(); + } + else + { + ClearAppBarLiftTargetAndPendingPost(); + } + } + + void PostTrySetAppBarLiftTargetIfOnScreen() + { + var runnable = GetOrCreateSetAppBarLiftTargetRunnable(); + RemoveCallbacks(runnable); + Post(runnable); + } + + void ClearAppBarLiftTargetAndPendingPost() + { + if (_setAppBarLiftTargetRunnable is not null) + { + RemoveCallbacks(_setAppBarLiftTargetRunnable); + } + + this.ClearAppBarLiftTarget(); + } + + Java.Lang.IRunnable GetOrCreateSetAppBarLiftTargetRunnable() + { + return _setAppBarLiftTargetRunnable ??= new Java.Lang.Runnable(() => this.TrySetAppBarLiftTargetIfOnScreen()); + } + #region IHandleWindowInsets Implementation (int left, int top, int right, int bottom) _originalPadding; diff --git a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt index b3c4372f7b7d..01d355e5ff68 100644 --- a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -9,6 +9,7 @@ override Microsoft.Maui.PlatformDrawable.ThresholdType.get -> System.Type! *REMOVED*override Microsoft.Maui.Graphics.MauiDrawable.OnBoundsChange(Android.Graphics.Rect! bounds) -> void *REMOVED*override Microsoft.Maui.Graphics.MauiDrawable.OnDraw(Android.Graphics.Drawables.Shapes.Shape? shape, Android.Graphics.Canvas? canvas, Android.Graphics.Paint? paint) -> void override Microsoft.Maui.Handlers.LabelHandler.GetDesiredSize(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size +override Microsoft.Maui.Platform.MauiScrollView.OnVisibilityChanged(Android.Views.View! changedView, Android.Views.ViewStates visibility) -> void override Microsoft.Maui.Platform.ContentViewGroup.HasOverlappingRendering.get -> bool override Microsoft.Maui.Platform.LayoutViewGroup.HasOverlappingRendering.get -> bool override Microsoft.Maui.Platform.WrapperView.HasOverlappingRendering.get -> bool diff --git a/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.Android.cs b/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.Android.cs index e0917fdc2d3a..3f1763ba184c 100644 --- a/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.Android.cs +++ b/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.Android.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Android.Views; using Android.Widget; using AndroidX.AppCompat.Widget; +using AndroidX.CoordinatorLayout.Widget; using AndroidX.Core.Widget; +using Google.Android.Material.AppBar; using Microsoft.Maui.DeviceTests.Stubs; using Microsoft.Maui.Graphics; using Microsoft.Maui.Handlers; @@ -14,6 +17,146 @@ namespace Microsoft.Maui.DeviceTests { public partial class ScrollViewHandlerTests : CoreHandlerTestBase { + // Regression test for https://github.com/dotnet/maui/issues/35180 + // On Material3, the AppBarLayout was auto-detecting the scroll target as the outer + // FragmentContainerView, causing a flicker on every layout pass triggered by CheckBox / + // Switch animations after scrolling. The fix pins LiftOnScrollTargetViewId to the + // real MauiScrollView so AppBarLayout correctly evaluates the scroll position. + [Fact] + [Category(TestCategory.ScrollView)] + public async Task AppBarLiftTargetSetToScrollViewOnAttach() + { + if (!Microsoft.Maui.RuntimeFeature.IsMaterial3Enabled) + return; + + await InvokeOnMainThreadAsync(async () => + { + var context = MauiContext.Context!; + + // Replicate the NavigationPage CoordinatorLayout structure: + // CoordinatorLayout + // ├─ AppBarLayout (sibling — the lift-on-scroll host) + // └─ FrameLayout (content container) + // └─ MauiScrollView + var coordinator = new CoordinatorLayout(context); + var appBarLayout = new AppBarLayout(context); + appBarLayout.SetLiftable(true); + + var contentFrame = new FrameLayout(context); + var scrollView = new Microsoft.Maui.Platform.MauiScrollView(context); + + contentFrame.AddView(scrollView, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MatchParent, + ViewGroup.LayoutParams.MatchParent)); + + coordinator.AddView(appBarLayout, new CoordinatorLayout.LayoutParams( + ViewGroup.LayoutParams.MatchParent, + ViewGroup.LayoutParams.WrapContent)); + + coordinator.AddView(contentFrame, new CoordinatorLayout.LayoutParams( + ViewGroup.LayoutParams.MatchParent, + ViewGroup.LayoutParams.MatchParent)); + + // Attach the whole tree to the window so that OnAttachedToWindow fires. + await coordinator.AttachAndRun(async () => + { + // Post() schedules the lift-target assignment on the next looper tick. + // Await a task continuation that runs after that tick has been processed. + var tcs = new TaskCompletionSource(); + scrollView.Post(new Java.Lang.Runnable(() => tcs.SetResult(true))); + await tcs.Task; + + Assert.Equal(scrollView.Id, appBarLayout.LiftOnScrollTargetViewId); + }); + + // After detach the lift target must be released to avoid stale references. + Assert.NotEqual(scrollView.Id, appBarLayout.LiftOnScrollTargetViewId); + }); + } + + [Fact] + [Category(TestCategory.ScrollView)] + public async Task AppBarLiftTargetClearedOnVisibilityGone() + { + if (!Microsoft.Maui.RuntimeFeature.IsMaterial3Enabled) + return; + + await InvokeOnMainThreadAsync(async () => + { + var context = MauiContext.Context!; + + var coordinator = new CoordinatorLayout(context); + var appBarLayout = new AppBarLayout(context); + appBarLayout.SetLiftable(true); + + var contentFrame = new FrameLayout(context); + var scrollView = new Microsoft.Maui.Platform.MauiScrollView(context); + + contentFrame.AddView(scrollView, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent)); + + coordinator.AddView(appBarLayout, new CoordinatorLayout.LayoutParams( + ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.WrapContent)); + + coordinator.AddView(contentFrame, new CoordinatorLayout.LayoutParams( + ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent)); + + await coordinator.AttachAndRun(async () => + { + // Wait for the initial Post() to settle before checking. + var tcs = new TaskCompletionSource(); + scrollView.Post(new Java.Lang.Runnable(() => tcs.SetResult(true))); + await tcs.Task; + + Assert.Equal(scrollView.Id, appBarLayout.LiftOnScrollTargetViewId); + + // Hiding the scroll view should synchronously clear the lift target. + scrollView.Visibility = ViewStates.Gone; + Assert.NotEqual(scrollView.Id, appBarLayout.LiftOnScrollTargetViewId); + + // Restoring visibility should re-establish the lift target. + scrollView.Visibility = ViewStates.Visible; + var tcs2 = new TaskCompletionSource(); + scrollView.Post(new Java.Lang.Runnable(() => tcs2.SetResult(true))); + await tcs2.Task; + + Assert.Equal(scrollView.Id, appBarLayout.LiftOnScrollTargetViewId); + }); + }); + } + + [Fact] + [Category(TestCategory.ScrollView)] + public async Task AppBarLiftTargetNotSetWhenNoAppBarLayout() + { + if (!Microsoft.Maui.RuntimeFeature.IsMaterial3Enabled) + return; + + await InvokeOnMainThreadAsync(async () => + { + var context = MauiContext.Context!; + + // Plain FrameLayout with no CoordinatorLayout / AppBarLayout ancestor. + var frame = new FrameLayout(context); + var scrollView = new Microsoft.Maui.Platform.MauiScrollView(context); + + frame.AddView(scrollView, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent)); + + await frame.AttachAndRun(async () => + { + // Allow the Post()-deferred work to settle. + var tcs = new TaskCompletionSource(); + scrollView.Post(new Java.Lang.Runnable(() => tcs.SetResult(true))); + await tcs.Task; + + // Without an AppBarLayout in the hierarchy, no view ID should be generated + // (SetAppBarLiftTarget only assigns an ID when it actually claims the target). + Assert.Equal(View.NoId, scrollView.Id); + }); + }); + } + [Fact] public async Task ContentInitializesCorrectly() {