Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,6 +49,7 @@ public class MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource> : Recycler
SimpleItemTouchHelperCallback _itemTouchHelperCallback;
WeakNotifyPropertyChangedProxy _layoutPropertyChangedProxy;
PropertyChangedEventHandler _layoutPropertyChanged;
AppBarLiftTargetHelper _appBarLiftTargetHelper;

~MauiRecyclerView() => _layoutPropertyChangedProxy?.Unsubscribe();

Expand All @@ -59,6 +62,47 @@ public MauiRecyclerView(Context context, Func<IItemsLayout> getItemsLayout, Func
_itemsUpdateScrollObserver = new DataChangeObserver(AdjustScrollForItemUpdate);
}

protected override void OnAttachedToWindow()
Comment thread
Dhivya-SF4094 marked this conversation as resolved.
{
base.OnAttachedToWindow();

if (RuntimeFeature.IsMaterial3Enabled)
{
_appBarLiftTargetHelper ??= new AppBarLiftTargetHelper(this);
Post(_appBarLiftTargetHelper.TrySetIfOnScreen);
}
}

protected override void OnDetachedFromWindow()
{
base.OnDetachedFromWindow();

if (RuntimeFeature.IsMaterial3Enabled)
{
_appBarLiftTargetHelper?.Clear();
}
}

protected override void OnVisibilityChanged(AView changedView, ViewStates visibility)
Comment thread
Dhivya-SF4094 marked this conversation as resolved.
{
base.OnVisibilityChanged(changedView, visibility);

if (!RuntimeFeature.IsMaterial3Enabled)
{
return;
}

if (visibility == ViewStates.Visible)
{
_appBarLiftTargetHelper ??= new AppBarLiftTargetHelper(this);
Post(_appBarLiftTargetHelper.TrySetIfOnScreen);
}
else
{
_appBarLiftTargetHelper?.Clear();
}
}

