Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
60 changes: 60 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,18 +49,76 @@ public class MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource> : Recycler
SimpleItemTouchHelperCallback _itemTouchHelperCallback;
WeakNotifyPropertyChangedProxy _layoutPropertyChangedProxy;
PropertyChangedEventHandler _layoutPropertyChanged;
readonly Java.Lang.IRunnable _setAppBarLiftTargetRunnable;

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

public MauiRecyclerView(Context context, Func<IItemsLayout> getItemsLayout, Func<TAdapter> getAdapter) : base(new ContextThemeWrapper(context, Resource.Style.collectionViewTheme))
{
_getItemsLayout = getItemsLayout ?? throw new ArgumentNullException(nameof(getItemsLayout));
CreateAdapter = getAdapter ?? throw new ArgumentNullException(nameof(getAdapter));
_setAppBarLiftTargetRunnable = new Java.Lang.Runnable(() => this.TrySetAppBarLiftTargetIfOnScreen());
Comment thread
Dhivya-SF4094 marked this conversation as resolved.
Outdated
Comment thread
Dhivya-SF4094 marked this conversation as resolved.
Outdated

_emptyCollectionObserver = new DataChangeObserver(UpdateEmptyViewVisibility);
_itemsUpdateScrollObserver = new DataChangeObserver(AdjustScrollForItemUpdate);
}

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

if (RuntimeFeature.IsMaterial3Enabled)
{
PostTrySetAppBarLiftTargetIfOnScreen();
}
}

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

if (RuntimeFeature.IsMaterial3Enabled)
{
ClearAppBarLiftTargetAndPendingPost();
}
}

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

if (changedView != this)
{
return;
}

if (!RuntimeFeature.IsMaterial3Enabled)
{
return;
}

if (visibility == ViewStates.Visible)
{
PostTrySetAppBarLiftTargetIfOnScreen();
}
else
{
ClearAppBarLiftTargetAndPendingPost();
}
}

void PostTrySetAppBarLiftTargetIfOnScreen()
{
RemoveCallbacks(_setAppBarLiftTargetRunnable);
Post(_setAppBarLiftTargetRunnable);
}

void ClearAppBarLiftTargetAndPendingPost()
{
RemoveCallbacks(_setAppBarLiftTargetRunnable);
this.ClearAppBarLiftTarget();
}

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
242 changes: 242 additions & 0 deletions src/Core/src/Platform/Android/AppbarLayoutExtensions.cs
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;
Comment thread
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;
}
Comment thread
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)
{
Comment thread
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);
}
Comment thread
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);
}
}
}
}
Loading
Loading