public virtual void TearDownOldElement(TItemsView oldElement)
{
// Stop listening for layout property changes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void
override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.OnHiddenChanged(bool hidden) -> void
~override Microsoft.Maui.Controls.Handlers.Items.RecyclerViewScrollListener<TItemsView, TItemsViewSource>.OnScrollStateChanged(AndroidX.RecyclerView.Widget.RecyclerView recyclerView, int newState) -> void
~override Microsoft.Maui.Controls.Handlers.Items.SelectableItemsViewAdapter<TItemsView, TItemsSource>.IsSelectionEnabled(Android.Views.ViewGroup parent, int viewType) -> bool
override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource>.OnAttachedToWindow() -> void
override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource>.OnDetachedFromWindow() -> void
override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource>.OnVisibilityChanged(Android.Views.View changedView, Android.Views.ViewStates visibility) -> void
override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void
233 changes: 233 additions & 0 deletions src/Core/src/Platform/Android/AppBarLiftTargetHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
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.
internal class AppBarLiftTargetHelper
{
readonly View _view;
readonly Rect _visibleRect = new Rect();
AppBarLayout? _liftOnScrollAppBar;
ScrollChangedListener? _scrollChangedListener;

internal AppBarLiftTargetHelper(View view)
{
_view = view;
}

internal void TrySetIfOnScreen()
{
Comment thread
Dhivya-SF4094 marked this conversation as resolved.
Outdated
Comment thread
Dhivya-SF4094 marked this conversation as resolved.
Outdated
// Guard: the view may have detached or been hidden between Post() and execution.
if (!_view.IsAttachedToWindow || _view.Visibility != ViewStates.Visible)
{
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(_visibleRect))
{
// Off-screen — start listening for parent scroll changes so we
// can claim the lift target when the page scrolls into view.
StartListeningForParentScrollChanges();
return;
}

TrySetAppBarLiftTarget();

// Listen for parent scroll changes to detect when we go off-screen
// (e.g. user swipes to another carousel page).
StartListeningForParentScrollChanges();
}

internal void Clear()
{
StopListeningForParentScrollChanges();
ClearAppBarLiftTarget();
}

void ClearAppBarLiftTarget()
{
if (_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.
Comment thread
Dhivya-SF4094 marked this conversation as resolved.
Outdated
if (_liftOnScrollAppBar.LiftOnScrollTargetViewId == _view.Id)
{
_liftOnScrollAppBar.LiftOnScrollTargetViewId = View.NoId;
_liftOnScrollAppBar.SetLifted(false);
}

_liftOnScrollAppBar = null;
}

void TrySetAppBarLiftTarget()
Comment thread
Dhivya-SF4094 marked this conversation as resolved.
Outdated
{
// If a MauiScrollView ancestor exists, it should own the lift target instead.
// The outermost scroll view in the page is the one whose scroll offset should
// drive the AppBar's lifted state.
if (HasAncestorMauiScrollView())
{
return;
}

var appBar = FindAppBarLayout();
if (appBar is null)
{
return;
}

if (_view.Id == View.NoId)
{
_view.Id = View.GenerateViewId();
}

Comment thread
Dhivya-SF4094 marked this conversation as resolved.
Outdated
_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));
}

void OnParentScrollChanged()
{
if (!_view.IsAttachedToWindow || _view.Visibility != ViewStates.Visible)
{
return;
}

bool isOnScreen = _view.GetGlobalVisibleRect(_visibleRect);
bool ownsTarget = _liftOnScrollAppBar is not null;

if (isOnScreen && !ownsTarget)
{
TrySetAppBarLiftTarget();
}
else if (!isOnScreen && ownsTarget)
{
// Release without stopping the listener — we still need to
// detect when the carousel swipes back to this page.
ClearAppBarLiftTarget();
}
}

void StartListeningForParentScrollChanges()
{
if (_scrollChangedListener is not null)
{
return;
}

var observer = _view.ViewTreeObserver;
if (observer is null || !observer.IsAlive)
{
return;
}

_scrollChangedListener = new ScrollChangedListener(this);
observer.AddOnScrollChangedListener(_scrollChangedListener);
}

void StopListeningForParentScrollChanges()
{
if (_scrollChangedListener is null)
{
return;
}

var observer = _view.ViewTreeObserver;
if (observer is not null && observer.IsAlive)
{
observer.RemoveOnScrollChangedListener(_scrollChangedListener);
}

_scrollChangedListener.Dispose();
_scrollChangedListener = null;
}

bool HasAncestorMauiScrollView()
{
var parent = _view.Parent;
while (parent is View parentView)
{
if (parentView is MauiScrollView)
{
return true;
}

// Stop once we reach the AppBarLayout level — anything above that isn't "inside the page".
if (parentView is AppBarLayout ||
(parentView.Id != View.NoId && parentView.Id == Resource.Id.navigationlayout_appbar))
{
return false;
}

parent = parentView.Parent;
}

return false;
}

AppBarLayout? FindAppBarLayout()
{
// Walk up the ancestry looking for an AppBarLayout that is a sibling
// of the content view hosting this scroll view.
// NavigationPage uses Resource.Id.navigationlayout_appbar, but Shell creates
// its AppBarLayout programmatically without an ID, so we match any AppBarLayout.
var parent = _view.Parent;
while (parent is View parentView)
{
if (parentView is ViewGroup group)
{
for (int i = 0; i < group.ChildCount; i++)
{
if (group.GetChildAt(i) is AppBarLayout appBar)
{
return appBar;
}
}
}

parent = parentView.Parent;
}

return null;
}

// Lightweight Java-side listener that forwards ViewTreeObserver scroll
// changes back to the managed helper for carousel page-change detection.
sealed class ScrollChangedListener : Java.Lang.Object, ViewTreeObserver.IOnScrollChangedListener
{
readonly AppBarLiftTargetHelper _helper;

public ScrollChangedListener(AppBarLiftTargetHelper helper)
{
_helper = helper;
}

public void OnScrollChanged()
{
_helper.OnParentScrollChanged();
}
}
}
}
40 changes: 40 additions & 0 deletions src/Core/src/Platform/Android/MauiScrollView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public class MauiScrollView : NestedScrollView, IScrollBarView, NestedScrollView
ScrollBarVisibility _horizontalScrollVisibility;
bool _didSafeAreaEdgeConfigurationChange = true;
bool _isInsetListenerSet;
AppBarLiftTargetHelper? _appBarLiftTargetHelper;

internal float LastX { get; set; }
internal float LastY { get; set; }
Expand Down Expand Up @@ -62,6 +63,21 @@ 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.
_appBarLiftTargetHelper ??= new AppBarLiftTargetHelper(this);
Post(_appBarLiftTargetHelper.TrySetIfOnScreen);
}
}

protected override void OnDetachedFromWindow()
Expand All @@ -72,6 +88,30 @@ protected override void OnDetachedFromWindow()

_isInsetListenerSet = false;
_didSafeAreaEdgeConfigurationChange = true;
if (RuntimeFeature.IsMaterial3Enabled)
{
_appBarLiftTargetHelper?.Clear();
}
}

protected override void OnVisibilityChanged(View changedView, ViewStates visibility)
Comment thread
Dhivya-SF4094 marked this conversation as resolved.
{
base.OnVisibilityChanged(changedView, visibility);

if (!RuntimeFeature.IsMaterial3Enabled)
{
return;
}

if (visibility == ViewStates.Visible)
{
_appBarLiftTargetHelper ??= new AppBarLiftTargetHelper(this);
Post(_appBarLiftTargetHelper.TrySetIfOnScreen);
}
else
{
_appBarLiftTargetHelper?.Clear();
}
}

#region IHandleWindowInsets Implementation
Expand Down
1 change: 1 addition & 0 deletions src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading