diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs
index 558f99d4c436..3d48a2c6c9d6 100644
--- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs
+++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs
@@ -81,7 +81,7 @@ DataTemplate GetDataTemplate(int viewTypeId)
public override void OnViewRecycled(Java.Lang.Object holder)
{
- if (holder is ElementViewHolder evh)
+ if (holder is ElementViewHolder evh && _listItems is not null)
{
// only clear out the Element if the item has been removed
bool found = false;
@@ -208,7 +208,9 @@ protected virtual void OnFlyoutItemsChanged(object sender, EventArgs e)
protected override void Dispose(bool disposing)
{
if (_disposed)
+ {
return;
+ }
_disposed = true;
@@ -222,8 +224,15 @@ protected override void Dispose(bool disposing)
internal void Disconnect()
{
+ if (_shellContext is null)
+ {
+ return;
+ }
+
if (Shell is IShellController scc)
+ {
scc.FlyoutItemsChanged -= OnFlyoutItemsChanged;
+ }
_listItems = null;
_selectedCallback = null;
diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs
index d2311ce85709..36d56d7770dc 100644
--- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs
+++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs
@@ -1,12 +1,8 @@
#nullable disable
using System;
using System.ComponentModel;
-using System.Threading.Tasks;
using Android.Content;
using Android.Graphics.Drawables;
-using Android.Hardware.Lights;
-using Android.Runtime;
-using Android.Util;
using Android.Views;
using Android.Widget;
using AndroidX.CoordinatorLayout.Widget;
@@ -15,12 +11,9 @@
using AndroidX.RecyclerView.Widget;
using Google.Android.Material.AppBar;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Maui.Controls.Internals;
-using Microsoft.Maui.Controls.Platform.Compatibility;
using Microsoft.Maui.Layouts;
using AView = Android.Views.View;
using LP = Android.Views.ViewGroup.LayoutParams;
-
namespace Microsoft.Maui.Controls.Platform.Compatibility
{
public class ShellFlyoutTemplatedContentRenderer : Java.Lang.Object, IShellFlyoutContentRenderer
@@ -211,6 +204,7 @@ protected virtual void LoadView(IShellContext shellContext)
MauiWindowInsetListener.SetupViewWithLocalListener(coordinator, _shellFlyoutListener);
UpdateFlyoutHeaderBehavior();
+
_shellContext.Shell.PropertyChanged += OnShellPropertyChanged;
UpdateFlyoutBackground();
@@ -267,31 +261,51 @@ protected void OnElementSelected(Element element)
protected virtual void OnShellPropertyChanged(object sender, PropertyChangedEventArgs e)
{
+ // When using the new ShellHandler (not the compatibility ShellRenderer),
+ // all these properties are already handled by the handler's property mapper.
+ // Responding to PropertyChanged here would cause double updates.
+ if (_shellContext.Shell.Handler is Handlers.ShellHandler)
+ {
+ return;
+ }
+
if (e.PropertyName == Shell.FlyoutHeaderBehaviorProperty.PropertyName)
+ {
UpdateFlyoutHeaderBehavior();
+ }
else if (e.IsOneOf(
Shell.FlyoutBackgroundColorProperty,
Shell.FlyoutBackgroundProperty,
Shell.FlyoutBackgroundImageProperty,
Shell.FlyoutBackgroundImageAspectProperty))
+ {
UpdateFlyoutBackground();
+ }
else if (e.Is(Shell.FlyoutVerticalScrollModeProperty))
+ {
UpdateVerticalScrollMode();
+ }
else if (e.IsOneOf(
Shell.FlyoutHeaderProperty,
Shell.FlyoutHeaderTemplateProperty))
+ {
UpdateFlyoutHeader();
+ }
else if (e.IsOneOf(
Shell.FlyoutFooterProperty,
Shell.FlyoutFooterTemplateProperty))
+ {
UpdateFlyoutFooter();
+ }
else if (e.IsOneOf(
Shell.FlyoutContentProperty,
Shell.FlyoutContentTemplateProperty))
+ {
UpdateFlyoutContent();
+ }
}
- protected virtual void UpdateFlyoutContent()
+ public virtual void UpdateFlyoutContent()
{
if (!_rootView.IsAlive())
return;
@@ -362,7 +376,7 @@ AView CreateFlyoutContent(ViewGroup rootView)
return _contentView.PlatformView;
}
- protected virtual void UpdateFlyoutHeader()
+ public virtual void UpdateFlyoutHeader()
{
if (_headerView != null)
{
@@ -374,10 +388,7 @@ protected virtual void UpdateFlyoutHeader()
oldHeaderView.Dispose();
}
- if (_flyoutHeader != null)
- {
- _flyoutHeader.MeasureInvalidated -= OnFlyoutHeaderMeasureInvalidated;
- }
+ _flyoutHeader?.MeasureInvalidated -= OnFlyoutHeaderMeasureInvalidated;
_flyoutHeader = ((IShellController)_shellContext.Shell).FlyoutHeader;
if (_flyoutHeader != null)
@@ -414,7 +425,7 @@ void OnHeaderViewLayoutChange(object sender, AView.LayoutChangeEventArgs e)
UpdateContentPadding();
}
- protected virtual void UpdateFlyoutFooter()
+ public virtual void UpdateFlyoutFooter()
{
if (_footerView != null)
{
@@ -427,7 +438,7 @@ protected virtual void UpdateFlyoutFooter()
var footer = ((IShellController)_shellContext.Shell).FlyoutFooter;
- if (footer == null)
+ if (footer is null)
{
UpdateContentPadding();
return;
@@ -592,7 +603,7 @@ void OnFlyoutViewLayoutChanging()
}
}
- void UpdateVerticalScrollMode()
+ public virtual void UpdateVerticalScrollMode()
{
if (_flyoutContentView is RecyclerView rv && rv.GetLayoutManager() is ScrollLayoutManager lm)
{
@@ -600,10 +611,9 @@ void UpdateVerticalScrollMode()
}
}
- protected virtual void UpdateFlyoutBackground()
+ public virtual void UpdateFlyoutBackground()
{
var brush = _shellContext.Shell.FlyoutBackground;
-
if (Brush.IsNullOrEmpty(brush))
{
var color = _shellContext.Shell.FlyoutBackgroundColor;
@@ -672,7 +682,7 @@ void UpdateFlyoutBgImageAsync()
});
}
- protected virtual void UpdateFlyoutHeaderBehavior()
+ public virtual void UpdateFlyoutHeaderBehavior()
{
if (_headerView == null)
return;
@@ -737,8 +747,7 @@ internal void Disconnect()
if (_shellContext?.Shell != null)
_shellContext.Shell.PropertyChanged -= OnShellPropertyChanged;
- if (_flyoutHeader != null)
- _flyoutHeader.MeasureInvalidated -= OnFlyoutHeaderMeasureInvalidated;
+ _flyoutHeader?.MeasureInvalidated -= OnFlyoutHeaderMeasureInvalidated;
_flyoutHeader = null;
@@ -775,8 +784,7 @@ protected override void Dispose(bool disposing)
if (View != null && View is ShellFlyoutLayout sfl)
sfl.LayoutChanging -= OnFlyoutViewLayoutChanging;
- if (_headerView != null)
- _headerView.LayoutChange -= OnHeaderViewLayoutChange;
+ _headerView?.LayoutChange -= OnHeaderViewLayoutChange;
_contentView?.View = null;
@@ -814,8 +822,7 @@ public HeaderContainer(Context context, View view, IMauiContext mauiContext) : b
void Initialize(View view)
{
- if (view != null)
- view.PropertyChanged += OnViewPropertyChanged;
+ view?.PropertyChanged += OnViewPropertyChanged;
}
void OnViewPropertyChanged(object sender, PropertyChangedEventArgs e)
@@ -874,11 +881,8 @@ protected override void Dispose(bool disposing)
internal void Disconnect()
{
- if (View != null)
- {
- View.PropertyChanged -= OnViewPropertyChanged;
- View = null;
- }
+ View?.PropertyChanged -= OnViewPropertyChanged;
+ View = null;
}
internal void SetFlyoutHeaderBehavior(FlyoutHeaderBehavior flyoutHeaderBehavior)
@@ -984,4 +988,4 @@ public override bool CanScrollVertically()
}
}
}
-}
+}
\ No newline at end of file
diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSearchViewAdapter.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSearchViewAdapter.cs
index ee545a21f279..b6c4493a3759 100644
--- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSearchViewAdapter.cs
+++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSearchViewAdapter.cs
@@ -154,6 +154,11 @@ class CustomFilter : Filter
{
private readonly BaseAdapter _adapter;
+ // Required by Android JNI bridge for native handle activation
+ protected CustomFilter(IntPtr javaReference, global::Android.Runtime.JniHandleOwnership transfer) : base(javaReference, transfer)
+ {
+ }
+
public CustomFilter(BaseAdapter adapter)
{
_adapter = adapter;
@@ -169,7 +174,7 @@ protected override FilterResults PerformFiltering(ICharSequence constraint)
protected override void PublishResults(ICharSequence constraint, FilterResults results)
{
- _adapter.NotifyDataSetChanged();
+ _adapter?.NotifyDataSetChanged();
}
}
diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarAppearanceTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarAppearanceTracker.cs
index 1c466efc50ba..a41e0b26291c 100644
--- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarAppearanceTracker.cs
+++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarAppearanceTracker.cs
@@ -20,6 +20,11 @@ public ShellToolbarAppearanceTracker(IShellContext shellContext)
public virtual void SetAppearance(AToolbar toolbar, IShellToolbarTracker toolbarTracker, ShellAppearance appearance)
{
+ if (appearance is null)
+ {
+ return;
+ }
+
var foreground = appearance.ForegroundColor;
var background = appearance.BackgroundColor;
var titleColor = appearance.TitleColor;
diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs
index 330e77d4c13a..fa7de088379b 100644
--- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs
+++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs
@@ -31,6 +31,7 @@
using Paint = Android.Graphics.Paint;
using R = Android.Resource;
+#pragma warning disable IDE0031 // Use null propagation
namespace Microsoft.Maui.Controls.Platform.Compatibility
{
public class ShellToolbarTracker : Java.Lang.Object, AView.IOnClickListener, IShellToolbarTracker, IFlyoutBehaviorObserver
@@ -102,7 +103,10 @@ public bool CanNavigateBack
{
get
{
- if (_page?.Navigation?.NavigationStack?.Count > 1)
+ var navStackCount = _page?.Navigation?.NavigationStack?.Count ?? 0;
+ var canNavFromStack = navStackCount > 1;
+
+ if (canNavFromStack)
return true;
return _canNavigateBack;
@@ -491,7 +495,9 @@ protected virtual async void UpdateLeftBarButtonItem(Context context, AToolbar t
defaultDrawerArrowDrawable = true;
}
- icon?.Progress = (CanNavigateBack && backButtonVisible) ? 1 : 0;
+ var canNav = CanNavigateBack && backButtonVisible;
+ var progress = canNav ? 1 : 0;
+ icon?.Progress = progress;
if (command != null || (CanNavigateBack && backButtonVisible))
{
@@ -521,6 +527,11 @@ protected virtual async void UpdateLeftBarButtonItem(Context context, AToolbar t
_drawerToggle.SyncState();
+ // Re-apply icon Progress AFTER SyncState since SyncState resets it to 0
+ if (icon is not null)
+ {
+ icon.Progress = progress;
+ }
//this needs to be set after SyncState
UpdateToolbarIconAccessibilityText(toolbar, _shell);
@@ -684,6 +695,18 @@ protected virtual void UpdateToolbarItems(AToolbar toolbar, Page page)
if (SearchHandler is not null && SearchHandler.SearchBoxVisibility != SearchBoxVisibility.Hidden)
{
var context = ShellContext.AndroidContext;
+
+ // If the SearchHandler changed (e.g., navigating between pages with different SearchHandlers),
+ // dispose the old search view so it gets recreated with the new handler's icons/settings.
+ if (_searchView is not null && _searchView.SearchHandler != SearchHandler)
+ {
+ _searchView.View.RemoveFromParent();
+ _searchView.View.ViewAttachedToWindow -= OnSearchViewAttachedToWindow;
+ _searchView.SearchConfirmed -= OnSearchConfirmed;
+ _searchView.Dispose();
+ _searchView = null;
+ }
+
if (_searchView is null)
{
_searchView = GetSearchView(context);
@@ -744,6 +767,14 @@ protected virtual void UpdateToolbarItems(AToolbar toolbar, Page page)
}
else
{
+
+ // BUG FIX: Remove the collapsible search menu item when navigating to a page without SearchHandler
+ // Previously, only _searchView was cleaned up, but the menu item remained visible
+ if (menu.FindItem(_placeholderMenuItemId) is not null)
+ {
+ menu.RemoveItem(_placeholderMenuItemId);
+ }
+
if (_searchView is not null)
{
_searchView.View.RemoveFromParent();
diff --git a/src/Controls/src/Core/Handlers/Shell/ShellContentNavigationFragment.Android.cs b/src/Controls/src/Core/Handlers/Shell/ShellContentNavigationFragment.Android.cs
new file mode 100644
index 000000000000..d336e9361265
--- /dev/null
+++ b/src/Controls/src/Core/Handlers/Shell/ShellContentNavigationFragment.Android.cs
@@ -0,0 +1,625 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Android.Content;
+using Android.OS;
+using Android.Views;
+using Android.Widget;
+using AndroidX.Fragment.App;
+using Microsoft.Maui.Controls.Internals;
+using Microsoft.Maui.Controls.Platform.Compatibility;
+using Microsoft.Maui.Graphics;
+using AView = Android.Views.View;
+using LP = Android.Views.ViewGroup.LayoutParams;
+
+namespace Microsoft.Maui.Controls.Handlers
+{
+ ///
+ /// Fragment that hosts a ShellContent page with its own StackNavigationManager.
+ /// This enables each content tab to have independent navigation.
+ ///
+ public class ShellContentNavigationFragment : Fragment, IStackNavigation
+ {
+ ShellContent? _shellContent;
+ IMauiContext? _mauiContext;
+ ShellSectionHandler? _handler;
+ StackNavigationManager? _stackNavigationManager;
+ FragmentContainerView? _navigationContainer;
+ Page? _rootPage;
+ int _navigationContainerId;
+ ShellContentStackNavigationView? _navigationViewAdapter;
+ ShellSection? _subscribedShellSection; // Cached at subscribe time for reliable unsubscribe
+
+ // Default constructor required by Android's FragmentManager for fragment restoration.
+ // When FragmentStateAdapter (ViewPager2) saves and restores fragment state during tab switches,
+ // it uses Fragment.instantiate() which requires a parameterless constructor.
+ public ShellContentNavigationFragment()
+ {
+ }
+
+ public ShellContentNavigationFragment(ShellContent? shellContent, IMauiContext? mauiContext, ShellSectionHandler? handler)
+ {
+ _shellContent = shellContent;
+ _mauiContext = mauiContext;
+ _handler = handler;
+ }
+
+ public override void OnCreate(Bundle? savedInstanceState)
+ {
+ // Always pass null to prevent restoring stale child fragment state.
+ // OffscreenPageLimit keeps fragments alive so restoration shouldn't occur,
+ // but this is defense-in-depth.
+ base.OnCreate(null);
+ }
+
+ public override AView OnCreateView(LayoutInflater inflater, ViewGroup? container, Bundle? savedInstanceState)
+ {
+ // If this fragment was restored by FragmentManager without proper data,
+ // return an empty view. The ViewPager2 adapter will recreate it properly.
+ if (_shellContent is null || _mauiContext is null)
+ {
+ return new FrameLayout(inflater.Context!);
+ }
+
+ if (_navigationContainer is not null)
+ {
+ if (_navigationContainer.Parent is ViewGroup containerParent)
+ {
+ containerParent.RemoveView(_navigationContainer);
+ }
+
+ // Re-subscribe to NavigationRequested if it was unsubscribed in OnDestroyView.
+ // This handles the case where the fragment's view is destroyed and recreated
+ // (e.g., ViewPager2 offscreen recycling) — without this, navigation silently stops.
+ if (!_subscribedToNavigationRequested)
+ {
+ var parentSection = _shellContent?.Parent as ShellSection;
+ if (parentSection is not null)
+ {
+ _subscribedShellSection = parentSection;
+ ((IShellSectionController)parentSection).NavigationRequested += OnNavigationRequested;
+ _subscribedToNavigationRequested = true;
+ }
+ }
+
+ // Check if NavHostFragment needs recreation
+ var existingNavHost = ChildFragmentManager.FindFragmentById(_navigationContainerId);
+ if (existingNavHost is null && _stackNavigationManager is not null)
+ {
+ var recreatedNavHost = new MauiNavHostFragment()
+ {
+ StackNavigationManager = _stackNavigationManager
+ };
+ ChildFragmentManager
+ .BeginTransactionEx()
+ .AddEx(_navigationContainerId, recreatedNavHost)
+ .CommitNowAllowingStateLoss();
+ }
+
+ return _navigationContainer;
+ }
+
+ // Get the root page for this ShellContent
+ _rootPage = ((IShellContentController)_shellContent)?.GetOrCreateContent();
+ if (_rootPage is null)
+ {
+ return new FrameLayout(inflater.Context!);
+ }
+
+ // Subscribe to navigation events EARLY - before anything else
+ // This ensures we catch any navigation requests that come before the view is attached
+ var shellSection = _shellContent?.Parent as ShellSection;
+ if (shellSection is not null && !_subscribedToNavigationRequested)
+ {
+ _subscribedShellSection = shellSection;
+ ((IShellSectionController)shellSection).NavigationRequested += OnNavigationRequested;
+ _subscribedToNavigationRequested = true;
+ }
+
+ // Create FragmentContainerView for navigation stack
+ if (_navigationContainerId == 0)
+ {
+ _navigationContainerId = AView.GenerateViewId();
+ }
+
+ _navigationContainer = new FragmentContainerView(_mauiContext!.Context!)
+ {
+ Id = _navigationContainerId,
+ LayoutParameters = new LP(LP.MatchParent, LP.MatchParent)
+ };
+
+ // Create StackNavigationManager with scoped context
+ var scopedContext = _mauiContext.MakeScoped(fragmentManager: ChildFragmentManager);
+ _stackNavigationManager = _handler is not null
+ ? new ShellStackNavigationManager(scopedContext, _handler)
+ : new StackNavigationManager(scopedContext);
+
+ // Create NavHostFragment
+ var navHostFragment = new MauiNavHostFragment()
+ {
+ StackNavigationManager = _stackNavigationManager
+ };
+
+ ChildFragmentManager
+ .BeginTransactionEx()
+ .AddEx(_navigationContainerId, navHostFragment)
+ .CommitNowAllowingStateLoss();
+
+ _navigationContainer.ViewAttachedToWindow += OnNavigationContainerAttached;
+
+ if (_navigationContainer.IsAttachedToWindow)
+ {
+ ConnectAndInitialize();
+ }
+
+ return _navigationContainer;
+ }
+
+ bool _subscribedToNavigationRequested;
+
+ void OnNavigationContainerAttached(object? sender, AView.ViewAttachedToWindowEventArgs e)
+ {
+ _navigationContainer?.ViewAttachedToWindow -= OnNavigationContainerAttached;
+ ConnectAndInitialize();
+ }
+
+ void ConnectAndInitialize()
+ {
+ if (_stackNavigationManager is null || _rootPage is null)
+ {
+ return;
+ }
+
+ // Create adapter that wraps the root page and delegates NavigationFinished to this fragment
+ _navigationViewAdapter = new ShellContentStackNavigationView(_rootPage, this);
+
+ // Connect using the adapter (which properly implements IStackNavigationView via page delegation)
+ _stackNavigationManager.Connect(_navigationViewAdapter, _navigationContainer);
+
+ // Apply dark/light background to the navigation container when the page has no explicit
+ // Background, matching old ShellPageContainer constructor behavior.
+ // We set it on the container (not the page's platform view) because the page's handler
+ // hasn't been created yet at this point — StackNavigationManager creates it asynchronously.
+ // The page view is transparent by default, so the container background shows through.
+ if (_rootPage is IView view && view.Background is null && _navigationContainer is not null)
+ {
+ var context = _mauiContext!.Context!;
+ bool isDark = Controls.Application.Current?.RequestedTheme == ApplicationModel.AppTheme.Dark;
+ int bgColor = isDark
+ ? AndroidX.Core.Content.ContextCompat.GetColor(context, global::Android.Resource.Color.BackgroundDark)
+ : AndroidX.Core.Content.ContextCompat.GetColor(context, global::Android.Resource.Color.BackgroundLight);
+ _navigationContainer.SetBackgroundColor(new global::Android.Graphics.Color(bgColor));
+ }
+
+ // Subscribe to navigation events if not already subscribed
+ // (may have already been done in OnCreateView)
+ var shellSection = _shellContent?.Parent as ShellSection;
+ if (shellSection is not null && !_subscribedToNavigationRequested)
+ {
+ _subscribedShellSection = shellSection;
+ ((IShellSectionController)shellSection).NavigationRequested += OnNavigationRequested;
+ _subscribedToNavigationRequested = true;
+ }
+
+ // Build the initial stack from the ShellSection's current navigation stack.
+ // Pages may have been pushed (e.g., from OnNavigatedTo) before this fragment
+ // was created by ViewPager2. Those NavigationRequested events were lost because
+ // nobody was subscribed yet. Reconcile by reading the section's actual stack.
+ var initialStack = new List { _rootPage };
+ if (shellSection is not null && shellSection.CurrentItem == _shellContent)
+ {
+ var sectionStack = shellSection.Stack;
+ for (int i = 1; i < sectionStack.Count; i++)
+ {
+ if (sectionStack[i] is not null)
+ {
+ initialStack.Add(sectionStack[i]);
+ }
+ }
+ }
+
+ _stackNavigationManager.RequestNavigation(new NavigationRequest(initialStack, false));
+
+ // Intentionally clearing (not replaying) pending navigation requests.
+ //
+ // When HasNavHost is false, navigation requests are queued into _pendingNavigationRequests.
+ // However, ConnectAndInitialize() builds the initial navigation stack directly from
+ // shellSection.Stack (see above), which already includes any pages pushed while queued.
+ //
+ // Replaying the queue would cause duplicate page pushes because those pages are already
+ // in the stack. Additionally, queued requests have e.Task == null (the TCS is only created
+ // after the HasNavHost check at line ~1043), so there is no hanging Task to worry about —
+ // the caller (ShellSection.OnPushAsync) safely handles null Task.
+ _pendingNavigationRequests.Clear();
+ }
+
+ void OnNavigationRequested(object? sender, NavigationRequestedEventArgs e)
+ {
+ // Only handle navigation for THIS ShellContent
+ // Note: Don't check IsVisible - ViewPager2 may report pages as not visible
+ // even when they are the current item
+ if (_stackNavigationManager is null)
+ {
+ return;
+ }
+
+ var shellSection = _shellContent?.Parent as ShellSection;
+ if (shellSection is null || shellSection.CurrentItem != _shellContent)
+ {
+ return;
+ }
+
+ // Check if StackNavigationManager is ready (has NavHost)
+ if (!_stackNavigationManager.HasNavHost)
+ {
+ // Queue the navigation request to be processed after initialization
+ _pendingNavigationRequests.Enqueue(e);
+ return;
+ }
+
+ // Create a TaskCompletionSource to serialize navigation requests.
+ // ShellSection.OnPushAsync awaits e.Task — without this, multiple pushes
+ // fire in rapid succession and BuildNavigationStack reads stale NavigationStack,
+ // causing intermediate pages to be lost from the stack.
+ //
+ // Skip TCS when StackNavigationManager is already navigating (e.g., initial setup
+ // from ConnectAndInitialize). In that case, the push will be queued internally by
+ // StackNavigationManager and processed after the current navigation completes via
+ // ProcessNavigationQueue. Creating a TCS here would block Shell.GoToAsync because
+ // the initial navigation's NavigationFinished would complete the wrong TCS.
+ if (!_stackNavigationManager.IsNavigating)
+ {
+ var tcs = new System.Threading.Tasks.TaskCompletionSource();
+ _navigationTaskCompletionSource = tcs;
+ e.Task = tcs.Task;
+ }
+
+ var requestedStack = BuildNavigationStack(e);
+ if (requestedStack is not null && requestedStack.Count > 0)
+ {
+ // Update logical stack so subsequent rapid pushes build from the correct state
+ _logicalNavigationStack = requestedStack;
+ _stackNavigationManager.RequestNavigation(new NavigationRequest(requestedStack, e.Animated));
+ }
+ else
+ {
+ // Nothing to navigate — complete immediately
+ var pendingTcs = _navigationTaskCompletionSource;
+ _navigationTaskCompletionSource = null;
+ pendingTcs?.TrySetResult(true);
+ }
+ }
+
+ System.Threading.Tasks.TaskCompletionSource? _navigationTaskCompletionSource;
+ readonly Queue _pendingNavigationRequests = new Queue();
+
+ // Tracks the logical navigation stack including pending/queued pushes that haven't
+ // been applied yet. This prevents the 3+ rapid PushAsync bug where BuildNavigationStack
+ // reads stale NavigationStack and drops intermediate pages.
+ List? _logicalNavigationStack;
+
+ List BuildNavigationStack(NavigationRequestedEventArgs e)
+ {
+ var currentStack = _logicalNavigationStack
+ ?? _stackNavigationManager?.NavigationStack?.ToList()
+ ?? new List();
+
+ switch (e.RequestType)
+ {
+ case NavigationRequestType.Push:
+ var pushStack = new List(currentStack);
+ if (e.Page is not null)
+ {
+ pushStack.Add(e.Page);
+ }
+ return pushStack;
+
+ case NavigationRequestType.Pop:
+ if (currentStack.Count > 1)
+ {
+ var popStack = new List(currentStack);
+ popStack.RemoveAt(popStack.Count - 1);
+ return popStack;
+ }
+ return currentStack.ToList();
+
+ case NavigationRequestType.PopToRoot:
+ if (currentStack.Count > 0)
+ {
+ return new List { currentStack[0] };
+ }
+ break;
+
+ case NavigationRequestType.Insert:
+ case NavigationRequestType.Remove:
+ var section = _shellContent?.Parent as ShellSection;
+ if (section is not null)
+ {
+ var resultStack = new List();
+ foreach (var page in section.Stack)
+ {
+ resultStack.Add(page ?? _rootPage!);
+ }
+ return resultStack;
+ }
+ break;
+ }
+
+ return currentStack.ToList();
+ }
+
+ void IStackNavigation.RequestNavigation(NavigationRequest request)
+ {
+ _stackNavigationManager?.RequestNavigation(request);
+ }
+
+ // IStackNavigation.NavigationFinished - delegates to the internal method
+ void IStackNavigation.NavigationFinished(IReadOnlyList newStack)
+ {
+ OnNavigationFinished(newStack);
+ }
+
+ ///
+ /// Called by ShellContentStackNavigationView when navigation completes.
+ /// Updates toolbar and tab visibility based on the new navigation stack.
+ ///
+ internal void OnNavigationFinished(IReadOnlyList newStack)
+ {
+ // Sync logical stack with the actual completed state.
+ // If the manager is no longer navigating (queue drained), clear the override
+ // so BuildNavigationStack reads fresh NavigationStack next time.
+ if (_stackNavigationManager is null || !_stackNavigationManager.IsNavigating)
+ {
+ _logicalNavigationStack = null;
+ }
+
+ // Complete the pending navigation task so ShellSection.OnPushAsync can proceed
+ var tcs = _navigationTaskCompletionSource;
+ _navigationTaskCompletionSource = null;
+ tcs?.TrySetResult(true);
+
+ if (!IsVisible || _handler is null)
+ {
+ return;
+ }
+
+ // Check if this content is currently active
+ var shellSection = _shellContent?.Parent as ShellSection;
+ var shellItem = shellSection?.Parent as ShellItem;
+ var shell = shellItem?.Parent as Shell;
+
+ if (shellSection is null || shellItem is null || shell is null)
+ {
+ return;
+ }
+
+ if (shell.CurrentItem != shellItem ||
+ shellItem.CurrentItem != shellSection ||
+ shellSection.CurrentItem != _shellContent)
+ {
+ return;
+ }
+
+ // Update toolbar
+ if (newStack.Count > 0 && newStack[newStack.Count - 1] is Page currentPage)
+ {
+ var toolbarTracker = _handler.ToolbarTracker;
+ var shellToolbar = _handler.ShellToolbar;
+ if (toolbarTracker is not null && shellToolbar is not null)
+ {
+ toolbarTracker.CanNavigateBack = newStack.Count > 1;
+
+ if (toolbarTracker.Page != currentPage)
+ {
+ toolbarTracker.Page = currentPage;
+ }
+
+ shellToolbar.Handler?.UpdateValue(nameof(IToolbar.BackButtonVisible));
+
+ if (shell.Toolbar is ShellToolbar st)
+ {
+ st.ApplyChanges();
+ }
+
+ ((IShellController)shell).AppearanceChanged(currentPage, false);
+ }
+ }
+
+ // Hide/show content tabs based on navigation depth.
+ // Push beyond root → hide top tabs; pop to root → show top tabs.
+ // Uses PlaceTopTabs/RemoveTopTabs (fragment add/remove in NRM slot).
+ var shouldShowTabs = newStack.Count == 1 && ((IShellSectionController)shellSection).GetItems().Count > 1;
+
+ if (shouldShowTabs)
+ {
+ _handler.PlaceTopTabs();
+ }
+ else
+ {
+ _handler.RemoveTopTabs();
+ }
+ }
+
+ internal static void CloseActiveActionModes(AView? view)
+ {
+ if (view is global::Android.Widget.ListView listView)
+ {
+ // Unwrap HeaderViewListAdapter — ListViewRenderer adds header/footer views
+ var adapter = listView.Adapter;
+ if (adapter is HeaderViewListAdapter headerAdapter)
+ {
+ adapter = headerAdapter.WrappedAdapter;
+ }
+
+ if (adapter is Compatibility.CellAdapter cellAdapter)
+ {
+ cellAdapter.CloseContextActions();
+ }
+ return;
+ }
+
+ if (view is ViewGroup viewGroup)
+ {
+ for (int i = 0; i < viewGroup.ChildCount; i++)
+ {
+ CloseActiveActionModes(viewGroup.GetChildAt(i));
+ }
+ }
+ }
+
+ public override void OnDestroyView()
+ {
+
+ if (_shellContent is null)
+ {
+ base.OnDestroyView();
+ return;
+ }
+
+ // Disconnect StackNavigationManager IMMEDIATELY when the fragment view is destroyed.
+ // This removes the ViewAttachedToWindow listener before ViewPager2 can recycle/re-attach
+ // the view, preventing IllegalStateException from accessing Fragment on a destroyed view.
+ _navigationContainer?.ViewAttachedToWindow -= OnNavigationContainerAttached;
+
+ _stackNavigationManager?.Disconnect();
+
+ // Clear cached state so the next OnCreateView goes through the full creation path
+ // instead of the reuse path. This ensures ConnectAndInitialize runs properly with
+ // a fresh NavHostFragment and StackNavigationManager connection.
+ // Without this, the reuse path returns the old container with a disconnected manager,
+ // leaving HasNavHost == false and navigation requests queued forever.
+ _navigationContainer = null;
+ _stackNavigationManager = null;
+
+ // Disconnect the root page and all its child handlers when the ShellContent has been
+ // removed from the Shell hierarchy (e.g. Shell.Items.Clear()).
+ // The guard prevents premature disconnection during normal back-navigation where the
+ // page is still cached and will be reused — without it, handlers like WebView would
+ // be incorrectly released mid-navigation.
+ if (_shellContent?.FindParentOfType() is null)
+ {
+ _rootPage?.DisconnectHandlers();
+ }
+
+ if (_subscribedToNavigationRequested)
+ {
+ if (_subscribedShellSection is not null)
+ {
+ ((IShellSectionController)_subscribedShellSection).NavigationRequested -= OnNavigationRequested;
+ }
+ _subscribedToNavigationRequested = false;
+ }
+
+ base.OnDestroyView();
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && _shellContent is null)
+ {
+ base.Dispose(disposing);
+ return;
+ }
+
+ if (disposing)
+ {
+ _navigationContainer?.ViewAttachedToWindow -= OnNavigationContainerAttached;
+
+ if (_subscribedToNavigationRequested)
+ {
+ if (_subscribedShellSection is not null)
+ {
+ ((IShellSectionController)_subscribedShellSection).NavigationRequested -= OnNavigationRequested;
+ }
+ _subscribedToNavigationRequested = false;
+ }
+
+ _pendingNavigationRequests.Clear();
+ _navigationViewAdapter = null;
+ _stackNavigationManager?.Disconnect();
+ _stackNavigationManager = null;
+ _navigationContainer = null;
+ _subscribedShellSection = null;
+ _rootPage = null;
+ }
+ base.Dispose(disposing);
+ }
+ }
+
+ ///
+ /// Adapter that wraps a Page (which is an IView) and adds IStackNavigation behavior
+ /// by delegating NavigationFinished to the owning fragment.
+ /// This is a cleaner approach than having the Fragment implement IStackNavigationView
+ /// with 40+ stub properties.
+ ///
+ internal class ShellContentStackNavigationView : IStackNavigationView
+ {
+ readonly Page _page;
+ readonly ShellContentNavigationFragment _fragment;
+
+ public ShellContentStackNavigationView(Page page, ShellContentNavigationFragment fragment)
+ {
+ _page = page ?? throw new ArgumentNullException(nameof(page));
+ _fragment = fragment ?? throw new ArgumentNullException(nameof(fragment));
+ }
+
+ // IStackNavigation - delegate to fragment
+ void IStackNavigation.RequestNavigation(NavigationRequest request)
+ {
+ // Navigation requests go through the fragment
+ ((IStackNavigation)_fragment).RequestNavigation(request);
+ }
+
+ // IStackNavigationView - delegate to fragment for the callback
+ public void NavigationFinished(IReadOnlyList newStack)
+ {
+ _fragment.OnNavigationFinished(newStack);
+ }
+
+ // IView - delegate all properties to the underlying Page
+ public Size Arrange(Rect bounds) => ((IView)_page).Arrange(bounds);
+ public Size Measure(double widthConstraint, double heightConstraint) => ((IView)_page).Measure(widthConstraint, heightConstraint);
+ public void InvalidateMeasure() => ((IView)_page).InvalidateMeasure();
+ public void InvalidateArrange() => ((IView)_page).InvalidateArrange();
+ public bool Focus() => ((IView)_page).Focus();
+ public void Unfocus() => ((IView)_page).Unfocus();
+
+ public string AutomationId => ((IView)_page).AutomationId;
+ public FlowDirection FlowDirection => ((IView)_page).FlowDirection;
+ public Primitives.LayoutAlignment HorizontalLayoutAlignment => ((IView)_page).HorizontalLayoutAlignment;
+ public Primitives.LayoutAlignment VerticalLayoutAlignment => ((IView)_page).VerticalLayoutAlignment;
+ public Semantics? Semantics => ((IView)_page).Semantics;
+ public IShape? Clip => ((IView)_page).Clip;
+ public IShadow? Shadow => ((IView)_page).Shadow;
+ public bool IsEnabled => ((IView)_page).IsEnabled;
+ public bool IsFocused { get => ((IView)_page).IsFocused; set => ((IView)_page).IsFocused = value; }
+ public Visibility Visibility => ((IView)_page).Visibility;
+ public double Opacity => ((IView)_page).Opacity;
+ public Paint? Background => ((IView)_page).Background;
+ public Rect Frame { get => ((IView)_page).Frame; set => ((IView)_page).Frame = value; }
+ public double Width => ((IView)_page).Width;
+ public double MinimumWidth => ((IView)_page).MinimumWidth;
+ public double MaximumWidth => ((IView)_page).MaximumWidth;
+ public double Height => ((IView)_page).Height;
+ public double MinimumHeight => ((IView)_page).MinimumHeight;
+ public double MaximumHeight => ((IView)_page).MaximumHeight;
+ public Thickness Margin => ((IView)_page).Margin;
+ public Size DesiredSize => ((IView)_page).DesiredSize;
+ public int ZIndex => ((IView)_page).ZIndex;
+ public IViewHandler? Handler { get => _page.Handler; set => _page.Handler = value; }
+ public bool InputTransparent => ((IView)_page).InputTransparent;
+ IElementHandler? IElement.Handler { get => _page.Handler; set => _page.Handler = value as IViewHandler; }
+ public IElement? Parent => ((IElement)_page).Parent;
+ public double TranslationX => ((IView)_page).TranslationX;
+ public double TranslationY => ((IView)_page).TranslationY;
+ public double Scale => ((IView)_page).Scale;
+ public double ScaleX => ((IView)_page).ScaleX;
+ public double ScaleY => ((IView)_page).ScaleY;
+ public double Rotation => ((IView)_page).Rotation;
+ public double RotationX => ((IView)_page).RotationX;
+ public double RotationY => ((IView)_page).RotationY;
+ public double AnchorX => ((IView)_page).AnchorX;
+ public double AnchorY => ((IView)_page).AnchorY;
+ }
+}
diff --git a/src/Controls/src/Core/Handlers/Shell/ShellHandler.Android.cs b/src/Controls/src/Core/Handlers/Shell/ShellHandler.Android.cs
new file mode 100644
index 000000000000..d2ba92e03294
--- /dev/null
+++ b/src/Controls/src/Core/Handlers/Shell/ShellHandler.Android.cs
@@ -0,0 +1,766 @@
+#nullable enable
+using System;
+using Android.Content;
+using AndroidX.CoordinatorLayout.Widget;
+using AndroidX.DrawerLayout.Widget;
+using Microsoft.Maui.Controls.Platform;
+using Microsoft.Maui.Controls.Platform.Compatibility;
+using AView = Android.Views.View;
+using AToolbar = AndroidX.AppCompat.Widget.Toolbar;
+using LP = Android.Views.ViewGroup.LayoutParams;
+using APaint = Android.Graphics.Paint;
+using ACanvas = Android.Graphics.Canvas;
+using ADrawable = Android.Graphics.Drawables.Drawable;
+using AFormat = Android.Graphics.Format;
+using AColorFilter = Android.Graphics.ColorFilter;
+using GPaint = Microsoft.Maui.Graphics.Paint;
+using Microsoft.Maui.Graphics;
+namespace Microsoft.Maui.Controls.Handlers
+{
+ ///
+ /// Shell handler that uses MauiDrawerLayout (same infrastructure as FlyoutViewHandler).
+ /// This replaces the old ShellFlyoutRenderer-based approach.
+ ///
+ public partial class ShellHandler : ViewHandler, IShellContext, IAppearanceObserver
+ {
+ // MauiDrawerLayout is now the PlatformView (same as FlyoutViewHandler)
+ MauiDrawerLayout MauiDrawerLayout => PlatformView;
+
+ // Navigation root (inflated navigationlayout.axml) — provides named slots
+ // for content, toolbar, bottom tabs, and top tabs (same as FlyoutViewHandler).
+ AView? _navigationRoot;
+
+ // Flyout content view (Shell's flyout menu)
+ AView? _flyoutContentView;
+ IShellFlyoutContentRenderer? _flyoutContentRenderer;
+
+ // Current shell item renderer
+ IShellItemRenderer? _currentShellItemRenderer;
+
+ // ShellItem handler — placed once in navigationlayout_content, reused
+ // across ShellItem switches via SwitchToShellItem() (no fragment replacement).
+ ShellItemHandler? _shellItemHandler;
+
+ // Track flyout behavior for IShellContext
+ FlyoutBehavior _currentBehavior = FlyoutBehavior.Flyout;
+
+ // Track the current scrim brush from appearance observer (matches old ShellFlyoutRenderer pattern).
+ // Initialized to Brush.Transparent because the appearance observer fires with null first.
+ Brush _scrimBrush = Brush.Transparent;
+
+ // Gradient scrim drawable — replaces the built-in scrim when a non-solid brush is used.
+ // Set as Foreground on _navigationRoot so it draws on top of ALL children (including AppBarLayout).
+ // Matches ShellFlyoutRenderer's DrawChild + Paint approach using Paint with gradient shader.
+ ScrimBrushDrawable? _scrimDrawable;
+
+ // Pending fragment transaction (from RunOrWaitForResume, same as FlyoutViewHandler)
+ IDisposable? _pendingFragment;
+
+ protected override MauiDrawerLayout CreatePlatformView()
+ {
+ // Create MauiDrawerLayout (same as FlyoutViewHandler)
+ var drawerLayout = new MauiDrawerLayout(Context);
+
+ // Use Padding layout mode to match old ShellFlyoutRenderer behavior.
+ // In locked mode, the content is padded left by the flyout width
+ // (the drawer stays open and content shifts right).
+ drawerLayout.FlyoutLayoutModeValue = MauiDrawerLayout.FlyoutLayoutMode.Padding;
+
+ // Inflate navigationlayout.axml — provides named slots for content,
+ // toolbar, bottom tabs, and top tabs (same as FlyoutViewHandler pattern).
+ // This enables Shell to use the same NRM infrastructure as non-Shell.
+ var layoutInflater = MauiContext?.GetLayoutInflater()
+ ?? throw new InvalidOperationException("LayoutInflater missing");
+ _navigationRoot = layoutInflater.Inflate(Resource.Layout.navigationlayout, null)
+ ?? throw new InvalidOperationException("navigationlayout inflation failed");
+
+ // Add navigation root as content inside MauiDrawerLayout.
+ // Fragment transactions target Resource.Id.navigationlayout_content
+ // (a static ID from the layout) instead of a dynamic GenerateViewId().
+ drawerLayout.AddView(_navigationRoot, new LP(LP.MatchParent, LP.MatchParent));
+
+ // Set as content view for MauiDrawerLayout's internal tracking
+ drawerLayout.SetContentView(_navigationRoot);
+
+ return drawerLayout;
+ }
+
+ protected override void ConnectHandler(MauiDrawerLayout platformView)
+ {
+ base.ConnectHandler(platformView);
+
+ // Window insets setup for the CoordinatorLayout (same as FlyoutViewHandler)
+ if (_navigationRoot is CoordinatorLayout cl)
+ {
+ MauiWindowInsetListener.SetupViewWithLocalListener(cl);
+ }
+
+ // Add appearance observer similar to ShellRenderer
+ ((IShellController)VirtualView).AddAppearanceObserver(this, VirtualView);
+
+ // Subscribe to drawer state changes
+ platformView.OnPresentedChanged += OnFlyoutPresentedChanged;
+ platformView.DrawerSlide += OnDrawerSlide;
+
+ // Handle initial item switch when view is attached to the window.
+ // MapCurrentItem fires during SetVirtualView before the view is in the
+ // Activity's hierarchy, so we defer the initial load to ViewAttachedToWindow
+ // (same pattern as FlyoutViewHandler.DrawerLayoutAttached).
+ platformView.ViewAttachedToWindow += OnPlatformViewAttachedToWindow;
+
+ // Initialize flyout behavior
+ var behavior = (VirtualView as IFlyoutView)?.FlyoutBehavior ?? FlyoutBehavior.Flyout;
+ _currentBehavior = behavior;
+ UpdateFlyoutBehaviorInternal(behavior);
+ }
+
+ protected override void DisconnectHandler(MauiDrawerLayout platformView)
+ {
+ if (VirtualView is not null)
+ {
+ ((IShellController)VirtualView).RemoveAppearanceObserver(this);
+ }
+
+ platformView.OnPresentedChanged -= OnFlyoutPresentedChanged;
+ platformView.DrawerSlide -= OnDrawerSlide;
+
+ RemoveScrimDrawable();
+
+ _currentShellItemRenderer?.Dispose();
+ _currentShellItemRenderer = null;
+ _shellItemHandler = null;
+
+ if (_flyoutContentRenderer is IDisposable disposable)
+ {
+ disposable.Dispose();
+ }
+ _flyoutContentRenderer = null;
+ _flyoutContentView = null;
+
+ // Clean up pending fragment transaction
+ _pendingFragment?.Dispose();
+ _pendingFragment = null;
+
+ // Clean up ViewAttachedToWindow listener
+ platformView.ViewAttachedToWindow -= OnPlatformViewAttachedToWindow;
+
+ // Clean up window insets and navigation root
+ if (_navigationRoot is CoordinatorLayout cl)
+ {
+ MauiWindowInsetListener.RemoveViewWithLocalListener(cl);
+ }
+ _navigationRoot = null;
+
+ // Disconnect MauiDrawerLayout
+ platformView.Disconnect();
+
+ base.DisconnectHandler(platformView);
+ }
+
+ void OnFlyoutPresentedChanged(bool isPresented)
+ {
+ // Sync the Shell's FlyoutIsPresented property with actual drawer state
+ if (_currentBehavior == FlyoutBehavior.Flyout)
+ {
+ VirtualView?.SetValueFromRenderer(Shell.FlyoutIsPresentedProperty, isPresented);
+ }
+ }
+
+ void SwitchToItem(ShellItem newItem, bool animate)
+ {
+ if (newItem is null)
+ {
+ return;
+ }
+
+ // Subsequent switches: reuse the permanent fragment, update handler data.
+ // No fragment transaction — the wrapper fragment stays in navigationlayout_content.
+ if (_shellItemHandler is not null)
+ {
+ _shellItemHandler.SwitchToShellItem(newItem);
+ return;
+ }
+
+ // First switch: create handler + fragment, add to layout
+ _pendingFragment?.Dispose();
+ _pendingFragment = null;
+
+ var context = MauiContext?.Context;
+
+ if (context is null || _navigationRoot is null)
+ {
+ return;
+ }
+
+ var fragmentManager = MauiContext!.GetFragmentManager();
+
+ _currentShellItemRenderer = CreateShellItemRenderer(newItem);
+ _currentShellItemRenderer.ShellItem = newItem;
+
+ var fragment = _currentShellItemRenderer.Fragment;
+
+ // Store handler reference for future switches.
+ // After this fragment transaction, the wrapper fragment stays in
+ // navigationlayout_content — subsequent ShellItem switches update
+ // the handler's data instead of replacing the fragment.
+ if (_currentShellItemRenderer is ShellItemHandlerAdapter adapter)
+ {
+ _shellItemHandler = adapter.GetHandler();
+ }
+
+ // Use RunOrWaitForResume for state-saved safety (same as FlyoutViewHandler),
+ // but CommitNow() instead of Commit() because Shell's navigation flow requires
+ // the fragment to be immediately in the hierarchy — page Appearing events
+ // and NavigationRequested handlers fire synchronously after this returns.
+ _pendingFragment = fragmentManager.RunOrWaitForResume(context, (fm) =>
+ {
+ var transaction = fm.BeginTransaction();
+
+ if (animate)
+ {
+ transaction.SetTransition((int)global::Android.App.FragmentTransit.FragmentOpen);
+ }
+
+ transaction
+ .Replace(Resource.Id.navigationlayout_content, fragment)
+ .SetReorderingAllowed(true)
+ .CommitNow();
+ });
+ }
+
+ void OnPlatformViewAttachedToWindow(object? sender, AView.ViewAttachedToWindowEventArgs e)
+ {
+ PlatformView.ViewAttachedToWindow -= OnPlatformViewAttachedToWindow;
+
+ // Initial load: now that the view is in the Activity's hierarchy,
+ // the FragmentManager can find navigationlayout_content by ID.
+ if (VirtualView?.CurrentItem is not null && _navigationRoot is not null)
+ {
+ SwitchToItem(VirtualView.CurrentItem, animate: false);
+ }
+ }
+
+ public static void MapCurrentItem(ShellHandler handler, Shell shell)
+ {
+ // During initial SetVirtualView, MapCurrentItem fires before the view is
+ // in the Activity's hierarchy — skip it. The initial load is handled by
+ // OnPlatformViewAttachedToWindow (same pattern as FlyoutViewHandler.DrawerLayoutAttached).
+ if (!handler.PlatformView.IsAttachedToWindow)
+ {
+ return;
+ }
+
+ handler.SwitchToItem(shell.CurrentItem, animate: true);
+ }
+
+ public static void MapIsPresented(ShellHandler handler, Shell shell)
+ {
+ // Use MauiDrawerLayout's open/close methods
+ if (handler.MauiDrawerLayout is not null)
+ {
+ if (shell.FlyoutIsPresented)
+ {
+ handler.MauiDrawerLayout.OpenFlyout();
+ }
+ else
+ {
+ handler.MauiDrawerLayout.CloseFlyout();
+ }
+ }
+ }
+
+ public static void MapFlyoutBehavior(ShellHandler handler, Shell shell)
+ {
+ var behavior = (shell as IFlyoutView).FlyoutBehavior;
+ handler.UpdateFlyoutBehaviorInternal(behavior);
+ }
+
+ void UpdateFlyoutBehaviorInternal(FlyoutBehavior behavior)
+ {
+ _currentBehavior = behavior;
+
+ // Ensure flyout content is added for non-disabled behaviors
+ if (behavior != FlyoutBehavior.Disabled)
+ {
+ EnsureFlyoutContentCreated();
+ }
+
+ // Use MauiDrawerLayout's SetBehavior method
+ MauiDrawerLayout?.SetBehavior(behavior);
+
+ // Update content padding for locked mode
+ if (behavior == FlyoutBehavior.Locked && _navigationRoot is not null)
+ {
+ var padding = MauiDrawerLayout?.GetLockedContentPadding() ?? 0;
+ // Use SetPaddingRelative so padding is applied to the Start side,
+ // matching MauiDrawerLayout's GravityFlags.Start drawer placement.
+ // In LTR: start = left. In RTL: start = right.
+ _navigationRoot.SetPaddingRelative(padding, _navigationRoot.PaddingTop, _navigationRoot.PaddingEnd, _navigationRoot.PaddingBottom);
+ }
+ else
+ {
+ _navigationRoot?.SetPaddingRelative(0, _navigationRoot.PaddingTop, _navigationRoot.PaddingEnd, _navigationRoot.PaddingBottom);
+ }
+
+ // Sync Shell property
+ if (behavior == FlyoutBehavior.Locked)
+ {
+ VirtualView?.SetValueFromRenderer(Shell.FlyoutIsPresentedProperty, true);
+ }
+ else if (behavior == FlyoutBehavior.Disabled)
+ {
+ VirtualView?.SetValueFromRenderer(Shell.FlyoutIsPresentedProperty, false);
+ }
+
+ // Re-evaluate scrim for behavior change (locked = no scrim)
+ if (VirtualView is not null)
+ {
+ UpdateScrim(_scrimBrush);
+ }
+ }
+
+ void EnsureFlyoutContentCreated()
+ {
+ if (_flyoutContentView is not null)
+ return;
+
+ // Create the flyout content renderer
+ _flyoutContentRenderer = CreateShellFlyoutContentRenderer();
+ _flyoutContentView = _flyoutContentRenderer.AndroidView;
+
+ if (_flyoutContentView is not null)
+ {
+ // Set flyout width from MauiDrawerLayout's default calculation
+ var flyoutWidth = VirtualView.FlyoutWidth;
+ if (flyoutWidth == -1)
+ {
+ flyoutWidth = MauiDrawerLayout.DefaultFlyoutWidth;
+ }
+ else
+ {
+ flyoutWidth = Context.ToPixels(flyoutWidth);
+ }
+
+ MauiDrawerLayout.FlyoutWidth = flyoutWidth;
+ MauiDrawerLayout.SetFlyoutView(_flyoutContentView);
+ }
+ }
+
+ public static void MapFlyoutWidth(ShellHandler handler, Shell shell)
+ {
+ // Update the flyout width using MauiDrawerLayout
+ if (handler.MauiDrawerLayout is not null)
+ {
+ var width = shell.FlyoutWidth;
+ if (width == -1)
+ {
+ width = handler.MauiDrawerLayout.DefaultFlyoutWidth;
+ }
+ else
+ {
+ width = handler.Context.ToPixels(width);
+ }
+
+ handler.MauiDrawerLayout.FlyoutWidth = width;
+
+ // Update the flyout view's layout params
+ if (handler._flyoutContentView?.LayoutParameters is not null)
+ {
+ handler._flyoutContentView.LayoutParameters.Width = (int)width;
+ handler._flyoutContentView.RequestLayout();
+ }
+
+ // Recompute locked content padding if in locked mode
+ if (handler._currentBehavior == FlyoutBehavior.Locked && handler._navigationRoot is not null)
+ {
+ var padding = handler.MauiDrawerLayout.GetLockedContentPadding();
+ handler._navigationRoot.SetPaddingRelative(padding, handler._navigationRoot.PaddingTop, handler._navigationRoot.PaddingEnd, handler._navigationRoot.PaddingBottom);
+ }
+ }
+ }
+
+ public static void MapFlyoutHeight(ShellHandler handler, Shell shell)
+ {
+ if (handler._flyoutContentView?.LayoutParameters is null)
+ {
+ return;
+ }
+
+ var height = shell.FlyoutHeight;
+ int heightPixels;
+ if (height == -1)
+ {
+ heightPixels = LP.MatchParent;
+ }
+ else
+ {
+ heightPixels = (int)handler.Context.ToPixels(height);
+ }
+
+ handler._flyoutContentView.LayoutParameters.Height = heightPixels;
+ handler._flyoutContentView.RequestLayout();
+ }
+
+ public static void MapFlowDirection(ShellHandler handler, Shell shell)
+ {
+ // Update the flow direction on the MauiDrawerLayout
+ if (handler.MauiDrawerLayout is null)
+ {
+ return;
+ }
+
+ handler.MauiDrawerLayout.LayoutDirection = shell.FlowDirection.ToLayoutDirection();
+ }
+
+ const uint DefaultScrimColor = 0x99000000;
+
+ public static void MapFlyoutBackdrop(ShellHandler handler, Shell shell)
+ {
+ if (handler.MauiDrawerLayout is null)
+ {
+ return;
+ }
+
+ handler._scrimBrush = shell.FlyoutBackdrop;
+ handler.UpdateScrim(handler._scrimBrush);
+ }
+
+ void UpdateScrim(Brush backdrop)
+ {
+ if (MauiDrawerLayout is null)
+ {
+ return;
+ }
+
+ if (_currentBehavior == FlyoutBehavior.Locked)
+ {
+ MauiDrawerLayout.SetScrimColor(Colors.Transparent.ToPlatform());
+ RemoveScrimDrawable();
+ return;
+ }
+
+ if (backdrop is SolidColorBrush solidColor)
+ {
+ RemoveScrimDrawable();
+
+ var backdropColor = solidColor.Color;
+ if (backdropColor is null)
+ {
+ unchecked
+ {
+ MauiDrawerLayout.SetScrimColor((int)DefaultScrimColor);
+ }
+ }
+ else
+ {
+ MauiDrawerLayout.SetScrimColor(backdropColor.ToPlatform());
+ }
+ }
+ else if (backdrop is not null && backdrop != Brush.Default && backdrop != Brush.Transparent)
+ {
+ // Gradient or other non-solid brush: disable built-in scrim and use Foreground drawable.
+ // The Foreground draws on top of ALL children (including AppBarLayout/Toolbar),
+ // matching ShellFlyoutRenderer's DrawChild + Paint approach.
+ MauiDrawerLayout.SetScrimColor(Colors.Transparent.ToPlatform());
+ SetScrimDrawable(backdrop);
+ }
+ else
+ {
+ // Default scrim for null/default brushes
+ RemoveScrimDrawable();
+ unchecked
+ {
+ MauiDrawerLayout.SetScrimColor((int)DefaultScrimColor);
+ }
+ }
+ }
+
+ void OnDrawerSlide(object? sender, DrawerLayout.DrawerSlideEventArgs e)
+ {
+ if (_scrimDrawable is ScrimBrushDrawable scrim)
+ {
+ scrim.Alpha = (int)(e.SlideOffset * 255);
+ }
+ }
+
+ void SetScrimDrawable(Brush brush)
+ {
+ var drawable = new ScrimBrushDrawable(brush);
+
+ // Dispose old drawable if replacing
+ if (_scrimDrawable is not null)
+ {
+ _navigationRoot?.Foreground = null;
+ _scrimDrawable.Dispose();
+ }
+
+ _scrimDrawable = drawable;
+ _navigationRoot?.Foreground = _scrimDrawable;
+ }
+
+ void RemoveScrimDrawable()
+ {
+ if (_scrimDrawable is null)
+ {
+ return;
+ }
+
+ _navigationRoot?.Foreground = null;
+ _scrimDrawable.Dispose();
+ _scrimDrawable = null;
+ }
+
+ public static void MapFlyoutBackground(ShellHandler handler, Shell shell)
+ {
+ // Update the flyout content renderer when background changes
+ if (handler._flyoutContentRenderer is ShellFlyoutTemplatedContentRenderer templatedRenderer)
+ {
+ templatedRenderer.UpdateFlyoutBackground();
+ }
+ }
+
+ public static void MapFlyoutBackgroundImage(ShellHandler handler, Shell shell)
+ {
+ // Update the flyout content renderer when background image changes
+ if (handler._flyoutContentRenderer is ShellFlyoutTemplatedContentRenderer templatedRenderer)
+ {
+ templatedRenderer.UpdateFlyoutBackground();
+ }
+ }
+
+ public static void MapFlyoutHeader(ShellHandler handler, Shell shell)
+ {
+ // Update the flyout header when it changes
+ if (handler._flyoutContentRenderer is ShellFlyoutTemplatedContentRenderer templatedRenderer)
+ {
+ templatedRenderer.UpdateFlyoutHeader();
+ }
+ }
+
+ public static void MapFlyoutFooter(ShellHandler handler, Shell shell)
+ {
+ // Update the flyout footer when it changes
+ if (handler._flyoutContentRenderer is ShellFlyoutTemplatedContentRenderer templatedRenderer)
+ {
+ templatedRenderer.UpdateFlyoutFooter();
+ }
+ }
+
+ public static void MapFlyoutHeaderBehavior(ShellHandler handler, Shell shell)
+ {
+ // Update the flyout header behavior when it changes
+ if (handler._flyoutContentRenderer is ShellFlyoutTemplatedContentRenderer templatedRenderer)
+ {
+ templatedRenderer.UpdateFlyoutHeaderBehavior();
+ }
+ }
+
+ public static void MapFlyoutVerticalScrollMode(ShellHandler handler, Shell shell)
+ {
+ // Update the flyout vertical scroll mode when it changes
+ if (handler._flyoutContentRenderer is ShellFlyoutTemplatedContentRenderer templatedRenderer)
+ {
+ templatedRenderer.UpdateVerticalScrollMode();
+ }
+ }
+
+ public static void MapFlyout(ShellHandler handler, Shell shell)
+ {
+ // Update the flyout content when it changes
+ if (handler._flyoutContentRenderer is ShellFlyoutTemplatedContentRenderer templatedRenderer)
+ {
+ templatedRenderer.UpdateFlyoutContent();
+ }
+ }
+
+ protected virtual IShellItemRenderer CreateShellItemRenderer(ShellItem shellItem)
+ {
+ // Resolve the handler through the handler service to allow custom ShellItemHandler subclasses
+ // registered via AddHandler() to be used.
+ var handler = MauiContext!.Handlers.GetHandler(shellItem.GetType()) as ShellItemHandler
+ ?? new ShellItemHandler();
+ handler.SetMauiContext(MauiContext!);
+ handler.SetVirtualView(shellItem);
+
+ // Wrap it in an adapter to make it compatible with IShellItemRenderer
+ return new ShellItemHandlerAdapter(handler, MauiContext!);
+ }
+
+ protected virtual IShellFlyoutContentRenderer CreateShellFlyoutContentRenderer()
+ {
+ return new ShellFlyoutTemplatedContentRenderer(this);
+ }
+
+ protected virtual IShellObservableFragment CreateFragmentForPage(Page page)
+ {
+ return new ShellContentFragment(this, page);
+ }
+
+ protected virtual IShellSectionRenderer CreateShellSectionRenderer(ShellSection shellSection)
+ {
+ // Resolve the handler through the handler service to allow custom ShellSectionHandler subclasses
+ // registered via AddHandler() to be used.
+ var handler = MauiContext!.Handlers.GetHandler(shellSection.GetType()) as ShellSectionHandler
+ ?? new ShellSectionHandler();
+
+ handler.SetMauiContext(MauiContext!);
+ handler.SetVirtualView(shellSection);
+
+ return new ShellSectionHandlerAdapter(handler, MauiContext!);
+ }
+
+ protected virtual IShellToolbarTracker CreateTrackerForToolbar(AToolbar toolbar)
+ {
+ return new ShellToolbarTracker(this, toolbar, MauiDrawerLayout);
+ }
+
+ protected virtual IShellToolbarAppearanceTracker CreateToolbarAppearanceTracker()
+ {
+ return new ShellToolbarAppearanceTracker(this);
+ }
+
+ protected virtual IShellTabLayoutAppearanceTracker CreateTabLayoutAppearanceTracker(ShellSection shellSection)
+ {
+ return new ShellTabLayoutAppearanceTracker(this);
+ }
+
+ protected virtual IShellBottomNavViewAppearanceTracker CreateBottomNavViewAppearanceTracker(ShellItem shellItem)
+ {
+ return new ShellBottomNavViewAppearanceTracker(this, shellItem);
+ }
+
+ #region IShellContext Implementation
+
+ Context IShellContext.AndroidContext => Context;
+
+ // Return MauiDrawerLayout (which IS a DrawerLayout)
+ DrawerLayout IShellContext.CurrentDrawerLayout => MauiDrawerLayout;
+
+ Shell IShellContext.Shell => VirtualView;
+
+ IShellObservableFragment IShellContext.CreateFragmentForPage(Page page)
+ {
+ return CreateFragmentForPage(page);
+ }
+
+ IShellFlyoutContentRenderer IShellContext.CreateShellFlyoutContentRenderer()
+ {
+ return CreateShellFlyoutContentRenderer();
+ }
+
+ IShellItemRenderer IShellContext.CreateShellItemRenderer(ShellItem shellItem)
+ {
+ return CreateShellItemRenderer(shellItem);
+ }
+
+ IShellSectionRenderer IShellContext.CreateShellSectionRenderer(ShellSection shellSection)
+ {
+ return CreateShellSectionRenderer(shellSection);
+ }
+
+ IShellToolbarTracker IShellContext.CreateTrackerForToolbar(AToolbar toolbar)
+ {
+ return CreateTrackerForToolbar(toolbar);
+ }
+
+ IShellToolbarAppearanceTracker IShellContext.CreateToolbarAppearanceTracker()
+ {
+ return CreateToolbarAppearanceTracker();
+ }
+
+ IShellTabLayoutAppearanceTracker IShellContext.CreateTabLayoutAppearanceTracker(ShellSection shellSection)
+ {
+ return CreateTabLayoutAppearanceTracker(shellSection);
+ }
+
+ IShellBottomNavViewAppearanceTracker IShellContext.CreateBottomNavViewAppearanceTracker(ShellItem shellItem)
+ {
+ return CreateBottomNavViewAppearanceTracker(shellItem);
+ }
+
+ #endregion
+
+ #region IAppearanceObserver
+
+ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance)
+ {
+ // Match old ShellFlyoutRenderer: null appearance → transparent scrim,
+ // non-null appearance → use FlyoutBackdrop from appearance.
+ if (appearance is null)
+ {
+ _scrimBrush = Brush.Transparent;
+ }
+ else
+ {
+ _scrimBrush = appearance.FlyoutBackdrop;
+ }
+
+ UpdateScrim(_scrimBrush);
+ }
+
+ #endregion
+
+ ///
+ /// Lightweight drawable that renders a Brush as a gradient scrim via Paint + shader.
+ /// Matches ShellFlyoutRenderer's DrawChild + Paint approach: the shader is created
+ /// from the Brush and rendered directly with canvas.DrawRect, supporting alpha control
+ /// via DrawerSlide for the fade-in/out effect.
+ ///
+ sealed class ScrimBrushDrawable : ADrawable
+ {
+ readonly APaint _paint = new APaint { AntiAlias = true };
+ readonly Brush _brush;
+ int _alpha;
+ int _cachedWidth;
+ int _cachedHeight;
+
+ public ScrimBrushDrawable(Brush brush)
+ {
+ _brush = brush;
+ }
+
+ public override void Draw(ACanvas canvas)
+ {
+ var bounds = Bounds;
+ var width = bounds.Width();
+ var height = bounds.Height();
+
+ if (width <= 0 || height <= 0 || _brush is null)
+ return;
+
+ // Recreate shader when bounds change (same as ShellFlyoutRenderer's DrawChild)
+ if (width != _cachedWidth || height != _cachedHeight)
+ {
+ ((GPaint)_brush).ApplyTo(_paint, height, width);
+ _cachedWidth = width;
+ _cachedHeight = height;
+ }
+
+ _paint.Alpha = _alpha;
+ canvas.DrawRect(0, 0, width, height, _paint);
+ }
+
+ public override void SetAlpha(int alpha)
+ {
+ _alpha = alpha;
+ InvalidateSelf();
+ }
+
+ public override int Opacity => (int)AFormat.Translucent;
+
+ public override void SetColorFilter(AColorFilter? colorFilter)
+ {
+ _paint.SetColorFilter(colorFilter);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _paint.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+ }
+ }
+}
diff --git a/src/Controls/src/Core/Handlers/Shell/ShellHandler.Tizen.cs b/src/Controls/src/Core/Handlers/Shell/ShellHandler.Tizen.cs
index 1dd52f2d8977..896d5f59e972 100644
--- a/src/Controls/src/Core/Handlers/Shell/ShellHandler.Tizen.cs
+++ b/src/Controls/src/Core/Handlers/Shell/ShellHandler.Tizen.cs
@@ -84,6 +84,12 @@ public static void MapFlyoutItems(ShellHandler handler, Shell view)
handler.PlatformView.UpdateItems();
}
+ // TODO: No-op stubs — make public when Tizen implements these features.
+ internal static void MapFlowDirection(ShellHandler handler, Shell view) { }
+ internal static void MapFlyoutBackgroundImage(ShellHandler handler, Shell view) { }
+ internal static void MapFlyoutVerticalScrollMode(ShellHandler handler, Shell view) { }
+ internal static void MapFlyoutIcon(ShellHandler handler, Shell view) { }
+
void OnToggled(object? sender, EventArgs e)
{
if (sender is ShellView shellView)
diff --git a/src/Controls/src/Core/Handlers/Shell/ShellHandler.cs b/src/Controls/src/Core/Handlers/Shell/ShellHandler.cs
index d93a1c779f73..d1a86acc1e9b 100644
--- a/src/Controls/src/Core/Handlers/Shell/ShellHandler.cs
+++ b/src/Controls/src/Core/Handlers/Shell/ShellHandler.cs
@@ -1,5 +1,5 @@
#nullable disable
-#if WINDOWS || TIZEN
+#if WINDOWS || TIZEN || ANDROID
using System;
using System.Collections.Generic;
using System.Text;
@@ -14,30 +14,35 @@ public partial class ShellHandler
public static PropertyMapper Mapper =
new PropertyMapper(ElementMapper)
{
- [nameof(IToolbarElement.Toolbar)] = (handler, view) => ViewHandler.MapToolbar(handler, view),
- [nameof(IFlyoutView.Flyout)] = MapFlyout,
- [nameof(IFlyoutView.IsPresented)] = MapIsPresented,
- [nameof(IFlyoutView.FlyoutBehavior)] = MapFlyoutBehavior,
- [nameof(IFlyoutView.FlyoutWidth)] = MapFlyoutWidth,
+ [nameof(Shell.CurrentItem)] = MapCurrentItem,
[nameof(Shell.FlyoutBackground)] = MapFlyoutBackground,
[nameof(Shell.FlyoutBackgroundColor)] = MapFlyoutBackground,
- [nameof(Shell.FlyoutContent)] = MapFlyout,
- [nameof(Shell.CurrentItem)] = MapCurrentItem,
[nameof(Shell.FlyoutBackdrop)] = MapFlyoutBackdrop,
- [nameof(Shell.FlyoutFooter)] = MapFlyoutFooter,
- [nameof(Shell.FlyoutFooterTemplate)] = MapFlyoutFooter,
[nameof(Shell.FlyoutHeader)] = MapFlyoutHeader,
[nameof(Shell.FlyoutHeaderTemplate)] = MapFlyoutHeader,
+ [nameof(Shell.FlyoutFooter)] = MapFlyoutFooter,
+ [nameof(Shell.FlyoutFooterTemplate)] = MapFlyoutFooter,
[nameof(Shell.FlyoutHeaderBehavior)] = MapFlyoutHeaderBehavior,
- [nameof(Shell.Items)] = MapItems,
- [nameof(Shell.FlyoutItems)] = MapFlyoutItems,
-#if WINDOWS
- [nameof(Shell.FlyoutIcon)] = MapFlyoutIcon,
+ [nameof(IFlyoutView.FlyoutBehavior)] = MapFlyoutBehavior,
+ [nameof(IFlyoutView.FlyoutWidth)] = MapFlyoutWidth,
+ [nameof(IFlyoutView.IsPresented)] = MapIsPresented,
+ [nameof(Shell.FlyoutContent)] = MapFlyout,
[nameof(Shell.FlyoutContentTemplate)] = MapFlyout,
[nameof(Shell.FlowDirection)] = MapFlowDirection,
[nameof(Shell.FlyoutBackgroundImage)] = MapFlyoutBackgroundImage,
[nameof(Shell.FlyoutBackgroundImageAspect)] = MapFlyoutBackgroundImage,
[nameof(Shell.FlyoutVerticalScrollMode)] = MapFlyoutVerticalScrollMode,
+
+#if WINDOWS || TIZEN
+ [nameof(IToolbarElement.Toolbar)] = (handler, view) => ViewHandler.MapToolbar(handler, view),
+ [nameof(IFlyoutView.Flyout)] = MapFlyout,
+ [nameof(Shell.Items)] = MapItems,
+ [nameof(Shell.FlyoutItems)] = MapFlyoutItems,
+ [nameof(Shell.FlyoutIcon)] = MapFlyoutIcon,
+#endif
+
+#if ANDROID
+ [nameof(Shell.FlyoutHeight)] = MapFlyoutHeight,
#endif
};
diff --git a/src/Controls/src/Core/Handlers/Shell/ShellItemHandler.Android.cs b/src/Controls/src/Core/Handlers/Shell/ShellItemHandler.Android.cs
new file mode 100644
index 000000000000..5fceff130f22
--- /dev/null
+++ b/src/Controls/src/Core/Handlers/Shell/ShellItemHandler.Android.cs
@@ -0,0 +1,1339 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using Android.OS;
+using Android.Views;
+using AndroidX.CoordinatorLayout.Widget;
+using AndroidX.Fragment.App;
+using AndroidX.ViewPager2.Adapter;
+using AndroidX.ViewPager2.Widget;
+using Google.Android.Material.AppBar;
+using Google.Android.Material.BottomNavigation;
+using Google.Android.Material.BottomSheet;
+using Google.Android.Material.Navigation;
+using Microsoft.Maui.Controls.Platform;
+using Microsoft.Maui.Controls.Platform.Compatibility;
+using AToolbar = AndroidX.AppCompat.Widget.Toolbar;
+using AView = Android.Views.View;
+
+namespace Microsoft.Maui.Controls.Handlers
+{
+ ///
+ /// Handler for ShellItem on Android. Uses ViewPager2 for tab navigation (same as TabbedPageManager).
+ /// Now also manages the shared toolbar for all sections (moved from ShellSectionHandler).
+ ///
+ public partial class ShellItemHandler : ElementHandler, IAppearanceObserver
+ {
+ internal ViewPager2? _viewPager;
+ internal BottomNavigationView? _bottomNavigationView;
+ internal TabbedViewManager? _tabbedViewManager;
+ ShellItemTabbedViewAdapter? _shellItemAdapter;
+ ShellSectionFragmentAdapter? _adapter;
+ ShellItemPageChangeCallback? _pageChangeCallback;
+ IShellContext? _shellContext;
+ Fragment? _parentFragment; // The wrapper fragment that hosts this handler
+ IShellBottomNavViewAppearanceTracker? _appearanceTracker;
+ Shell? _registeredShell; // Cached at AddAppearanceObserver time for reliable RemoveAppearanceObserver
+ ShellSection? _shellSection;
+ Page? _displayedPage;
+ bool _preserveFragmentResources; // During SwitchToShellItem, preserve fragment-level resources
+ bool _switchingShellItem; // During SwitchToShellItem, suppress mapper-triggered SwitchToSection
+ bool _pendingAdapterUpdate; // After adapter rebuild, suppress next smooth scroll to avoid VP2 overshoot
+
+ // Shared toolbar components (moved from ShellSectionHandler)
+ internal Toolbar? _shellToolbar; // Virtual Toolbar view
+ internal AToolbar? _toolbar; // Native platform toolbar
+ internal IShellToolbarTracker? _toolbarTracker;
+ IShellToolbarAppearanceTracker? _toolbarAppearanceTracker;
+ internal AppBarLayout? _appBarLayout;
+
+ ///
+ /// Property mapper for ShellItem properties.
+ ///
+ public static PropertyMapper Mapper = new PropertyMapper(ElementMapper)
+ {
+ [nameof(ShellItem.CurrentItem)] = MapCurrentItem,
+ [Shell.TabBarIsVisibleProperty.PropertyName] = MapTabBarIsVisible,
+ };
+
+ ///
+ /// Command mapper for ShellItem commands.
+ ///
+ public static CommandMapper CommandMapper = new CommandMapper(ElementCommandMapper);
+
+ ///
+ /// Initializes a new instance of the ShellItemHandler class.
+ ///
+ public ShellItemHandler() : base(Mapper, CommandMapper)
+ {
+ }
+
+ ///
+ /// Sets the parent fragment that hosts this handler. Used for child fragment management.
+ ///
+ internal void SetParentFragment(Fragment fragment)
+ {
+ _parentFragment = fragment;
+ }
+
+ ///
+ /// Creates the platform element (ViewPager2) for the ShellItem.
+ /// Note: The actual ViewPager2 used at runtime comes from shellitemlayout.axml
+ /// (inflated in ShellItemWrapperFragment). This method satisfies the ElementHandler
+ /// contract. The fragment assigns the real ViewPager2 to _viewPager.
+ ///
+ protected override ViewPager2 CreatePlatformElement()
+ {
+ var context = MauiContext?.Context ?? throw new InvalidOperationException("MauiContext cannot be null");
+
+ // Create a placeholder ViewPager2 to satisfy the handler contract.
+ // The ShellItemWrapperFragment replaces _viewPager with the one from XML layout.
+ _viewPager = new ViewPager2(context);
+
+ return _viewPager;
+ }
+
+ ///
+ /// Gets the BottomNavigationView for external layout management.
+ /// The parent (ShellHandler or wrapper) should add this to the layout.
+ ///
+ public BottomNavigationView? BottomNavigationView => _bottomNavigationView;
+
+ ///
+ /// Gets the IShellContext from the ShellHandler.
+ ///
+ IShellContext GetShellContext()
+ {
+ var shell = VirtualView?.FindParentOfType();
+ if (shell?.Handler is IShellContext context)
+ return context;
+
+ throw new InvalidOperationException("ShellHandler must implement IShellContext");
+ }
+
+ ///
+ /// Sets up the ViewPager2 adapter for ShellSections.
+ /// Called from OnViewCreated in the wrapper fragment.
+ ///
+ internal void SetupViewPagerAdapter()
+ {
+ if (_viewPager is null || VirtualView is null || _parentFragment is null)
+ {
+ return;
+ }
+
+ var shellSections = ((IShellItemController)VirtualView).GetItems();
+
+ if (shellSections is null || shellSections.Count == 0)
+ {
+ return;
+ }
+
+ _shellContext ??= GetShellContext();
+
+ if (_adapter is not null)
+ {
+ // Reuse existing adapter — update sections for new ShellItem.
+ // GetItemId/ContainsItem ensure old fragments are removed by FragmentStateAdapter.
+ _adapter.UpdateSections(shellSections);
+ }
+ else
+ {
+ _adapter = new ShellSectionFragmentAdapter(
+ _parentFragment.ChildFragmentManager,
+ _parentFragment.Lifecycle,
+ shellSections,
+ _shellContext);
+
+ _viewPager.Adapter = _adapter;
+ }
+
+ // Keep ViewPager2 configuration up to date.
+ // SaveEnabled=false prevents stale fragment state restoration.
+ // UserInputEnabled=false disables swipe (bottom tabs switch via BNV only).
+ ((AView)_viewPager).SaveEnabled = false;
+ _viewPager.UserInputEnabled = false;
+ _viewPager.OffscreenPageLimit = Math.Max(shellSections.Count, 1);
+
+ // Register page change callback for VP2 settle notifications.
+ // When VP2 finishes settling on a page, OnPageSelected re-syncs toolbar
+ // and top tabs. This covers cases where the initial SwitchToSection runs
+ // before section fragments are fully created.
+ if (_pageChangeCallback is null)
+ {
+ _pageChangeCallback = new ShellItemPageChangeCallback(this);
+ _viewPager.RegisterOnPageChangeCallback(_pageChangeCallback);
+ }
+ }
+
+ ///
+ /// Sets up the TabbedViewManager for bottom tab management.
+ /// Creates the ITabbedView adapter, wires callbacks, and sets the element.
+ /// TabbedViewManager creates the BNV and manages tabs internally.
+ ///
+ internal void SetupTabbedViewManager()
+ {
+ if (_viewPager is null || VirtualView is null || MauiContext is null)
+ {
+ return;
+ }
+
+ var shellSections = ((IShellItemController)VirtualView).GetItems();
+
+ if (shellSections is null || shellSections.Count == 0)
+ {
+ return;
+ }
+
+ // Create the adapter that presents ShellItem as ITabbedView
+ _shellItemAdapter = new ShellItemTabbedViewAdapter(VirtualView);
+
+ // Create TabbedViewManager with Shell's ViewPager2 (external VP2 mode).
+ // No OnTabSelected callback needed — TabbedViewManager sets Element.CurrentTab
+ // which calls ProposeSection via ShellItemTabbedViewAdapter, triggering
+ // MapCurrentItem → SwitchToSection for all tracking work.
+ _tabbedViewManager = new TabbedViewManager(MauiContext, _viewPager);
+
+ // Wire reselect callback so consumers can override OnTabReselected
+ // for scroll-to-top or refresh behavior (matching legacy ShellItemRenderer).
+ _tabbedViewManager.OnTabReselected = (position) =>
+ {
+ var sections = ((IShellItemController)VirtualView).GetItems();
+ if (position >= 0 && position < sections.Count)
+ {
+ OnTabReselected(sections[position]);
+ }
+ };
+
+ // Wire "More" bottom sheet creation so consumers can override CreateMoreBottomSheet
+ // to provide a custom overflow UI (matching legacy ShellItemRenderer).
+ _tabbedViewManager.CreateMoreBottomSheet = (selectCallback, items) =>
+ {
+ return CreateMoreBottomSheet(selectCallback, items);
+ };
+
+ // SetElement creates BNV and populates tabs
+ _tabbedViewManager.SetElement(_shellItemAdapter);
+
+ // Get BNV reference for appearance tracker
+ _bottomNavigationView = _tabbedViewManager.BottomNavigationView;
+ }
+
+ ///
+ /// Rebuilds the bottom navigation tabs after items change (e.g., SwitchToShellItem).
+ /// Updates the adapter to reflect the new ShellItem's sections.
+ ///
+ internal void RebuildBottomNavigation()
+ {
+ if (_tabbedViewManager is null || VirtualView is null)
+ {
+ return;
+ }
+
+ // Create new adapter for the new ShellItem
+ _shellItemAdapter = new ShellItemTabbedViewAdapter(VirtualView);
+ _tabbedViewManager.SetElement(_shellItemAdapter);
+
+ // Update BNV reference (SetElement creates a new BNV)
+ _bottomNavigationView = _tabbedViewManager.BottomNavigationView;
+ }
+
+ ///
+ /// Switches to a new ShellSection using ViewPager2.
+ /// The ViewPager2 adapter handles the fragment management.
+ ///
+ internal void SwitchToSection(ShellSection newSection, bool animate)
+ {
+ if (newSection is null || _viewPager is null || VirtualView is null)
+ {
+ return;
+ }
+
+ var items = ((IShellItemController)VirtualView).GetItems();
+
+ if (items is null)
+ {
+ return;
+ }
+
+ var index = items.IndexOf(newSection);
+
+ if (index < 0)
+ {
+ return;
+ }
+
+ // After an adapter rebuild (items added/removed), ViewPager2's smooth scroll
+ // can overshoot the target position ("Passed over target position"). Use instant
+ // jump instead to ensure we land on the correct section.
+ if (_pendingAdapterUpdate)
+ {
+ animate = false;
+ _pendingAdapterUpdate = false;
+ }
+
+ // Switch ViewPager2 to the new section
+ if (_viewPager.CurrentItem != index)
+ {
+ _viewPager.SetCurrentItem(index, animate);
+ }
+
+ // Update top tabs: remove old section's tabs and evaluate new section's
+ NotifyTopTabsForSectionSwitch(_shellSection, newSection);
+
+ // Update bottom navigation — skip during shell item switch to prevent
+ // the old BNV's listener from poisoning CurrentItem via ProposeSection
+ if (!_switchingShellItem)
+ {
+ _tabbedViewManager?.SetSelectedTab(index);
+ }
+
+ // Remove stale observer from old section before registering on the new one.
+ // Without this, the old section's observer stays active and can fire late
+ // (e.g., TabOne with CanNavigateBack=True overriding TabTwo's toolbar state).
+ if (_shellSection is not null && _shellSection != newSection)
+ {
+ ((IShellSectionController)_shellSection).RemoveDisplayedPageObserver(this);
+ }
+
+ // Track the current section
+ _shellSection = newSection;
+
+ // Track displayed page changes
+ ((IShellSectionController)newSection).AddDisplayedPageObserver(this, UpdateDisplayedPage);
+ }
+
+ ///
+ /// Switches to a new ShellItem without replacing the fragment.
+ /// The ViewPager2, BottomNavigationView, and toolbar stay in the layout;
+ /// only the adapter data and bottom nav items are rebuilt.
+ /// Called by ShellHandler for the permanent fragment pattern.
+ ///
+ internal void SwitchToShellItem(ShellItem newItem)
+ {
+ if (newItem is null || VirtualView == newItem)
+ {
+ return;
+ }
+
+ // Set flag to preserve fragment-level resources during disconnect/connect cycle.
+ // SetVirtualView triggers DisconnectHandler → ConnectHandler. Without this flag,
+ // DisconnectHandler would destroy toolbar, adapter, and fragment references.
+ _preserveFragmentResources = true;
+
+ // Suppress MapCurrentItem → SwitchToSection during SetVirtualView.
+ // SetVirtualView remaps all properties including CurrentItem, which triggers
+ // SwitchToSection while the TabbedViewManager still has the OLD ShellItem's adapter.
+ // That causes SetSelectedTab on the old BNV, firing OnNavigationItemSelected which
+ // poisons the old ShellItem's CurrentItem via ProposeSection.
+ _switchingShellItem = true;
+ try
+ {
+ SetVirtualView(newItem);
+ }
+ finally
+ {
+ _switchingShellItem = false;
+ _preserveFragmentResources = false;
+ }
+
+ // Rebuild ViewPager2 adapter for new ShellItem's sections
+ SetupViewPagerAdapter();
+
+ // Rebuild bottom navigation for new ShellItem's sections via TabbedViewManager
+ RebuildBottomNavigation();
+
+ // Apply badges to the rebuilt bottom navigation
+ UpdateAllBadges();
+
+ // Update tab visibility for new ShellItem (may need to show/hide bottom tabs)
+ var showTabs = ((IShellItemController)newItem).ShowTabs;
+
+ if (showTabs)
+ {
+ _tabbedViewManager?.SetTabLayout();
+ }
+ else
+ {
+ _tabbedViewManager?.RemoveTabs();
+ }
+
+ // Re-register appearance observer with new ShellItem
+ RegisterAppearanceObserver();
+
+ // Reset _displayedPage so the final SwitchToSection → AddDisplayedPageObserver →
+ // UpdateDisplayedPage runs fully (not early-returning due to same page reference).
+ // During SetVirtualView above, MapCurrentItem fired and set _displayedPage, but at
+ // that point the appearance observer was removed — so the toolbar update was lost.
+ _displayedPage = null;
+
+ // Switch to the new item's current section
+ if (newItem.CurrentItem is not null)
+ {
+ SwitchToSection(newItem.CurrentItem, animate: false);
+ }
+ }
+
+ #region Navigation Support
+
+ ///
+ /// Called when an already-selected tab is tapped again. Override to implement
+ /// scroll-to-top, refresh, or other custom reselection behavior.
+ /// Matches the legacy ShellItemRenderer.OnTabReselected extensibility point.
+ ///
+ protected virtual void OnTabReselected(ShellSection shellSection)
+ {
+ }
+
+ ///
+ /// Called when the active section changes (e.g., user taps a different bottom tab).
+ /// Override to intercept tab switches, add custom transition logic, or perform
+ /// analytics. Call base to execute the actual section switch.
+ /// Matches the legacy ShellItemRenderer.ChangeSection extensibility point.
+ ///
+ protected virtual void OnSectionChanged(ShellSection shellSection, bool animate)
+ {
+ SwitchToSection(shellSection, animate);
+ }
+
+ ///
+ /// Creates the "More" overflow bottom sheet displayed when there are more than 5 tabs.
+ /// Override to provide a completely custom overflow UI.
+ /// Matches the legacy ShellItemRenderer.CreateMoreBottomSheet extensibility point.
+ ///
+ protected virtual BottomSheetDialog CreateMoreBottomSheet(
+ Action selectCallback,
+ List<(string title, ImageSource? icon, bool tabEnabled)> items)
+ {
+ return BottomNavigationViewUtils.CreateMoreBottomSheet(
+ selectCallback,
+ MauiContext!,
+ items);
+ }
+
+ ///
+ /// Updates a specific bottom tab's title in-place. Called from ShellSectionHandler mapper.
+ ///
+ internal void UpdateBottomTabTitle(ShellSection? section)
+ {
+ if (_tabbedViewManager is null || section is null)
+ {
+ return;
+ }
+
+ var index = ((IShellItemController)VirtualView).GetItems().IndexOf(section);
+ if (index >= 0)
+ {
+ _tabbedViewManager.UpdateTabTitle(index, section.Title);
+ }
+ }
+
+ ///
+ /// Updates a specific bottom tab's icon in-place. Called from ShellSectionHandler mapper.
+ ///
+ internal void UpdateBottomTabIcon(ShellSection? section)
+ {
+ if (_tabbedViewManager is null || section is null)
+ {
+ return;
+ }
+
+ var index = ((IShellItemController)VirtualView).GetItems().IndexOf(section);
+ if (index >= 0)
+ {
+ _tabbedViewManager.UpdateTabIcon(index);
+ }
+ }
+
+ ///
+ /// Updates a specific bottom tab's enabled state in-place. Called from ShellSectionHandler mapper.
+ ///
+ internal void UpdateBottomTabEnabled(ShellSection? section)
+ {
+ if (_tabbedViewManager is null || section is null)
+ {
+ return;
+ }
+
+ var index = ((IShellItemController)VirtualView).GetItems().IndexOf(section);
+ if (index >= 0)
+ {
+ _tabbedViewManager.UpdateTabEnabled(index, section.IsEnabled);
+ }
+ }
+
+ ///
+ /// Updates a specific bottom tab's badge. Called from ShellSectionHandler mapper.
+ ///
+ internal void UpdateBottomTabBadge(ShellSection? section)
+ {
+ if (_bottomNavigationView is null || section is null)
+ {
+ return;
+ }
+
+ var index = ((IShellItemController)VirtualView).GetItems().IndexOf(section);
+ if (index >= 0)
+ {
+ UpdateBadgeForTab(section, index);
+ }
+ }
+
+ ///
+ /// Applies all badges to the bottom navigation after initial setup or rebuild.
+ ///
+ internal void UpdateAllBadges()
+ {
+ if (_bottomNavigationView is null || VirtualView is null)
+ {
+ return;
+ }
+
+ var items = ((IShellItemController)VirtualView).GetItems();
+ var maxItems = _bottomNavigationView.MaxItemCount;
+
+ if (items.Count == 0 || maxItems <= 0)
+ {
+ return;
+ }
+
+ var hasOverflow = items.Count > maxItems;
+ var lastIndexToUpdate = hasOverflow ? maxItems - 2 : Math.Min(items.Count, maxItems) - 1;
+
+ for (int i = 0; i <= lastIndexToUpdate; i++)
+ {
+ UpdateBadgeForTab(items[i], i);
+ }
+
+ if (hasOverflow)
+ {
+ _bottomNavigationView.RemoveBadge(maxItems - 1);
+ }
+ }
+
+ ///
+ /// Applies badge text/color to a single bottom navigation tab.
+ ///
+ void UpdateBadgeForTab(ShellSection section, int index)
+ {
+ if (_bottomNavigationView is null)
+ {
+ return;
+ }
+
+ var badgeText = section.BadgeText;
+
+ if (badgeText is null)
+ {
+ _bottomNavigationView.RemoveBadge(index);
+ return;
+ }
+
+ var badgeColor = section.BadgeColor;
+
+ // Remove and recreate badge when clearing color to reset to platform default
+ if (badgeColor is null)
+ {
+ _bottomNavigationView.RemoveBadge(index);
+ }
+
+ var badge = _bottomNavigationView.GetOrCreateBadge(index);
+ if (badgeText.Length > 0)
+ {
+ badge.Text = badgeText;
+ }
+ else
+ {
+ badge.ClearNumber(); // Empty string shows as dot indicator
+ }
+
+ if (badgeColor is not null)
+ {
+ badge.BackgroundColor = badgeColor.ToPlatform();
+ }
+
+ var badgeTextColor = section.BadgeTextColor;
+ if (badgeTextColor is not null)
+ {
+ badge.BadgeTextColor = badgeTextColor.ToPlatform();
+ }
+ }
+
+ ///
+ /// Helper method to count non-null pages and get the top page from a stack.
+ /// Returns (topPage, canNavigateBack).
+ ///
+ static (Page? topPage, bool canNavigateBack) GetStackInfo(IReadOnlyList stack)
+ {
+ if (stack is null || stack.Count == 0)
+ {
+ return (null, false);
+ }
+
+ Page? topPage = null;
+ int nonNullCount = 0;
+
+ // Single pass: find top page and count non-null pages
+ for (int i = stack.Count - 1; i >= 0; i--)
+ {
+ var page = stack[i];
+
+ if (page is not null)
+ {
+ nonNullCount++;
+ topPage ??= page; // First non-null from top is the current page
+ }
+ }
+
+ return (topPage, nonNullCount > 1);
+ }
+
+ ///
+ /// Updates the displayed page reference AND updates the toolbar.
+ /// This is the key callback from Shell that ensures the toolbar reflects the currently displayed page.
+ ///
+ void UpdateDisplayedPage(Page page)
+ {
+ if (page is null || _displayedPage == page || ((ElementHandler)this).VirtualView is null)
+ {
+ return;
+ }
+
+ _displayedPage = page;
+
+ // Get navigation state from the section's stack
+ var section = page.FindParentOfType();
+ var (_, canNavigateBack) = section is not null ? GetStackInfo(section.Stack) : (null, false);
+
+ // Update toolbar with page and navigation state
+ UpdateToolbar(page, canNavigateBack);
+
+ // Re-evaluate tab bar visibility for the new page
+ UpdateTabBarVisibility();
+ }
+
+ void UpdateTabBarVisibility()
+ {
+ if (_tabbedViewManager is null || _displayedPage is null || ((ElementHandler)this).VirtualView is null)
+ {
+ return;
+ }
+
+ var showTabs = ((IShellItemController)VirtualView).ShowTabs;
+
+ if (showTabs)
+ {
+ _tabbedViewManager.SetTabLayout();
+ }
+ else
+ {
+ _tabbedViewManager.RemoveTabs();
+ }
+ }
+
+ static void MapTabBarIsVisible(ShellItemHandler handler, ShellItem item)
+ {
+ handler.UpdateTabBarVisibility();
+ }
+
+ ///
+ /// Called when ViewPager2 settles on a page (section).
+ /// Re-applies toolbar properties (title, back button behavior, custom icons)
+ /// after VP2 settles — critical for BackButtonBehavior.IconOverride.
+ ///
+ internal void OnPageSelected(int position)
+ {
+ if (VirtualView is null || _viewPager is null)
+ {
+ return;
+ }
+
+ var items = ((IShellItemController)VirtualView).GetItems();
+ if (items is null || position >= items.Count)
+ {
+ return;
+ }
+
+ var newSection = items[position];
+ UpdateToolbarForSection(newSection);
+ }
+
+ ///
+ /// Manages top tab transitions when switching between sections (bottom tabs).
+ /// navigationlayout_toptabs is a GLOBAL slot — only one section's TabLayout can
+ /// be there at a time. The outgoing section must remove its tabs before the
+ /// incoming section can place its own.
+ ///
+ void NotifyTopTabsForSectionSwitch(ShellSection? oldSection, ShellSection? newSection)
+ {
+ // Remove outgoing section's top tabs from the NRM slot
+ if (oldSection?.Handler is ShellSectionHandler oldHandler)
+ {
+ oldHandler.RemoveTopTabs();
+ }
+
+ // Evaluate incoming section's top tabs (will place if > 1 content)
+ if (newSection?.Handler is ShellSectionHandler newHandler)
+ {
+ var visibleCount = ((IShellSectionController)newSection).GetItems().Count;
+
+ if (visibleCount > 1)
+ {
+ newHandler.PlaceTopTabs();
+ }
+ }
+ }
+
+ ///
+ /// Handles the back button press by delegating to Shell's full navigation pipeline.
+ /// Shell.SendBackButtonPressed() handles BackButtonBehavior commands, page overrides,
+ /// navigation stack pops, modal dismissal, and ShellNavigatingEventArgs cancellation.
+ /// Returns true if Shell handled it, false if system should handle (app exit).
+ ///
+ internal bool OnBackButtonPressed()
+ {
+ var shell = VirtualView?.FindParentOfType();
+ return shell?.SendBackButtonPressed() ?? false;
+ }
+
+ #endregion Navigation Support
+
+ ///
+ /// Maps the CurrentItem property to switch the displayed section.
+ ///
+ public static void MapCurrentItem(ShellItemHandler handler, ShellItem shellItem)
+ {
+ if (handler is null || shellItem is null)
+ {
+ return;
+ }
+
+ handler.OnSectionChanged(shellItem.CurrentItem, animate: true);
+ }
+
+ ///
+ /// Connects the handler to the platform view.
+ ///
+ protected override void ConnectHandler(ViewPager2 platformView)
+ {
+ base.ConnectHandler(platformView);
+
+ // Subscribe to ShellItem property changes to detect CurrentItem changes from navigation
+ if (VirtualView is not null)
+ {
+ VirtualView.PropertyChanged += OnShellItemPropertyChanged;
+ ((IShellItemController)VirtualView).ItemsCollectionChanged += OnShellItemsChanged;
+ }
+
+ // Initialize shell context and appearance tracker early
+ _shellContext ??= GetShellContext();
+ _appearanceTracker = _shellContext.CreateBottomNavViewAppearanceTracker(VirtualView);
+
+ // NOTE: Appearance observer registration is deferred to RegisterAppearanceObserver()
+ // called from OnViewCreated in the wrapper fragment. At ConnectHandler time,
+ // the BottomNavigationView and Toolbar from the XML layout are not yet inflated.
+ // Registering here causes the initial OnAppearanceChanged callback to be lost
+ // because the views aren't ready. The old ShellItemRenderer registered in
+ // OnCreateView() AFTER creating all views — we match that pattern.
+ }
+
+ void OnShellItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == ShellItem.CurrentItemProperty.PropertyName)
+ {
+ // Update bottom navigation selection
+ UpdateBottomNavigationSelection();
+ }
+ }
+
+ void OnShellItemsChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
+ {
+ if (VirtualView is null)
+ {
+ return;
+ }
+
+ var shellItem = VirtualView;
+
+ var shellSections = ((IShellItemController)shellItem).GetItems();
+
+ // The adapter's _sections reference may be a live ReadOnlyCollection,
+ // but when items change visibility, positions shift and the position-based
+ // renderer cache becomes stale. Use UpdateSections() to clear the cache,
+ // remove stale IDs, and notify the adapter properly.
+ // Update OffscreenPageLimit BEFORE the adapter notification to prevent
+ // ViewPager2 from trying to bind positions beyond the new item count.
+ if (_adapter is not null && _viewPager is not null)
+ {
+ shellSections = ((IShellItemController)shellItem).GetItems();
+ _viewPager.OffscreenPageLimit = Math.Max(shellSections.Count, 1);
+ _adapter.UpdateSections(shellSections);
+ }
+ else if (_adapter is null && _viewPager is not null)
+ {
+ // 0→N transition: adapter/manager were not created during initial setup
+ // because there were no sections. Now that sections exist, create them.
+ SetupViewPagerAdapter();
+ SetupTabbedViewManager();
+ }
+
+ // Rebuild the bottom navigation menu for the updated sections via TabbedViewManager
+ _tabbedViewManager?.RefreshTabs();
+ UpdateTabBarVisibility();
+
+ // Signal that the adapter was just rebuilt. The next SwitchToSection call
+ // (from a pending GoToAsync) should skip smooth scroll to avoid VP2 overshoot.
+ _pendingAdapterUpdate = true;
+ }
+
+ void UpdateBottomNavigationSelection()
+ {
+ if (_tabbedViewManager is null || VirtualView is null)
+ {
+ return;
+ }
+
+ var items = ((IShellItemController)VirtualView).GetItems();
+
+ if (items is null)
+ {
+ return;
+ }
+
+ var currentIndex = items.IndexOf(VirtualView.CurrentItem);
+ if (currentIndex >= 0)
+ {
+ _tabbedViewManager.SetSelectedTab(currentIndex);
+ }
+ }
+
+ ///
+ /// Disconnects the handler from the platform view.
+ /// Comprehensive cleanup of resources
+ ///
+ protected override void DisconnectHandler(ViewPager2 platformView)
+ {
+ if (VirtualView is not null)
+ {
+ VirtualView.PropertyChanged -= OnShellItemPropertyChanged;
+ ((IShellItemController)VirtualView).ItemsCollectionChanged -= OnShellItemsChanged;
+ }
+
+ if (_shellSection is not null)
+ {
+ ((IShellSectionController)_shellSection).RemoveDisplayedPageObserver(this);
+ _shellSection = null;
+ }
+
+ _displayedPage = null;
+
+ // Unregister appearance observer (use cached Shell reference for reliable removal
+ // even when VirtualView has already been detached from Shell.Items)
+ if (_registeredShell is not null)
+ {
+ ((IShellController)_registeredShell).RemoveAppearanceObserver(this);
+ _registeredShell = null;
+ }
+
+ // Dispose per-item appearance tracker (ConnectHandler recreates for new item)
+ _appearanceTracker?.Dispose();
+ _appearanceTracker = null;
+
+ // Unregister page change callback
+ if (_pageChangeCallback is not null && _viewPager is not null)
+ {
+ _viewPager.UnregisterOnPageChangeCallback(_pageChangeCallback);
+ _pageChangeCallback = null;
+ }
+
+ if (!_preserveFragmentResources)
+ {
+ // Full disconnect: fragment is being destroyed — clean everything
+ if (_tabbedViewManager is not null)
+ {
+ _tabbedViewManager.RemoveTabs();
+ _tabbedViewManager.SetElement(null);
+ _tabbedViewManager = null;
+ }
+ _shellItemAdapter = null;
+
+ _toolbarAppearanceTracker?.Dispose();
+ _toolbarAppearanceTracker = null;
+
+ _toolbarTracker?.Dispose();
+ _toolbarTracker = null;
+
+ // Remove toolbar from outer AppBarLayout before nulling references
+ if (_toolbar?.Parent is ViewGroup toolbarParent)
+ toolbarParent.RemoveView(_toolbar);
+
+ _toolbar = null;
+ _shellToolbar = null;
+ _appBarLayout = null;
+
+ platformView.Adapter = null;
+ _adapter = null;
+
+ _shellContext = null;
+ _parentFragment = null;
+ }
+
+ base.DisconnectHandler(platformView);
+ }
+
+ #region IAppearanceObserver
+
+ ///
+ /// Called when Shell appearance changes (colors, styles, etc.)
+ /// Shell sends null appearance when the displayed page has no custom Shell colors,
+ /// signaling that appearance should reset to defaults.
+ ///
+ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance)
+ {
+ if (appearance is not null)
+ {
+ UpdateAppearance(appearance);
+ UpdateToolbarAppearance(appearance);
+ }
+ else
+ {
+ ResetAppearance();
+ ResetToolbarAppearance();
+ }
+ }
+
+ ///
+ /// Updates the bottom navigation view appearance based on Shell appearance.
+ ///
+ void UpdateAppearance(ShellAppearance appearance)
+ {
+ if (_bottomNavigationView is null || _bottomNavigationView.Visibility != ViewStates.Visible)
+ {
+ return;
+ }
+
+ _appearanceTracker?.SetAppearance(_bottomNavigationView, appearance);
+ }
+
+ ///
+ /// Resets the bottom navigation view appearance to defaults.
+ /// Called when Shell sends null appearance (page has no custom Shell colors).
+ ///
+ void ResetAppearance()
+ {
+ if (_bottomNavigationView is not null && _appearanceTracker is not null)
+ {
+ _appearanceTracker.ResetAppearance(_bottomNavigationView);
+ }
+ }
+
+ ///
+ /// Resets the toolbar appearance to defaults.
+ /// Called when Shell sends null appearance (page has no custom Shell colors).
+ ///
+ void ResetToolbarAppearance()
+ {
+ if (_toolbarAppearanceTracker is not null && _toolbar is not null && _toolbarTracker is not null)
+ {
+ _toolbarAppearanceTracker.ResetAppearance(_toolbar, _toolbarTracker);
+ }
+ }
+
+ #endregion IAppearanceObserver
+
+ ///
+ /// Registers as an appearance observer with the Shell. Must be called AFTER
+ /// BottomNavigationView and Toolbar are created (from OnViewCreated), so the
+ /// initial OnAppearanceChanged callback can apply appearance to the ready views.
+ /// Matches the old ShellItemRenderer pattern of registering in OnCreateView.
+ ///
+ internal void RegisterAppearanceObserver()
+ {
+ var shell = VirtualView?.FindParentOfType();
+ if (shell is not null)
+ {
+ _registeredShell = shell;
+ ((IShellController)shell).AddAppearanceObserver(this, VirtualView);
+ }
+ }
+
+ #region Toolbar Management
+
+ ///
+ /// Sets up the shared toolbar at the ShellItem level.
+ /// The toolbar is now managed here instead of at the ShellSection level,
+ /// so it persists across section switches within the same ShellItem.
+ ///
+ internal void SetupToolbar()
+ {
+ var shell = VirtualView?.FindParentOfType();
+
+ if (shell is null)
+ {
+ return;
+ }
+
+ // Find the outer AppBarLayout from navigationlayout.axml.
+ // The toolbar is placed at the NRM level (same as ViewHandler.MapToolbar),
+ // making it persistent across ShellItem and ShellSection changes.
+ _appBarLayout = FindNavigationLayoutAppBar();
+ var mauiContext = shell.Handler?.MauiContext;
+
+ if (_appBarLayout is null || mauiContext is null)
+ {
+ return;
+ }
+
+ _shellContext ??= GetShellContext();
+
+ // Create Toolbar virtual view with proper context using current item
+ _shellToolbar = new Toolbar(VirtualView?.CurrentItem);
+
+ // Apply toolbar changes from Shell
+ ShellToolbarTracker.ApplyToolbarChanges(shell.Toolbar, _shellToolbar);
+
+ // Create the platform toolbar
+ _toolbar = (AToolbar)_shellToolbar.ToPlatform(mauiContext);
+
+ // Add toolbar to outer AppBarLayout at position 0 (before navigationlayout_toptabs).
+ // Same pattern as ViewHandler.MapToolbar: appbarLayout.AddView(nativeToolBar, 0).
+ // The tracker constructor resolves _appBar via _platformToolbar.Parent.GetParentOfType(),
+ // so the toolbar must already be in the view hierarchy.
+ _appBarLayout.AddView(_toolbar, 0);
+
+ // Set up toolbar tracker and appearance tracker
+ _toolbarTracker = _shellContext.CreateTrackerForToolbar(_toolbar);
+ _toolbarAppearanceTracker = _shellContext.CreateToolbarAppearanceTracker();
+
+ // Set the toolbar reference
+ if (_toolbarTracker is not null && _shellToolbar is not null)
+ {
+ _toolbarTracker.SetToolbar(_shellToolbar);
+ }
+ }
+
+ ///
+ /// Finds the outer AppBarLayout (navigationlayout_appbar) from navigationlayout.axml.
+ /// Navigates from Shell's PlatformView (MauiDrawerLayout) down to the inflated layout.
+ ///
+ AppBarLayout? FindNavigationLayoutAppBar()
+ {
+ var shell = VirtualView?.FindParentOfType();
+ var rootView = shell?.Handler?.PlatformView as AView;
+ return rootView?.FindViewById(Resource.Id.navigationlayout_appbar);
+ }
+
+ ///
+ /// Updates the toolbar title and items for the current section.
+ /// Called when the current section changes (e.g., switching between bottom nav tabs).
+ ///
+ void UpdateToolbarForSection(ShellSection section)
+ {
+ if (_toolbarTracker is null || section is null)
+ {
+ return;
+ }
+
+ Page? currentPage = null;
+ bool canNavigateBack = false;
+
+ // Check if _displayedPage belongs to this section (fast path)
+ if (_displayedPage is not null)
+ {
+ var pageSection = _displayedPage.FindParentOfType();
+ if (pageSection == section)
+ {
+ currentPage = _displayedPage;
+ // Still need to calculate canNavigateBack from stack
+ (_, canNavigateBack) = GetStackInfo(section.Stack);
+ }
+ }
+
+ // If not found via _displayedPage, get from section's stack
+ if (currentPage is null)
+ {
+ (currentPage, canNavigateBack) = GetStackInfo(section.Stack);
+ }
+
+ // If still not found, use the root content page
+ if (currentPage is null && section.CurrentItem is not null)
+ {
+ currentPage = ((IShellContentController)section.CurrentItem).GetOrCreateContent();
+ canNavigateBack = false; // Root page
+ }
+
+ // Update toolbar with page and navigation state
+ if (currentPage is not null)
+ {
+ UpdateToolbar(currentPage, canNavigateBack);
+ }
+ }
+
+ ///
+ /// Consolidated toolbar update method. Single point of entry for all toolbar updates.
+ ///
+ void UpdateToolbar(Page page, bool canNavigateBack)
+ {
+ if (_toolbarTracker is null || page is null)
+ {
+ return;
+ }
+
+ // Update navigation state first
+ _toolbarTracker.CanNavigateBack = canNavigateBack;
+
+ // Update the page reference
+ _toolbarTracker.Page = page;
+
+ // Cache shell reference to avoid repeated FindParentOfType calls
+ var shell = VirtualView?.FindParentOfType();
+
+ if (shell is null)
+ {
+ return;
+ }
+
+ // Apply toolbar configuration
+ if (_shellToolbar is not null)
+ {
+ ShellToolbarTracker.ApplyToolbarChanges(shell.Toolbar, _shellToolbar);
+ _toolbarTracker.SetToolbar(_shellToolbar);
+ }
+
+ // Update shell toolbar
+ if (shell.Toolbar is ShellToolbar shellToolbar)
+ {
+ shellToolbar.ApplyChanges();
+ }
+
+ // Trigger appearance update (observers handle the rest)
+ ((IShellController)shell).AppearanceChanged(page, false);
+ }
+
+ ///
+ /// Updates the toolbar for a specific page.
+ /// Called when content tabs change within a multi-content ShellSection.
+ ///
+ internal void UpdateToolbarForPage(Page page)
+ {
+ if (page is null)
+ {
+ return;
+ }
+
+ // Get navigation state from the page's section
+ var section = page.FindParentOfType();
+ var (_, canNavigateBack) = section is not null ? GetStackInfo(section.Stack) : (null, false);
+
+ UpdateToolbar(page, canNavigateBack);
+ }
+
+ ///
+ /// Updates the toolbar appearance based on Shell appearance.
+ /// Called via IAppearanceObserver.OnAppearanceChanged - the single source of truth for appearance.
+ ///
+ void UpdateToolbarAppearance(ShellAppearance appearance)
+ {
+ if (_toolbarAppearanceTracker is null || _toolbar is null || appearance is null)
+ {
+ return;
+ }
+
+ _toolbarAppearanceTracker.SetAppearance(_toolbar, _toolbarTracker, appearance);
+ }
+
+ #endregion Toolbar Management
+ }
+
+ ///
+ /// Adapter that bridges ShellItemHandler with IShellItemRenderer interface.
+ /// This allows the new handler architecture to work with existing Shell infrastructure.
+ ///
+ internal class ShellItemHandlerAdapter : IShellItemRenderer
+ {
+ readonly ShellItemHandler _handler;
+ ShellItemWrapperFragment? _wrapperFragment;
+
+ public ShellItemHandlerAdapter(ShellItemHandler handler, IMauiContext mauiContext)
+ {
+ _handler = handler ?? throw new ArgumentNullException(nameof(handler));
+ }
+
+ internal ShellItemHandler GetHandler() => _handler;
+
+ public Fragment Fragment
+ {
+ get
+ {
+ // Lazily create the wrapper fragment when needed
+ if (_wrapperFragment is null)
+ {
+ _wrapperFragment = new ShellItemWrapperFragment(_handler);
+ }
+ return _wrapperFragment;
+ }
+ }
+
+ public ShellItem ShellItem
+ {
+ get => _handler.VirtualView;
+ set
+ {
+ if (_handler.VirtualView != value)
+ {
+ _handler.SetVirtualView(value);
+ }
+ }
+ }
+
+ public event EventHandler? Destroyed;
+
+ public void Dispose()
+ {
+ Destroyed?.Invoke(this, EventArgs.Empty);
+
+ // Disconnect the handler to unsubscribe from events and clean up resources.
+ ((IElementHandler)_handler).DisconnectHandler();
+
+ _wrapperFragment?.Dispose();
+ _wrapperFragment = null;
+ }
+ }
+
+ ///
+ /// ViewPager2 page change callback for ShellItem's section ViewPager2.
+ /// Notifies the handler when VP2 settles on a section page.
+ ///
+ internal class ShellItemPageChangeCallback : ViewPager2.OnPageChangeCallback
+ {
+ readonly ShellItemHandler _handler;
+
+ public ShellItemPageChangeCallback(ShellItemHandler handler)
+ {
+ _handler = handler ?? throw new ArgumentNullException(nameof(handler));
+ }
+
+ public override void OnPageSelected(int position)
+ {
+ base.OnPageSelected(position);
+ _handler.OnPageSelected(position);
+ }
+ }
+
+ ///
+ /// FragmentStateAdapter for ShellSections in ViewPager2.
+ /// Each page hosts a ShellSection's fragment.
+ ///
+ internal class ShellSectionFragmentAdapter : FragmentStateAdapter
+ {
+ IList _sections;
+ readonly IShellContext _shellContext;
+ readonly Dictionary _renderers = new Dictionary();
+ long _itemIdCounter;
+ readonly Dictionary _sectionIds = new Dictionary();
+
+ public ShellSectionFragmentAdapter(
+ FragmentManager fragmentManager,
+ AndroidX.Lifecycle.Lifecycle lifecycle,
+ IList sections,
+ IShellContext shellContext)
+ : base(fragmentManager, lifecycle)
+ {
+ _sections = sections ?? throw new ArgumentNullException(nameof(sections));
+ _shellContext = shellContext ?? throw new ArgumentNullException(nameof(shellContext));
+ }
+
+ public override int ItemCount => _sections.Count;
+
+ ///
+ /// Returns a stable ID for each ShellSection. FragmentStateAdapter uses this
+ /// to detect when sections change (e.g., on ShellItem switch) and automatically
+ /// removes fragments whose IDs are no longer present.
+ ///
+ public override long GetItemId(int position)
+ {
+ var section = _sections[position];
+ if (!_sectionIds.TryGetValue(section, out var id))
+ {
+ id = ++_itemIdCounter;
+ _sectionIds[section] = id;
+ }
+ return id;
+ }
+
+ public override bool ContainsItem(long itemId)
+ {
+ foreach (var kvp in _sectionIds)
+ {
+ if (kvp.Value == itemId && _sections.Contains(kvp.Key))
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Updates the adapter's sections for a new ShellItem.
+ /// FragmentStateAdapter uses GetItemId/ContainsItem to detect that old sections
+ /// are gone and removes their fragments automatically.
+ ///
+ internal void UpdateSections(IList newSections)
+ {
+ // Remove IDs for sections that are no longer present
+ var toRemove = new List();
+ foreach (var kvp in _sectionIds)
+ {
+ if (!newSections.Contains(kvp.Key))
+ toRemove.Add(kvp.Key);
+ }
+ foreach (var section in toRemove)
+ _sectionIds.Remove(section);
+
+ // Only dispose renderers for sections that are being removed.
+ // Surviving sections keep their renderers tracked — FragmentStateAdapter
+ // reuses their fragments (stable IDs) and never re-invokes CreateFragment.
+ foreach (var section in toRemove)
+ {
+ if (_renderers.TryGetValue(section, out var renderer))
+ {
+ renderer.Dispose();
+ _renderers.Remove(section);
+ }
+ }
+
+ _sections = newSections;
+ NotifyDataSetChanged();
+ }
+
+ public override Fragment CreateFragment(int position)
+ {
+ var section = _sections[position];
+
+ // Create or reuse section renderer (keyed by ShellSection for stable tracking)
+ if (!_renderers.TryGetValue(section, out var renderer))
+ {
+ renderer = _shellContext.CreateShellSectionRenderer(section);
+ renderer.ShellSection = section;
+ _renderers[section] = renderer;
+ }
+
+ // Get the fragment from the renderer
+ if (renderer is IShellObservableFragment observableFragment)
+ {
+ return observableFragment.Fragment;
+ }
+
+ throw new InvalidOperationException($"ShellSectionHandler for {section.Title} is not an IShellObservableFragment");
+ }
+
+ public IShellSectionRenderer? GetRenderer(int position)
+ {
+ if (position < 0 || position >= _sections.Count)
+ {
+ return null;
+ }
+
+ _renderers.TryGetValue(_sections[position], out var renderer);
+ return renderer;
+ }
+ }
+}
diff --git a/src/Controls/src/Core/Handlers/Shell/ShellItemWrapperFragment.Android.cs b/src/Controls/src/Core/Handlers/Shell/ShellItemWrapperFragment.Android.cs
new file mode 100644
index 000000000000..a99bec7cea32
--- /dev/null
+++ b/src/Controls/src/Core/Handlers/Shell/ShellItemWrapperFragment.Android.cs
@@ -0,0 +1,171 @@
+#nullable enable
+using System;
+using Android.OS;
+using Android.Views;
+using AndroidX.CoordinatorLayout.Widget;
+using AndroidX.Fragment.App;
+using AndroidX.ViewPager2.Widget;
+using Microsoft.Maui.Controls.Platform;
+using AView = Android.Views.View;
+
+namespace Microsoft.Maui.Controls.Handlers
+{
+ ///
+ /// Wrapper Fragment that hosts the ShellItemHandler's layout.
+ /// Inflates shellitemlayout.axml consistent with NavigationViewHandler and FlyoutViewHandler patterns.
+ /// The toolbar is managed at the ShellItem level (shared across all sections).
+ ///
+ public class ShellItemWrapperFragment : Fragment
+ {
+ readonly ShellItemHandler? _handler;
+ CoordinatorLayout? _rootLayout;
+ ShellBackPressedCallback? _backPressedCallback;
+
+ // Default constructor required by Android's FragmentManager for fragment restoration.
+ // Without this, FragmentManager.instantiate() crashes on process-death restoration.
+ public ShellItemWrapperFragment()
+ {
+ _handler = null;
+ }
+
+ public ShellItemWrapperFragment(ShellItemHandler handler)
+ {
+ _handler = handler;
+ // Let the handler know about its parent fragment for child fragment management
+ _handler.SetParentFragment(this);
+ }
+
+ public override AView OnCreateView(LayoutInflater inflater, ViewGroup? container, Bundle? savedInstanceState)
+ {
+ // If restored without proper handler reference, return empty view.
+ // The Shell infrastructure will recreate proper fragments after reconnecting.
+ if (_handler is null)
+ {
+ return new global::Android.Widget.FrameLayout(inflater.Context!);
+ }
+
+ // Inflate from XML layout — consistent with NavigationViewHandler/FlyoutViewHandler pattern
+ var rootView = inflater.Inflate(Resource.Layout.shellitemlayout, container, false)
+ ?? throw new InvalidOperationException("shellitemlayout inflation failed");
+
+ // Get references from inflated layout
+ _rootLayout = rootView.FindViewById(Resource.Id.shellitem_coordinator)
+ ?? throw new InvalidOperationException("shellitem_coordinator not found");
+ // NOTE: _appBarLayout is the outer navigationlayout_appbar from
+ // navigationlayout.axml, resolved lazily in SetupToolbar().
+
+ // Get ViewPager2 from the inflated layout
+ _handler._viewPager = rootView.FindViewById(Resource.Id.shellitem_viewpager);
+
+ // BNV is created by TabbedViewManager in SetupTabbedViewManager().
+ // It is placed into navigationlayout_bottomtabs via TabbedViewManager.SetTabLayout().
+
+ // Setup window insets for safe area handling
+ MauiWindowInsetListener.SetupViewWithLocalListener(_rootLayout);
+
+ return rootView;
+ }
+
+ public override void OnViewCreated(AView view, Bundle? savedInstanceState)
+ {
+ base.OnViewCreated(view, savedInstanceState);
+
+ // Skip setup if restored without handler (parameterless constructor path)
+ if (_handler is null)
+ {
+ return;
+ }
+
+ // Setup back button handling
+ _backPressedCallback = new ShellBackPressedCallback(_handler, this);
+ RequireActivity().OnBackPressedDispatcher.AddCallback(ViewLifecycleOwner, _backPressedCallback);
+
+ // Setup the shared toolbar
+ _handler.SetupToolbar();
+
+ // Setup TabbedViewManager for bottom tab management
+ // (creates BNV and populates tabs)
+ _handler.SetupTabbedViewManager();
+
+ // Place bottom tabs into navigationlayout_bottomtabs via TabbedViewManager
+ _handler._tabbedViewManager?.SetTabLayout();
+
+ // Apply initial badges to bottom navigation tabs
+ _handler.UpdateAllBadges();
+
+ // Now that the fragment is attached, setup the ViewPager2 adapter
+ _handler.SetupViewPagerAdapter();
+
+ // Register as appearance observer NOW that all views are ready.
+ // This must happen after SetupToolbar and SetupTabbedViewManager so that
+ // when Shell calls OnAppearanceChanged, the views can receive appearance updates.
+ _handler.RegisterAppearanceObserver();
+
+ // Trigger the initial section switch if needed
+ if (_handler.VirtualView?.CurrentItem is not null)
+ {
+ _handler.SwitchToSection(_handler.VirtualView.CurrentItem, animate: false);
+ }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ if (_backPressedCallback is not null)
+ {
+ _backPressedCallback.Remove();
+ _backPressedCallback.Dispose();
+ _backPressedCallback = null;
+ }
+
+ // Remove window insets listener
+ if (_rootLayout is not null)
+ {
+ MauiWindowInsetListener.RemoveViewWithLocalListener(_rootLayout);
+ }
+
+ _rootLayout = null;
+ }
+ base.Dispose(disposing);
+ }
+
+ ///
+ /// Custom OnBackPressedCallback for Shell navigation
+ ///
+ sealed class ShellBackPressedCallback : AndroidX.Activity.OnBackPressedCallback
+ {
+ readonly ShellItemHandler _handler;
+ readonly Fragment _fragment;
+
+ public ShellBackPressedCallback(ShellItemHandler handler, Fragment fragment) : base(true)
+ {
+ _handler = handler;
+ _fragment = fragment;
+ }
+
+ public override void HandleOnBackPressed()
+ {
+ // Route through Shell's full back navigation pipeline.
+ // Shell.SendBackButtonPressed() handles:
+ // - BackButtonBehavior.Command execution
+ // - Page.OnBackButtonPressed() overrides
+ // - Navigation stack pops (via Shell.OnBackButtonPressed)
+ // - Modal stack dismissal
+ // - ShellNavigatingEventArgs cancellation
+ // This matches the old renderer behavior where the lifecycle chain
+ // (Activity → Window → Shell) naturally invoked the full pipeline.
+ if (!_handler.OnBackButtonPressed())
+ {
+ // Shell didn't handle it (at root, no stack to pop, not cancelled).
+ // Forward to system by temporarily disabling this callback so the
+ // dispatcher falls through to the next handler in the chain
+ // (e.g., the Activity's default that finishes the app).
+ this.Enabled = false;
+ _fragment.RequireActivity().OnBackPressedDispatcher.OnBackPressed();
+ this.Enabled = true;
+ }
+ }
+ }
+ }
+}
diff --git a/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Android.cs b/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Android.cs
new file mode 100644
index 000000000000..c7040a68f3e5
--- /dev/null
+++ b/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Android.cs
@@ -0,0 +1,1014 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Linq;
+using Android.Content;
+using Android.OS;
+using Android.Views;
+using Android.Views.Animations;
+using Android.Widget;
+using AndroidX.Fragment.App;
+using AndroidX.ViewPager2.Adapter;
+using AndroidX.ViewPager2.Widget;
+using Google.Android.Material.Tabs;
+using Microsoft.Maui.Controls.Internals;
+using Microsoft.Maui.Controls.Platform.Compatibility;
+using Microsoft.Maui.Graphics;
+using AAnimation = Android.Views.Animations.Animation;
+using AView = Android.Views.View;
+using LP = Android.Views.ViewGroup.LayoutParams;
+
+namespace Microsoft.Maui.Controls.Handlers
+{
+ ///
+ /// UNIFIED Handler for ShellSection on Android.
+ /// Always uses ViewPager2 for content switching - works for both single and multiple ShellContents.
+ /// TabLayout visibility is controlled by item count (hidden if 1 item).
+ /// Each ShellContent gets its own StackNavigationManager for independent navigation.
+ ///
+ public partial class ShellSectionHandler : ElementHandler, IAppearanceObserver
+ {
+ Fragment? _parentFragment; // The wrapper fragment that hosts this handler
+ IShellContext? _shellContext;
+ IShellSectionController SectionController => (IShellSectionController)VirtualView;
+ IShellTabLayoutAppearanceTracker? _tabLayoutAppearanceTracker;
+ Shell? _registeredShell; // Cached at AddAppearanceObserver time for reliable removal
+ LinearLayout? _rootLayout;
+ ViewPager2? _viewPager;
+ TabLayout? _contentTabLayout;
+ ShellContentFragmentAdapter? _adapter;
+ TabbedViewManager? _tabbedViewManager;
+ ShellSectionTabbedViewAdapter? _shellSectionAdapter;
+ ViewPagerPageChangeCallback? _pageChangedCallback;
+
+ ///
+ /// Internal accessor for the ViewPager2 instance. Used by ViewPagerPageChangeCallback
+ /// to revert the pager position when navigation is cancelled (PlatformView is the
+ /// LinearLayout root, not the ViewPager2).
+ ///
+ internal ViewPager2? ViewPager => _viewPager;
+
+ ///
+ /// Gets the toolbar tracker from the parent ShellItemHandler.
+ /// The toolbar is managed at ShellItem level.
+ ///
+ internal IShellToolbarTracker? ToolbarTracker
+ {
+ get
+ {
+ var shellItem = VirtualView?.FindParentOfType();
+ if (shellItem?.Handler is ShellItemHandler itemHandler)
+ {
+ return itemHandler._toolbarTracker;
+ }
+ return null;
+ }
+ }
+
+ ///
+ /// Gets the virtual toolbar from the parent ShellItemHandler.
+ ///
+ internal Toolbar? ShellToolbar
+ {
+ get
+ {
+ var shellItem = VirtualView?.FindParentOfType();
+ if (shellItem?.Handler is ShellItemHandler itemHandler)
+ {
+ return itemHandler._shellToolbar;
+ }
+ return null;
+ }
+ }
+
+ ///
+ /// Property mapper for ShellSection properties.
+ ///
+ public static PropertyMapper Mapper = new PropertyMapper(ElementMapper)
+ {
+ [nameof(ShellSection.CurrentItem)] = MapCurrentItem,
+ [nameof(BaseShellItem.Title)] = MapTitle,
+ [nameof(BaseShellItem.Icon)] = MapIcon,
+ [nameof(BaseShellItem.IsEnabled)] = MapIsEnabled,
+ [nameof(BaseShellItem.BadgeText)] = MapBadge,
+ [nameof(BaseShellItem.BadgeColor)] = MapBadge,
+ [nameof(BaseShellItem.BadgeTextColor)] = MapBadge,
+ };
+
+ ///
+ /// Command mapper for ShellSection commands.
+ ///
+ public static CommandMapper CommandMapper = new CommandMapper(ElementCommandMapper)
+ {
+ };
+
+ ///
+ /// Initializes a new instance of the ShellSectionHandler class.
+ ///
+ public ShellSectionHandler() : base(Mapper, CommandMapper)
+ {
+ }
+
+ ///
+ /// Sets the parent fragment that hosts this handler. Used for child fragment management.
+ ///
+ internal void SetParentFragment(Fragment fragment)
+ {
+ _parentFragment = fragment;
+ }
+
+ ///
+ /// Gets the IShellContext from the parent Shell.
+ ///
+ IShellContext GetShellContext()
+ {
+ var shell = VirtualView?.FindParentOfType();
+ if (shell?.Handler is IShellContext context)
+ {
+ return context;
+ }
+ throw new InvalidOperationException("ShellHandler must implement IShellContext");
+ }
+
+ ///
+ /// Creates the platform element by inflating shellsectionlayout.axml.
+ /// Uses XML layout inflation consistent with NavigationViewHandler and FlyoutViewHandler patterns.
+ ///
+ protected override AView CreatePlatformElement()
+ {
+ var li = MauiContext?.GetLayoutInflater()
+ ?? throw new InvalidOperationException("LayoutInflater cannot be null");
+
+ var rootView = li.Inflate(Resource.Layout.shellsectionlayout, null)
+ ?? throw new InvalidOperationException("shellsectionlayout inflation failed");
+
+ _rootLayout = rootView.FindViewById(Resource.Id.shellsection_coordinator);
+ _viewPager = rootView.FindViewById(Resource.Id.shellsection_viewpager);
+
+ // Create TabLayout programmatically (no longer from XML layout).
+ // It will be placed into navigationlayout_toptabs via PlaceTopTabs().
+ var context = MauiContext?.Context
+ ?? throw new InvalidOperationException("MauiContext.Context cannot be null");
+
+ // Resolve ?attr/actionBarSize to match the old XML layout height.
+ // The old shellsectionlayout.axml used android:layout_height="?attr/actionBarSize"
+ // for the TabLayout. Using wrap_content would make tabs ~48dp instead of 56dp,
+ // shifting all content below and causing visual regressions.
+ var actionBarSizeAttribute = new int[] { global::Android.Resource.Attribute.ActionBarSize };
+ var typedArray = context.ObtainStyledAttributes(actionBarSizeAttribute);
+ int actionBarHeight = typedArray.GetDimensionPixelSize(0, LP.WrapContent);
+ typedArray.Recycle();
+
+ _contentTabLayout = new TabLayout(context)
+ {
+ Id = AView.GenerateViewId(),
+ LayoutParameters = new LP(LP.MatchParent, actionBarHeight),
+ Visibility = ViewStates.Gone, // Hidden by default (shown when > 1 tab)
+ TabMode = TabLayout.ModeScrollable
+ };
+
+ return rootView;
+ }
+
+ ///
+ /// Creates the animation for a navigation transition within this Shell section.
+ /// Override to provide custom page transition animations for push/pop navigation.
+ /// Return null to use the default navigation animation.
+ ///
+ /// The Android context for loading animation resources.
+ /// True if navigating back, false if navigating forward.
+ /// True for the entering page's animation, false for the exiting page's animation.
+ /// The animation to use, null to use default, or an animation with zero duration to skip animation.
+ protected virtual AAnimation? OnCreateNavigationAnimation(Context context, bool isPopping, bool enter)
+ {
+ return null;
+ }
+
+ // Internal accessor for ShellStackNavigationManager to invoke the protected virtual
+ internal AAnimation? InvokeOnCreateNavigationAnimation(Context context, bool isPopping, bool enter)
+ => OnCreateNavigationAnimation(context, isPopping, enter);
+
+ protected override void ConnectHandler(AView platformView)
+ {
+ base.ConnectHandler(platformView);
+
+ _shellContext = GetShellContext();
+
+ // Subscribe to visible items collection changes (fires on add/remove AND visibility changes)
+ SectionController.ItemsCollectionChanged += OnItemsCollectionChanged;
+
+ // Wait for the view to be attached before setting up the adapter
+ // This ensures the parent fragment is set
+ _rootLayout?.ViewAttachedToWindow += OnRootLayoutAttachedToWindow;
+
+ // Try to setup immediately if already attached
+ if (_rootLayout is not null && _rootLayout.IsAttachedToWindow && _parentFragment is not null)
+ {
+ SetupViewPagerAdapter();
+ }
+ }
+
+ void OnRootLayoutAttachedToWindow(object? sender, AView.ViewAttachedToWindowEventArgs e)
+ {
+ _rootLayout?.ViewAttachedToWindow -= OnRootLayoutAttachedToWindow;
+ SetupViewPagerAdapter();
+ }
+
+ ///
+ /// Sets up the ViewPager2 adapter and TabbedViewManager for top tabs.
+ /// Called when the view is attached and parent fragment is available.
+ ///
+ internal void SetupViewPagerAdapter()
+ {
+ if (_adapter is not null || _parentFragment is null || VirtualView is null || _viewPager is null || MauiContext is null || _shellContext is null)
+ {
+ return;
+ }
+
+ // Create scoped context with parent fragment's ChildFragmentManager
+ var scopedContext = MauiContext.MakeScoped(fragmentManager: _parentFragment.ChildFragmentManager);
+
+ // Create adapter
+ _adapter = new ShellContentFragmentAdapter(VirtualView, _parentFragment, scopedContext)
+ {
+ Handler = this
+ };
+
+ _viewPager.Adapter = _adapter;
+
+ // Disable ViewPager2 instance state saving/restoring.
+ // When tabs are hidden (0 items) and shown again, FragmentStateAdapter.restoreState()
+ // crashes with "Expected the adapter to be 'fresh'" because it tries to restore saved
+ // fragment state into an adapter that already has fragments registered.
+ // Shell manages its own state, so we don't need ViewPager2's state restoration.
+ ((AView)_viewPager).SaveEnabled = false;
+
+ // Keep ALL content fragments alive to prevent FragmentStateAdapter from
+ // saving/restoring fragment state. Restored fragments lose MAUI-specific state
+ // (StackNavigationManager, etc.) causing crashes.
+ var visibleItems = SectionController.GetItems();
+ _viewPager.OffscreenPageLimit = Math.Max(visibleItems.Count, 1);
+
+ // Setup TabbedViewManager for top tab management.
+ // Pre-assign Shell's TabLayout (specific sizing) before SetElement.
+ _shellSectionAdapter = new ShellSectionTabbedViewAdapter(VirtualView);
+ _tabbedViewManager = new TabbedViewManager(MauiContext, _viewPager)
+ {
+ TabLayout = _contentTabLayout
+ };
+ _tabbedViewManager.SetElement(_shellSectionAdapter);
+
+ // Register page change callback (stored in field for cleanup in DisconnectHandler)
+ _pageChangedCallback = new ViewPagerPageChangeCallback(this);
+ _viewPager.RegisterOnPageChangeCallback(_pageChangedCallback);
+
+ // Update TabLayout visibility based on item count
+ UpdateTabLayoutVisibility();
+
+ // Disable user swiping if only one item
+ UpdateViewPagerUserInput();
+
+ // Set initial position
+ SetInitialPosition();
+
+ // Setup TabLayout appearance tracker
+ _tabLayoutAppearanceTracker = _shellContext.CreateTabLayoutAppearanceTracker(VirtualView);
+
+ // Register as appearance observer for TabLayout updates
+ var shell = VirtualView.FindParentOfType();
+ if (shell is not null)
+ {
+ _registeredShell = shell;
+ ((IShellController)shell).AddAppearanceObserver(this, VirtualView);
+ }
+
+ // Trigger initial appearance update — only for the active section.
+ // During SwitchToShellItem, ViewPager2 recreates fragments for ALL sections.
+ // Without this guard, an inactive section (e.g., TabOne with NavStackCount=3)
+ // would override the toolbar with its stale page, wiping the flyout icon.
+ var currentContent = VirtualView.CurrentItem;
+ if (currentContent is not null && IsCurrentlyActiveSection())
+ {
+ var page = ((IShellContentController)currentContent).GetOrCreateContent();
+ if (page is not null && shell is not null)
+ {
+ // Update toolbar tracker with current page
+ var toolbarTracker = ToolbarTracker;
+ toolbarTracker?.Page = page;
+
+ ((IShellController)shell).AppearanceChanged(page, false);
+ }
+ }
+ }
+
+ void SetInitialPosition()
+ {
+ if (VirtualView?.CurrentItem is null || _viewPager is null)
+ {
+ return;
+ }
+
+ var visibleItems = SectionController.GetItems();
+ var currentIndex = visibleItems.IndexOf(VirtualView.CurrentItem);
+ if (currentIndex >= 0 && _viewPager.CurrentItem != currentIndex)
+ {
+ _viewPager.SetCurrentItem(currentIndex, false);
+ }
+ }
+
+ void UpdateTabLayoutVisibility()
+ {
+ if (_tabbedViewManager is null || VirtualView is null)
+ {
+ return;
+ }
+
+ // Only manage the shared top-tabs container for the currently active section.
+ // ViewPager2 creates ALL section fragments (offscreenPageLimit = sectionCount),
+ // so without this guard, inactive sections with ≤1 content call RemoveTopTabs()
+ // which hides the shared navigationlayout_toptabs container — overriding the
+ // active section's PlaceTopTabs() and making top tabs invisible.
+ if (VirtualView?.Parent is ShellItem parentItem && parentItem.CurrentItem != VirtualView)
+ {
+ return;
+ }
+
+ // Hide TabLayout when 0 or 1 visible content
+ var visibleCount = SectionController.GetItems().Count;
+ bool showTabs = visibleCount > 1;
+
+ if (showTabs)
+ {
+ PlaceTopTabs();
+ }
+ else
+ {
+ RemoveTopTabs();
+ }
+ }
+
+ void UpdateViewPagerUserInput()
+ {
+ if (_viewPager is null || VirtualView is null)
+ {
+ return;
+ }
+
+ // Disable user swiping if only 1 visible content
+ var visibleCount = SectionController.GetItems().Count;
+ bool enableUserInput = visibleCount > 1;
+ _viewPager.UserInputEnabled = enableUserInput;
+ }
+
+ ///
+ /// Exposes the content TabLayout for TabLayoutMediator and appearance updates.
+ ///
+ internal TabLayout? ContentTabLayout => _contentTabLayout;
+
+ AView? FindNavigationLayoutTopTabsContainer()
+ {
+ var shell = VirtualView?.FindParentOfType();
+ var rootView = shell?.Handler?.PlatformView as AView;
+ return rootView?.FindViewById(Resource.Id.navigationlayout_toptabs);
+ }
+
+ ///
+ /// Places the TabLayout into navigationlayout_toptabs via TabbedViewManager.
+ /// Delegates fragment placement to TabbedViewManager.SetTabLayout().
+ ///
+ internal void PlaceTopTabs()
+ {
+ if (_tabbedViewManager is null)
+ {
+ return;
+ }
+
+ // Only place if this section is the currently active section.
+ // ViewPager2 creates all section fragments (offscreenPageLimit = sectionCount),
+ // so without this guard, every multi-content section would call PlaceTopTabs()
+ // and the last one wins — showing wrong tabs on the initial active section.
+ if (VirtualView?.Parent is ShellItem parentItem && parentItem.CurrentItem != VirtualView)
+ {
+ return;
+ }
+
+ _tabbedViewManager.SetTabLayout();
+
+ // Ensure the container FragmentContainerView is visible when tabs are placed
+ var topTabsContainer = FindNavigationLayoutTopTabsContainer();
+ topTabsContainer?.Visibility = ViewStates.Visible;
+
+ _contentTabLayout?.Visibility = ViewStates.Visible;
+ }
+
+ ///
+ /// Removes the TabLayout from navigationlayout_toptabs via TabbedViewManager.
+ /// Called when tabs should be hidden (1 content, page pushed beyond root, or section deactivated).
+ ///
+ internal void RemoveTopTabs()
+ {
+ // Always hide the container FragmentContainerView so it doesn't take space in the AppBarLayout,
+ // even if no tabs were placed (initial single-content case)
+ var topTabsContainer = FindNavigationLayoutTopTabsContainer();
+ topTabsContainer?.Visibility = ViewStates.Gone;
+
+ _tabbedViewManager?.RemoveTabs();
+
+ _contentTabLayout?.Visibility = ViewStates.Gone;
+ }
+
+ protected override void DisconnectHandler(AView platformView)
+ {
+ // Unsubscribe from events
+ _rootLayout?.ViewAttachedToWindow -= OnRootLayoutAttachedToWindow;
+
+ SectionController.ItemsCollectionChanged -= OnItemsCollectionChanged;
+
+ // Only remove top tabs from the shared container if this is the active section.
+ // When inactive sections are disconnected (e.g., VP2 adapter updates recreate
+ // fragments for bottom tabs that reappeared), their DisconnectHandler must NOT
+ // hide the shared navigationlayout_toptabs container — it would override the
+ // active section's PlaceTopTabs() that already made it visible.
+ if (IsCurrentlyActiveSection())
+ {
+ RemoveTopTabs();
+ }
+
+ // Cleanup TabbedViewManager
+ _tabbedViewManager?.SetElement(null);
+ _tabbedViewManager = null;
+ _shellSectionAdapter = null;
+
+ // Unregister page change callback
+ if (_pageChangedCallback is not null && _viewPager is not null)
+ {
+ _viewPager.UnregisterOnPageChangeCallback(_pageChangedCallback);
+ _pageChangedCallback = null;
+ }
+
+ // Cleanup adapter
+ _adapter = null;
+ _viewPager?.Adapter = null;
+ _viewPager = null;
+
+ // Cleanup TabLayout
+ _contentTabLayout = null;
+
+ // Unregister appearance observer (use cached Shell reference for reliable removal
+ // even when VirtualView has already been detached from Shell.Items)
+ if (_registeredShell is not null)
+ {
+ ((IShellController)_registeredShell).RemoveAppearanceObserver(this);
+ _registeredShell = null;
+ }
+
+ // Dispose TabLayout appearance tracker
+ _tabLayoutAppearanceTracker?.Dispose();
+ _tabLayoutAppearanceTracker = null;
+
+ _rootLayout = null;
+ _shellContext = null;
+
+ base.DisconnectHandler(platformView);
+ }
+
+ ///
+ /// Maps CurrentItem property changes - just update ViewPager2 position.
+ ///
+ public static void MapCurrentItem(ShellSectionHandler handler, ShellSection shellSection)
+ {
+ if (handler is null || shellSection?.CurrentItem is null || handler._viewPager is null)
+ {
+ return;
+ }
+
+ var visibleItems = ((IShellSectionController)shellSection).GetItems();
+ var currentItem = shellSection.CurrentItem;
+
+ if (visibleItems is not null && currentItem is not null)
+ {
+ var targetIndex = visibleItems.IndexOf(currentItem);
+ if (targetIndex >= 0 && handler._viewPager.CurrentItem != targetIndex)
+ {
+ handler._viewPager.SetCurrentItem(targetIndex, true);
+ }
+ }
+ }
+
+ void OnItemsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (_adapter is null || _viewPager is null || _parentFragment is null || VirtualView is null || MauiContext is null)
+ {
+ return;
+ }
+
+ // When items go from 0 → N, we must create a fresh adapter because
+ // FragmentStateAdapter saves internal fragment state when items are removed.
+ // Reusing the same adapter with new items that have matching IDs triggers
+ // "Expected the adapter to be 'fresh' while restoring state" crash.
+ var previousCount = _adapter.ItemCount;
+ _adapter.OnItemsCollectionChanged();
+ var newCount = _adapter.ItemCount;
+
+ if (previousCount == 0 && newCount > 0)
+ {
+ // Replace with a fresh adapter to avoid stale state restoration
+ _viewPager?.Adapter = null;
+
+ _adapter = new ShellContentFragmentAdapter(VirtualView, _parentFragment, MauiContext.MakeScoped(fragmentManager: _parentFragment.ChildFragmentManager))
+ {
+ Handler = this
+ };
+ _viewPager?.Adapter = _adapter;
+ _viewPager?.SaveEnabled = false;
+
+ // Refresh TabbedViewManager's mediator for the new adapter
+ _tabbedViewManager?.RefreshTabs();
+ }
+ else
+ {
+ _adapter.NotifyDataSetChanged();
+ }
+
+ // Update OffscreenPageLimit for new visible count
+ var visibleCount = SectionController.GetItems().Count;
+ _viewPager?.OffscreenPageLimit = Math.Max(visibleCount, 1);
+
+ UpdateTabLayoutVisibility();
+ UpdateViewPagerUserInput();
+ }
+
+ ///
+ /// Maps Title property changes. Both ShellContent.Title and ShellSection.Title resolve
+ /// to "Title", so this single handler updates top tabs (ShellContent titles) and
+ /// notifies the parent ShellItemHandler to update the bottom tab title.
+ ///
+ static void MapTitle(ShellSectionHandler handler, ShellSection section)
+ {
+ if (handler.IsConnectingHandler())
+ {
+ // On initial load, SetupBottomNavigationView already populates tab titles.
+ // Skip redundant mapping during handler connection.
+ return;
+ }
+
+ // Update top tab titles (ShellContent titles in TabLayout)
+ UpdateTabTitle(handler, section);
+
+ // Update bottom tab title (ShellSection title in BottomNavigationView)
+ var shellItem = section?.FindParentOfType();
+ if (shellItem?.Handler is ShellItemHandler itemHandler)
+ {
+ itemHandler.UpdateBottomTabTitle(section);
+ }
+ }
+
+ ///
+ /// Maps Icon property changes. Notifies the parent ShellItemHandler to update
+ /// the bottom tab icon for this section.
+ ///
+ static void MapIcon(ShellSectionHandler handler, ShellSection section)
+ {
+ if (handler.IsConnectingHandler())
+ {
+ // On initial load, SetupBottomNavigationView already populates tab icons.
+ // Skip redundant mapping during handler connection.
+ return;
+ }
+
+ var shellItem = section?.FindParentOfType();
+ if (shellItem?.Handler is ShellItemHandler itemHandler)
+ {
+ itemHandler.UpdateBottomTabIcon(section);
+ }
+ }
+
+ ///
+ /// Maps IsEnabled property changes. Notifies the parent ShellItemHandler to update
+ /// the bottom tab enabled state for this section.
+ ///
+ static void MapIsEnabled(ShellSectionHandler handler, ShellSection section)
+ {
+ if (handler.IsConnectingHandler())
+ {
+ // On initial load, SetupBottomNavigationView already populates tab enabled state.
+ // Skip redundant mapping during handler connection.
+ return;
+ }
+
+ var shellItem = section?.FindParentOfType();
+ if (shellItem?.Handler is ShellItemHandler itemHandler)
+ {
+ itemHandler.UpdateBottomTabEnabled(section);
+ }
+ }
+
+ ///
+ /// Maps BadgeText, BadgeColor, BadgeTextColor property changes.
+ /// Notifies the parent ShellItemHandler to update the bottom tab badge.
+ ///
+ static void MapBadge(ShellSectionHandler handler, ShellSection section)
+ {
+ if (handler.IsConnectingHandler())
+ {
+ return;
+ }
+
+ var shellItem = section?.FindParentOfType();
+ if (shellItem?.Handler is ShellItemHandler itemHandler)
+ {
+ itemHandler.UpdateBottomTabBadge(section);
+ }
+ }
+
+ ///
+ /// Updates all top tab titles from current visible ShellContent items.
+ /// Called via mapper when a child ShellContent.Title changes — ShellContent.OnPropertyChanged
+ /// propagates the change to the parent ShellSection handler via UpdateValue("Title").
+ ///
+ static void UpdateTabTitle(ShellSectionHandler handler, ShellSection section)
+ {
+ if (handler._contentTabLayout is null || section is null)
+ {
+ return;
+ }
+
+ var visibleItems = ((IShellSectionController)section).GetItems();
+ for (int i = 0; i < visibleItems.Count; i++)
+ {
+ var tab = handler._contentTabLayout.GetTabAt(i);
+ tab?.SetText(new Java.Lang.String(visibleItems[i].Title));
+ }
+ }
+
+ ///
+ /// Checks if this ShellSection is currently the active one in the Shell hierarchy.
+ ///
+ internal bool IsCurrentlyActiveSection()
+ {
+ if (VirtualView is null)
+ return false;
+
+ var shellItem = VirtualView.FindParentOfType();
+ if (shellItem is null || shellItem.CurrentItem != VirtualView)
+ return false;
+
+ var shell = shellItem.FindParentOfType();
+ if (shell is null || shell.CurrentItem != shellItem)
+ return false;
+
+ return true;
+ }
+
+ #region IAppearanceObserver - TabLayout Appearance Only
+
+ ///
+ /// Called when Shell appearance changes.
+ /// ONLY updates TabLayout appearance.
+ /// Toolbar appearance is handled by ShellItemHandler.
+ ///
+ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance)
+ {
+ if (_tabLayoutAppearanceTracker is not null && _contentTabLayout is not null)
+ {
+ if (appearance is not null)
+ {
+ _tabLayoutAppearanceTracker.SetAppearance(_contentTabLayout, appearance);
+ }
+ else
+ {
+ _tabLayoutAppearanceTracker.ResetAppearance(_contentTabLayout);
+ }
+ }
+ }
+
+ #endregion
+ }
+
+ #region ViewPager2 Support Classes
+
+ ///
+ /// FragmentStateAdapter for managing ShellContent fragments in ViewPager2
+ ///
+ internal class ShellContentFragmentAdapter : FragmentStateAdapter
+ {
+ readonly ShellSection _shellSection;
+ readonly IMauiContext _mauiContext;
+ IList? _visibleItems;
+ long _itemIdCounter;
+ readonly Dictionary _contentIds = new Dictionary();
+ IShellSectionController SectionController => (IShellSectionController)_shellSection;
+
+ public ShellContentFragmentAdapter(ShellSection shellSection, Fragment parentFragment, IMauiContext mauiContext)
+ : base(parentFragment)
+ {
+ _shellSection = shellSection;
+ _mauiContext = mauiContext;
+ _visibleItems = SectionController.GetItems();
+ }
+
+ public override int ItemCount => _visibleItems?.Count ?? 0;
+
+ public ShellSectionHandler? Handler { get; set; }
+
+ ///
+ /// Refreshes the visible items list. Called when items collection changes (add/remove/visibility).
+ /// Removes stale IDs for items no longer present so ContainsItem returns false,
+ /// causing FragmentStateAdapter to remove their fragments.
+ ///
+ public void OnItemsCollectionChanged()
+ {
+ var newItems = SectionController.GetItems();
+
+ // Remove IDs for items that are no longer visible
+ var toRemove = new List();
+ foreach (var kvp in _contentIds)
+ {
+ if (!newItems.Contains(kvp.Key))
+ toRemove.Add(kvp.Key);
+ }
+ foreach (var item in toRemove)
+ _contentIds.Remove(item);
+
+ _visibleItems = newItems;
+ }
+
+ public override Fragment CreateFragment(int position)
+ {
+ if (_visibleItems is null || position >= _visibleItems.Count)
+ {
+ return null!;
+ }
+
+ var shellContent = _visibleItems[position];
+ return new ShellContentNavigationFragment(shellContent, _mauiContext, Handler);
+ }
+
+ ///
+ /// Returns a stable ID for each ShellContent. Uses an incrementing counter
+ /// with a dictionary to avoid hash collisions (GetHashCode is not guaranteed unique).
+ /// Same pattern as ShellSectionFragmentAdapter.GetItemId.
+ ///
+ public override long GetItemId(int position)
+ {
+ if (_visibleItems is null || position >= _visibleItems.Count)
+ {
+ return -1;
+ }
+ var content = _visibleItems[position];
+ if (!_contentIds.TryGetValue(content, out var id))
+ {
+ id = ++_itemIdCounter;
+ _contentIds[content] = id;
+ }
+ return id;
+ }
+
+ public override bool ContainsItem(long itemId)
+ {
+ if (_visibleItems is null)
+ {
+ return false;
+ }
+ foreach (var kvp in _contentIds)
+ {
+ if (kvp.Value == itemId && _visibleItems.Contains(kvp.Key))
+ return true;
+ }
+ return false;
+ }
+ }
+
+ ///
+ /// ViewPager2 page change callback to update toolbar when swiping between content tabs.
+ ///
+ internal class ViewPagerPageChangeCallback : ViewPager2.OnPageChangeCallback
+ {
+ readonly ShellSectionHandler _handler;
+ bool _selecting; // Matches legacy ShellSectionRenderer._selecting — prevents re-entrant OnPageSelected during cancel revert
+ public ViewPagerPageChangeCallback(ShellSectionHandler handler)
+ {
+ _handler = handler;
+ }
+
+ public override void OnPageSelected(int position)
+ {
+ base.OnPageSelected(position);
+
+ if (_selecting)
+ {
+ return;
+ }
+
+ // Close any active action modes (e.g., ListView context menus) from the previous page.
+ // OffscreenPageLimit keeps all fragment views attached, so OnDetachedFromWindow never
+ // fires on ListViews. OnPageSelected is the reliable hook for page changes.
+ ShellContentNavigationFragment.CloseActiveActionModes(_handler.PlatformView);
+
+ var virtualView = _handler.VirtualView;
+
+ if (virtualView is null)
+ {
+ return;
+ }
+
+ var visibleItems = ((IShellSectionController)virtualView).GetItems();
+
+ if (position >= visibleItems.Count)
+ {
+ return;
+ }
+
+ // Only update toolbar if this section is currently active
+ if (!_handler.IsCurrentlyActiveSection())
+ {
+ return;
+ }
+
+ var newCurrentItem = visibleItems[position];
+
+ // Skip if already on this content (e.g. programmatic GoToAsync set CurrentItem
+ // before VP2 moved, or MapCurrentItem drove VP2 to the correct position).
+ if (newCurrentItem == virtualView.CurrentItem)
+ {
+ return;
+ }
+
+ // This is the single authority for ShellContent navigation proposals.
+ // The ShellSectionTabbedViewAdapter.CurrentTab setter is intentionally a no-op
+ // to avoid double Shell.Navigating events on tab tap (TabSelected + OnPageSelected
+ // both fire for the same user action via TabLayoutMediator).
+ var shell = virtualView.FindParentOfType();
+ if (shell is not null)
+ {
+ var shellSection = virtualView;
+ var shellItem = shellSection.Parent as ShellItem;
+ var stack = shellSection.Stack.ToList();
+ bool accepted = ((IShellController)shell).ProposeNavigation(
+ ShellNavigationSource.ShellContentChanged,
+ shellItem, shellSection, newCurrentItem, stack, true);
+
+ if (!accepted)
+ {
+ // Navigation was cancelled — revert ViewPager2 and TabLayout.
+ // Matches legacy ShellSectionRenderer._selecting pattern: guard flag
+ // prevents re-entrant OnPageSelected during the deferred revert.
+ if (virtualView.CurrentItem is not null)
+ {
+ var currentPosition = visibleItems.IndexOf(virtualView.CurrentItem);
+
+ if (currentPosition >= 0)
+ {
+ _selecting = true;
+
+ _handler.ViewPager?.Post(() =>
+ {
+ _handler.ViewPager?.SetCurrentItem(currentPosition, false);
+
+ // Revert TabLayout indicator and text color state.
+ // SetScrollPosition forces a full visual state reset
+ // (including tab text colors that get stuck during swipe).
+ var tabLayout = _handler.ContentTabLayout;
+ if (tabLayout is not null && currentPosition < tabLayout.TabCount)
+ {
+ tabLayout.SetScrollPosition(currentPosition, 0f, true);
+ tabLayout.SelectTab(tabLayout.GetTabAt(currentPosition));
+ }
+
+ _selecting = false;
+ });
+ }
+ }
+ return;
+ }
+ }
+
+ var page = ((IShellContentController)newCurrentItem).GetOrCreateContent();
+
+ if (page is null)
+ {
+ return;
+ }
+
+ // Update toolbar title
+ var toolbarTracker = _handler.ToolbarTracker;
+ toolbarTracker?.Page = page;
+
+ // Update CurrentItem
+ virtualView.CurrentItem = newCurrentItem;
+
+ // Trigger appearance update
+ if (shell is not null)
+ {
+ ((IShellController)shell).AppearanceChanged(page, false);
+ }
+ }
+ }
+
+ ///
+ /// TabLayout configuration strategy for ShellContent tabs
+ ///
+ internal class ShellTabConfigurationStrategy : Java.Lang.Object, TabLayoutMediator.ITabConfigurationStrategy
+ {
+ readonly ShellSection _shellSection;
+ IShellSectionController SectionController => (IShellSectionController)_shellSection;
+
+ public ShellTabConfigurationStrategy(ShellSection shellSection)
+ {
+ _shellSection = shellSection;
+ }
+
+ public void OnConfigureTab(TabLayout.Tab tab, int position)
+ {
+ var visibleItems = SectionController.GetItems();
+ if (visibleItems is null || position >= visibleItems.Count)
+ return;
+
+ var shellContent = visibleItems[position];
+ tab.SetText(shellContent.Title);
+ }
+ }
+
+ #endregion ViewPager2 Support Classes
+
+ ///
+ /// Adapter that bridges ShellSectionHandler with IShellSectionRenderer interface.
+ ///
+ internal class ShellSectionHandlerAdapter : IShellSectionRenderer
+ {
+ readonly ShellSectionHandler _handler;
+ ShellSectionWrapperFragment? _wrapperFragment;
+
+ public ShellSectionHandlerAdapter(ShellSectionHandler handler, IMauiContext mauiContext)
+ {
+ _handler = handler ?? throw new ArgumentNullException(nameof(handler));
+ }
+
+ public Fragment Fragment
+ {
+ get
+ {
+ if (_wrapperFragment is null)
+ {
+ _wrapperFragment = new ShellSectionWrapperFragment(_handler);
+ }
+ return _wrapperFragment;
+ }
+ }
+
+ public ShellSection ShellSection
+ {
+ get => _handler.VirtualView;
+ set
+ {
+ if (_handler.VirtualView != value)
+ {
+ _handler.SetVirtualView(value);
+ }
+ }
+ }
+
+ // Required by IShellSectionRenderer → IShellObservableFragment interface.
+ // The new handler architecture (ShellItemHandler) does not subscribe to this event;
+ // it was only consumed by the old ShellItemRendererBase.HandleFragmentUpdate().
+#pragma warning disable CS0067
+ public event EventHandler? AnimationFinished;
+#pragma warning restore CS0067
+
+ public event EventHandler? Destroyed;
+
+ public void Dispose()
+ {
+ Destroyed?.Invoke(this, EventArgs.Empty);
+
+ // Disconnect the handler to unsubscribe from required events and clean up resources.
+ ((IElementHandler)_handler).DisconnectHandler();
+
+ _wrapperFragment?.Dispose();
+ _wrapperFragment = null;
+ }
+ }
+
+ ///
+ /// StackNavigationManager subclass that delegates animation creation to the ShellSectionHandler,
+ /// enabling custom page transition animations in Shell via protected virtual override.
+ ///
+ internal class ShellStackNavigationManager : StackNavigationManager
+ {
+ readonly ShellSectionHandler _handler;
+
+ public ShellStackNavigationManager(IMauiContext mauiContext, ShellSectionHandler handler)
+ : base(mauiContext)
+ {
+ _handler = handler;
+ }
+
+ public override AAnimation? OnCreateNavigationAnimation(
+ Context context, bool isPopping, bool enter)
+ {
+ return _handler.InvokeOnCreateNavigationAnimation(context, isPopping, enter)
+ ?? base.OnCreateNavigationAnimation(context, isPopping, enter);
+ }
+ }
+}
diff --git a/src/Controls/src/Core/Handlers/Shell/ShellSectionWrapperFragment.Android.cs b/src/Controls/src/Core/Handlers/Shell/ShellSectionWrapperFragment.Android.cs
new file mode 100644
index 000000000000..2d159ec62eae
--- /dev/null
+++ b/src/Controls/src/Core/Handlers/Shell/ShellSectionWrapperFragment.Android.cs
@@ -0,0 +1,113 @@
+#nullable enable
+using Android.OS;
+using Android.Views;
+using Android.Widget;
+using AndroidX.Fragment.App;
+using Microsoft.Maui.Controls.Platform.Compatibility;
+using AView = Android.Views.View;
+
+namespace Microsoft.Maui.Controls.Handlers
+{
+ ///
+ /// Simple wrapper fragment - just returns the handler's unified view.
+ ///
+ public class ShellSectionWrapperFragment : Fragment
+ {
+ readonly ShellSectionHandler? _handler;
+ AView? _view;
+
+ // Default constructor required by Android's FragmentManager for fragment restoration
+ public ShellSectionWrapperFragment()
+ {
+ _handler = null;
+ }
+
+ public ShellSectionWrapperFragment(ShellSectionHandler handler)
+ {
+ _handler = handler;
+ _handler.SetParentFragment(this);
+ }
+
+ public override void OnCreate(Bundle? savedInstanceState)
+ {
+ // Always pass null to prevent restoring stale child fragment state.
+ // OffscreenPageLimit keeps fragments alive so restoration shouldn't occur,
+ // but this is defense-in-depth.
+ base.OnCreate(null);
+ }
+
+ public override AView OnCreateView(LayoutInflater inflater, ViewGroup? container, Bundle? savedInstanceState)
+ {
+ // If restored without proper handler reference, return empty view
+ if (_handler is null)
+ {
+ return new FrameLayout(inflater.Context!);
+ }
+ if (_view is null)
+ {
+ _view = _handler.PlatformView ?? _handler.ToPlatform();
+ }
+
+ // Remove from parent if it has one (fragment recreation scenario)
+ if (_view.Parent is ViewGroup parent)
+ {
+ parent.RemoveView(_view);
+ }
+
+ return _view;
+ }
+
+ public override void OnViewCreated(AView view, Bundle? savedInstanceState)
+ {
+ base.OnViewCreated(view, savedInstanceState);
+
+ // Setup adapter now that fragment is attached
+ _handler?.SetupViewPagerAdapter();
+ }
+
+ public override void OnResume()
+ {
+ base.OnResume();
+
+ if (_handler is null || _handler.VirtualView is null)
+ {
+ return;
+ }
+
+ if (!_handler.IsCurrentlyActiveSection())
+ {
+ return;
+ }
+
+ var shell = _handler.VirtualView.FindParentOfType();
+ var currentContent = _handler.VirtualView.CurrentItem;
+
+ if (shell is null || currentContent is null)
+ {
+ return;
+ }
+
+ var page = ((IShellContentController)currentContent).GetOrCreateContent();
+
+ if (page is null)
+ {
+ return;
+ }
+
+ // Update toolbar
+ var toolbarTracker = _handler.ToolbarTracker;
+ toolbarTracker?.Page = page;
+
+ ((IShellController)shell).AppearanceChanged(page, false);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _view = null;
+ }
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/src/Controls/src/Core/Handlers/Shell/ShellTabbedViewAdapters.Android.cs b/src/Controls/src/Core/Handlers/Shell/ShellTabbedViewAdapters.Android.cs
new file mode 100644
index 000000000000..b0cdda058a4a
--- /dev/null
+++ b/src/Controls/src/Core/Handlers/Shell/ShellTabbedViewAdapters.Android.cs
@@ -0,0 +1,183 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Linq;
+using Microsoft.Maui.Graphics;
+
+namespace Microsoft.Maui.Controls.Handlers
+{
+ ///
+ /// Adapts a as an for bottom tab management.
+ /// Used by to delegate tab management to .
+ /// Bar colors are null because Shell uses appearance trackers for styling.
+ ///
+ internal class ShellItemTabbedViewAdapter : ITabbedViewSource
+ {
+ readonly ShellItem _shellItem;
+
+ public ShellItemTabbedViewAdapter(ShellItem shellItem)
+ {
+ _shellItem = shellItem;
+ }
+
+ public IReadOnlyList Tabs =>
+ ((IShellItemController)_shellItem).GetItems()
+ .Select(s => (ITab)new ShellSectionTab(s)).ToList();
+
+ public ITab? CurrentTab
+ {
+ get => _shellItem.CurrentItem is not null ? new ShellSectionTab(_shellItem.CurrentItem) : null;
+ set
+ {
+ if (value is ShellSectionTab tab && tab.Section != _shellItem.CurrentItem)
+ {
+ // Use ProposeSection to fire Shell.Navigating event and support cancellation
+ ((IShellItemController)_shellItem).ProposeSection(tab.Section);
+ }
+ }
+ }
+
+ public int CurrentTabIndex =>
+ _shellItem.CurrentItem is not null
+ ? ((IShellItemController)_shellItem).GetItems().IndexOf(_shellItem.CurrentItem)
+ : -1;
+
+ // Bar colors are null — Shell applies appearance via IShellBottomNavViewAppearanceTracker
+ public Color? BarBackgroundColor => null;
+ public object? BarBackground => null;
+ public Color? BarTextColor => null;
+ public Color? UnselectedTabColor => null;
+ public Color? SelectedTabColor => null;
+
+ public TabBarPlacement TabBarPlacement => TabBarPlacement.Bottom;
+ public int OffscreenPageLimit => 0; // Not used — Shell manages VP2 directly
+ public bool IsSwipePagingEnabled => false; // Bottom tabs don't support swipe
+ public bool IsSmoothScrollEnabled => false; // Not used
+
+ public Element? Owner => _shellItem;
+
+ public event NotifyCollectionChangedEventHandler TabsChanged
+ {
+ add => ((IShellItemController)_shellItem).ItemsCollectionChanged += value;
+ remove => ((IShellItemController)_shellItem).ItemsCollectionChanged -= value;
+ }
+ }
+
+ ///
+ /// Adapts a as an for top tab management.
+ /// Used by to delegate tab management to .
+ ///
+ internal class ShellSectionTabbedViewAdapter : ITabbedViewSource
+ {
+ readonly ShellSection _shellSection;
+ IShellSectionController SectionController => (IShellSectionController)_shellSection;
+
+ public ShellSectionTabbedViewAdapter(ShellSection shellSection)
+ {
+ _shellSection = shellSection;
+ }
+
+ public IReadOnlyList Tabs =>
+ SectionController.GetItems()
+ .Select(c => (ITab)new ShellContentTab(c)).ToList();
+
+ public ITab? CurrentTab
+ {
+ get => _shellSection.CurrentItem is not null ? new ShellContentTab(_shellSection.CurrentItem) : null;
+ set
+ {
+ // Intentionally a no-op. For Shell top tabs, the navigation proposal
+ // (ProposeNavigation) and CurrentItem update are handled exclusively by
+ // ViewPagerPageChangeCallback.OnPageSelected on ShellSectionHandler.
+ //
+ // TabbedViewManager.TabSelected calls this setter on tab tap, but
+ // TabLayoutMediator also drives VP2 to the new position in the same
+ // tap, which fires OnPageSelected. If we called ProposeNavigation here
+ // AND in OnPageSelected, Shell.Navigating would fire twice per tap.
+ //
+ // By keeping this setter empty, OnPageSelected is the single authority
+ // for ShellContent navigation proposals — one Navigating event per tap.
+ }
+ }
+
+ public int CurrentTabIndex =>
+ _shellSection.CurrentItem is not null
+ ? SectionController.GetItems().IndexOf(_shellSection.CurrentItem)
+ : -1;
+
+ // Bar colors are null — Shell applies appearance via IShellTabLayoutAppearanceTracker
+ public Color? BarBackgroundColor => null;
+ public object? BarBackground => null;
+ public Color? BarTextColor => null;
+ public Color? UnselectedTabColor => null;
+ public Color? SelectedTabColor => null;
+
+ public TabBarPlacement TabBarPlacement => TabBarPlacement.Top;
+ public int OffscreenPageLimit => 0; // Not used — Shell manages VP2 directly
+ public bool IsSwipePagingEnabled => false; // Not used
+ public bool IsSmoothScrollEnabled => false; // Not used
+
+ public Element? Owner => _shellSection;
+
+ public event NotifyCollectionChangedEventHandler TabsChanged
+ {
+ add => SectionController.ItemsCollectionChanged += value;
+ remove => SectionController.ItemsCollectionChanged -= value;
+ }
+ }
+
+ ///
+ /// Wraps a as an for bottom tab items.
+ ///
+ internal class ShellSectionTab : ITab
+ {
+ readonly ShellSection _section;
+
+ public ShellSectionTab(ShellSection section)
+ {
+ _section = section;
+ }
+
+ public string Title => _section.Title ?? string.Empty;
+ public IImageSource Icon => _section.Icon;
+ public bool IsEnabled => _section.IsEnabled;
+
+ ///
+ /// The underlying ShellSection — used by
+ /// to map tab selection back to ShellItem.CurrentItem.
+ ///
+ internal ShellSection Section => _section;
+
+ public override bool Equals(object? obj) => obj is ShellSectionTab other && _section == other._section;
+ public override int GetHashCode() => _section.GetHashCode();
+ }
+
+ ///
+ /// Wraps a as an for top tab items.
+ ///
+ internal class ShellContentTab : ITab
+ {
+ readonly ShellContent _content;
+
+ public ShellContentTab(ShellContent content)
+ {
+ _content = content;
+ }
+
+ public string Title => _content.Title ?? string.Empty;
+
+ // Shell top tabs are text-only — icons were never shown in the old ShellSectionRenderer.
+ // Return null to preserve this behavior (TabbedViewManager calls UpdateTabIcons for all tabs).
+ public IImageSource? Icon => null;
+ public bool IsEnabled => _content.IsEnabled;
+
+ ///
+ /// The underlying ShellContent — used by
+ /// to map tab selection back to ShellSection.CurrentItem.
+ ///
+ internal ShellContent Content => _content;
+
+ public override bool Equals(object? obj) => obj is ShellContentTab other && _content == other._content;
+ public override int GetHashCode() => _content.GetHashCode();
+ }
+}
diff --git a/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs b/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs
index 7b317c8f892f..701cdaeefca6 100644
--- a/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs
+++ b/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs
@@ -127,7 +127,7 @@ internal static IMauiHandlersCollection AddControlsHandlers(this IMauiHandlersCo
handlersCollection.AddHandler();
handlersCollection.AddHandler();
handlersCollection.AddHandler();
- handlersCollection.AddHandler();
+ handlersCollection.AddHandler();
#endif
handlersCollection.AddHandler();
handlersCollection.AddHandler();
@@ -212,13 +212,17 @@ internal static IMauiHandlersCollection AddControlsHandlers(this IMauiHandlersCo
handlersCollection.AddHandler();
#endif
-#if ANDROID || IOS || MACCATALYST
+#if IOS || MACCATALYST
handlersCollection.AddHandler();
#elif WINDOWS
handlersCollection.AddHandler();
handlersCollection.AddHandler();
handlersCollection.AddHandler();
handlersCollection.AddHandler();
+#elif ANDROID
+ handlersCollection.AddHandler();
+ handlersCollection.AddHandler();
+ handlersCollection.AddHandler();
#elif TIZEN
handlersCollection.AddHandler();
handlersCollection.AddHandler();
diff --git a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs
index 9cc0f1bd6118..3e8de6354ffd 100644
--- a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs
+++ b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs
@@ -17,7 +17,6 @@
using Google.Android.Material.Badge;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Primitives;
-using AGraphics = Android.Graphics;
using ATextView = global::Android.Widget.TextView;
using AToolbar = AndroidX.AppCompat.Widget.Toolbar;
using AView = global::Android.Views.View;
@@ -149,11 +148,14 @@ public static void UpdateBackButton(this AToolbar nativeToolbar, Toolbar toolbar
}
else
{
- // Reinitialize navigation icon to display flyout (hamburger) menu
- // This ensures the icon is shown when back button is not visible
- nativeToolbar.NavigationIcon = new DrawerArrowDrawable(context!);
+ // Preserve any custom flyout icon assigned by ShellToolbarTracker.
+ // Only create a default drawer arrow when no navigation icon exists.
+ nativeToolbar.NavigationIcon ??= new DrawerArrowDrawable(context!);
+
if (nativeToolbar.NavigationIcon is DrawerArrowDrawable iconDrawable)
+ {
iconDrawable.Progress = 0;
+ }
nativeToolbar.SetNavigationContentDescription(Resource.String.nav_app_bar_open_drawer_description);
}
@@ -199,7 +201,9 @@ public static void UpdateIconColor(this AToolbar nativeToolbar, Toolbar toolbar)
if (nativeToolbar.NavigationIcon is Drawable navigationIcon)
{
if (navigationIcon is DrawerArrowDrawable dad)
- dad.Color = AGraphics.Color.White;
+ {
+ dad.Color = global::Android.Graphics.Color.White;
+ }
navigationIcon.SetColorFilter(platformColor, FilterMode.SrcAtop);
}
@@ -213,6 +217,7 @@ public static void UpdateIconColor(this AToolbar nativeToolbar, Toolbar toolbar)
public static void UpdateBarTextColor(this AToolbar nativeToolbar, Toolbar toolbar)
{
var textColor = toolbar.BarTextColor;
+ var iconColor = toolbar.IconColor;
// Because we use the same toolbar across multiple navigation pages (think tabbed page with nested NavigationPage)
// We need to reset the toolbar text color to the default color when it's unset
@@ -234,12 +239,14 @@ public static void UpdateBarTextColor(this AToolbar nativeToolbar, Toolbar toolb
if (nativeToolbar.NavigationIcon is DrawerArrowDrawable icon)
{
- if (textColor != null)
+ // IconColor is the explicit source of truth for nav icon tint when set.
+ // Only fall back to BarTextColor for icon tint when IconColor is unset.
+ if (iconColor is null && textColor != null)
{
_defaultNavigationIconColor = icon.Color;
icon.Color = textColor.ToPlatform().ToArgb();
}
- else if (_defaultNavigationIconColor != null)
+ else if (iconColor is null && _defaultNavigationIconColor != null)
{
icon.Color = _defaultNavigationIconColor.Value;
}
diff --git a/src/Controls/src/Core/Platform/Android/ITabbedViewSource.cs b/src/Controls/src/Core/Platform/Android/ITabbedViewSource.cs
new file mode 100644
index 000000000000..e47e6eab1158
--- /dev/null
+++ b/src/Controls/src/Core/Platform/Android/ITabbedViewSource.cs
@@ -0,0 +1,39 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using Microsoft.Maui.Graphics;
+
+namespace Microsoft.Maui.Controls.Handlers;
+
+///
+/// Internal interface that provides tab management data to .
+/// Contains the same tab properties as but does NOT extend ,
+/// allowing Shell adapters to implement it without 40+ IView stub members.
+///
+/// Consumers: (bottom tabs),
+/// (top tabs), and future TabbedPage adapter (Phase 3).
+///
+///
+internal interface ITabbedViewSource
+{
+ IReadOnlyList Tabs { get; }
+ ITab? CurrentTab { get; set; }
+ int CurrentTabIndex { get; }
+ Color? BarBackgroundColor { get; }
+ object? BarBackground { get; }
+ Color? BarTextColor { get; }
+ Color? UnselectedTabColor { get; }
+ Color? SelectedTabColor { get; }
+ TabBarPlacement TabBarPlacement { get; }
+ int OffscreenPageLimit { get; }
+ bool IsSwipePagingEnabled { get; }
+ bool IsSmoothScrollEnabled { get; }
+
+ ///
+ /// The visual Element that owns this tab source — used as a parent for brush
+ /// binding context resolution (e.g., AppThemeBinding on gradient stops).
+ ///
+ Element? Owner { get; }
+
+ event NotifyCollectionChangedEventHandler TabsChanged;
+}
diff --git a/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs b/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs
index 8d3bd198e3ac..d47ada5c865d 100644
--- a/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs
+++ b/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs
@@ -3,177 +3,108 @@
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
-using Android.Content;
+using System.Linq;
using Android.Content.Res;
-using Android.Graphics;
using Android.Graphics.Drawables;
-using Android.Views;
-using AndroidX.AppCompat.Widget;
-using AndroidX.CoordinatorLayout.Widget;
-using AndroidX.Core.Content;
-using AndroidX.Core.Graphics;
using AndroidX.Fragment.App;
using AndroidX.ViewPager2.Widget;
-using Google.Android.Material.AppBar;
using Google.Android.Material.BottomNavigation;
using Google.Android.Material.BottomSheet;
-using Google.Android.Material.Navigation;
using Google.Android.Material.Tabs;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Controls.Internals;
using Microsoft.Maui.Controls.Platform;
using Microsoft.Maui.Controls.PlatformConfiguration.AndroidSpecific;
-using Microsoft.Maui.Graphics;
-using AColor = Android.Graphics.Color;
-using ADrawableCompat = AndroidX.Core.Graphics.Drawable.DrawableCompat;
using AView = Android.Views.View;
using Color = Microsoft.Maui.Graphics.Color;
namespace Microsoft.Maui.Controls.Handlers;
+///
+/// Thin wrapper around for TabbedPage tab management on Android.
+/// Bridges TabbedPage-specific concerns (Page lifecycle, per-page PropertyChanged) while delegating
+/// all tab UI logic (BNV, TabLayout, fragment placement, colors, icons) to TabbedViewManager.
+///
public class TabbedPageManager
{
- Fragment _tabLayoutFragment;
- ColorStateList _originalTabTextColors;
- ColorStateList _orignalTabIconColors;
- ColorStateList _newTabTextColors;
- ColorStateList _newTabIconColors;
- FragmentManager _fragmentManager;
- TabLayout _tabLayout;
- BottomNavigationView _bottomNavigationView;
- ViewPager2 _viewPager;
- protected Page previousPage;
- int[] _checkedStateSet = null;
- int[] _selectedStateSet = null;
- int[] _emptyStateSet = null;
- int _defaultARGBColor = Colors.Transparent.ToPlatform().ToArgb();
- AColor _defaultAndroidColor = Colors.Transparent.ToPlatform();
+ #region Properties & Constructor
+
+ readonly TabbedViewManager _tabbedViewManager;
readonly IMauiContext _context;
- readonly Listeners _listeners;
+ TabbedPageTabbedViewSourceAdapter _adapter;
+
protected TabbedPage Element { get; set; }
- public TabLayout TabLayout => _tabLayout;
- public BottomNavigationView BottomNavigationView => _bottomNavigationView;
- public ViewPager2 ViewPager => _viewPager;
- int _tabplacementId;
- Brush _currentBarBackground;
- Color _currentBarItemColor;
- Color _currentBarTextColor;
- Color _currentBarSelectedItemColor;
- ColorStateList _currentBarTextColorStateList;
- bool _tabItemStyleLoaded;
- TabLayoutMediator _tabLayoutMediator;
- IDisposable _pendingFragment;
-
- protected NavigationRootManager NavigationRootManager { get; }
+ protected Page previousPage;
+
+ public TabLayout TabLayout => _tabbedViewManager.TabLayout;
+ public BottomNavigationView BottomNavigationView => _tabbedViewManager.BottomNavigationView;
+ public ViewPager2 ViewPager => _tabbedViewManager.ViewPager;
+ public bool IsBottomTabPlacement => _tabbedViewManager.IsBottomTabPlacement;
+ public Color BarItemColor => _tabbedViewManager.BarItemColor;
+ public Color BarSelectedItemColor => _tabbedViewManager.BarSelectedItemColor;
+ protected NavigationRootManager NavigationRootManager => _context.GetNavigationRootManager();
+ protected FragmentManager FragmentManager => _context.GetFragmentManager();
public static bool IsDarkTheme => (Application.Current?.RequestedTheme ?? AppInfo.RequestedTheme) == AppTheme.Dark;
public TabbedPageManager(IMauiContext context)
{
_context = context;
- _listeners = new Listeners(this);
- _viewPager = new ViewPager2(context.Context)
+ _tabbedViewManager = new TabbedViewManager(context)
{
- OverScrollMode = OverScrollMode.Never,
- LayoutParameters = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent)
- };
+ // Wire TabbedViewManager callbacks to TabbedPageManager methods
+ OnPageSelected = OnPageSelectedInternal,
+ OnMoreItemSelected = OnMoreItemSelectedInternal,
- _viewPager.RegisterOnPageChangeCallback(_listeners);
+ // Consumer provides the ViewPager2 adapter
+ CreateAdapter = (fm, ctx) =>
+ new MultiPageFragmentStateAdapter(Element, fm, ctx) { CountOverride = Element.Children.Count }
+ };
}
internal IMauiContext MauiContext => _context;
- protected FragmentManager FragmentManager => _fragmentManager ??= _context.GetFragmentManager();
- public bool IsBottomTabPlacement => (Element != null) ? Element.OnThisPlatform().GetToolbarPlacement() == ToolbarPlacement.Bottom : false;
-
- public Color BarItemColor
- {
- get
- {
- if (Element != null)
- {
- if (Element.IsSet(TabbedPage.UnselectedTabColorProperty))
- return Element.UnselectedTabColor;
- }
- return null;
- }
- }
-
- public Color BarSelectedItemColor
- {
- get
- {
- if (Element != null)
- {
- if (Element.IsSet(TabbedPage.SelectedTabColorProperty))
- return Element.SelectedTabColor;
- }
-
- return null;
- }
- }
+ #endregion
+ #region Element Lifecycle
public virtual void SetElement(TabbedPage tabbedPage)
{
- var activity = _context.GetActivity();
- var themeContext = activity;
-
if (Element is not null)
{
Element.InternalChildren.ForEach(page => TeardownPage(page as Page));
((IPageController)Element).InternalChildren.CollectionChanged -= OnChildrenCollectionChanged;
Element.Appearing -= OnTabbedPageAppearing;
Element.Disappearing -= OnTabbedPageDisappearing;
-
- RemoveTabs();
-
- _viewPager.LayoutChange -= OnLayoutChanged;
- _viewPager.Adapter = null;
+ ViewPager.LayoutChange -= OnLayoutChanged;
}
Element = tabbedPage;
-
+
if (Element is not null)
{
- _viewPager.LayoutChange += OnLayoutChanged;
+ ViewPager.LayoutChange += OnLayoutChanged;
Element.Appearing += OnTabbedPageAppearing;
Element.Disappearing += OnTabbedPageDisappearing;
- _viewPager.Adapter = new MultiPageFragmentStateAdapter(tabbedPage, FragmentManager, _context) { CountOverride = tabbedPage.Children.Count };
-
- if (IsBottomTabPlacement)
- {
- _bottomNavigationView = new BottomNavigationView(_context.Context)
- {
- LayoutParameters = new CoordinatorLayout.LayoutParams(AppBarLayout.LayoutParams.MatchParent, AppBarLayout.LayoutParams.WrapContent)
- {
- Gravity = (int)GravityFlags.Bottom
- }
- };
- }
- else
+ // Wire per-page property tracking and collection change for page lifecycle
+ // Subscribe BEFORE SetElement so CountOverride is updated before NotifyDataSetChanged
+ foreach (var page in Element.Children)
{
- if (_tabLayout == null)
- {
- var layoutInflater = Element.Handler.MauiContext.GetLayoutInflater();
- _tabLayout = new TabLayout(_context.Context)
- {
- TabMode = TabLayout.ModeFixed,
- TabGravity = TabLayout.GravityFill,
- LayoutParameters = new AppBarLayout.LayoutParams(AppBarLayout.LayoutParams.MatchParent, AppBarLayout.LayoutParams.WrapContent)
- };
- }
+ SetupPage(page);
}
- OnChildrenCollectionChanged(null, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+ ((IPageController)tabbedPage).InternalChildren.CollectionChanged += OnChildrenCollectionChanged;
- ScrollToCurrentPage();
+ // Create adapter and delegate to TabbedViewManager
+ _adapter = new TabbedPageTabbedViewSourceAdapter(Element);
+ _tabbedViewManager.SetElement(_adapter);
previousPage = tabbedPage.CurrentPage;
-
- ((IPageController)tabbedPage).InternalChildren.CollectionChanged += OnChildrenCollectionChanged;
-
- SetTabLayout();
+ }
+ else
+ {
+ _tabbedViewManager.SetElement(null);
+ _adapter = null;
}
}
@@ -182,44 +113,6 @@ protected virtual void OnLayoutChanged(object sender, AView.LayoutChangeEventArg
Element.Arrange(e);
}
- void RemoveTabs()
- {
- _pendingFragment?.Dispose();
- _pendingFragment = null;
-
- if (_tabLayoutFragment != null)
- {
- var fragment = _tabLayoutFragment;
- _tabLayoutFragment = null;
-
- var fragmentManager =
- _context
- .GetNavigationRootManager()
- .FragmentManager;
-
- if (!fragmentManager.IsDestroyed(_context?.Context))
- {
- SetContentBottomMargin(0);
-
- if (_context?.Context is Context c)
- {
- _pendingFragment =
- fragmentManager
- .RunOrWaitForResume(c, fm =>
- {
- fm
- .BeginTransaction()
- .Remove(fragment)
- .SetReorderingAllowed(true)
- .Commit();
- });
- }
- }
-
- _tabplacementId = 0;
- }
- }
-
protected virtual void OnTabbedPageDisappearing(object sender, EventArgs e)
{
// Don't remove tabs during modal navigation — tabs should remain visible so they
@@ -229,12 +122,12 @@ protected virtual void OnTabbedPageDisappearing(object sender, EventArgs e)
if (Element?.Navigation?.ModalStack?.Count > 0)
return;
- RemoveTabs();
+ _tabbedViewManager.RemoveTabs();
}
protected virtual void OnTabbedPageAppearing(object sender, EventArgs e)
{
- SetTabLayout();
+ _tabbedViewManager.SetTabLayout();
}
protected virtual void RootViewChanged(object sender, EventArgs e)
@@ -242,629 +135,204 @@ protected virtual void RootViewChanged(object sender, EventArgs e)
if (sender is NavigationRootManager rootManager)
{
rootManager.RootViewChanged -= RootViewChanged;
- SetTabLayout();
+ _tabbedViewManager.SetTabLayout();
}
}
- internal void SetTabLayout()
- {
- _pendingFragment?.Dispose();
- _pendingFragment = null;
-
- int id;
- var rootManager =
- _context.GetNavigationRootManager();
-
- _tabItemStyleLoaded = false;
- if (rootManager.RootView == null)
- {
- rootManager.RootViewChanged += RootViewChanged;
- return;
- }
-
- if (IsBottomTabPlacement)
- {
- id = Resource.Id.navigationlayout_bottomtabs;
- if (_tabplacementId == id)
- return;
+ #endregion
- SetContentBottomMargin(RuntimeFeature.IsMaterial3Enabled ? _context.Context.Resources.GetDimensionPixelSize(Resource.Dimension.m3_bottom_nav_min_height) : _context.Context.Resources.GetDimensionPixelSize(Resource.Dimension.design_bottom_navigation_height));
- }
- else
- {
- id = Resource.Id.navigationlayout_toptabs;
- if (_tabplacementId == id)
- return;
-
- SetContentBottomMargin(0);
- }
-
- if (_context?.Context is Context c)
- {
- _pendingFragment =
- rootManager
- .FragmentManager
- .RunOrWaitForResume(c, fm =>
- {
- if (IsBottomTabPlacement)
- {
- _tabLayoutFragment = new ViewFragment(BottomNavigationView);
- }
- else
- {
- _tabLayoutFragment = new ViewFragment(TabLayout);
- }
-
- _tabplacementId = id;
-
- fm
- .BeginTransactionEx()
- .ReplaceEx(id, _tabLayoutFragment)
- .SetReorderingAllowed(true)
- .Commit();
- });
- }
- }
-
- void SetContentBottomMargin(int bottomMargin)
- {
- var rootManager = _context.GetNavigationRootManager();
- var layoutContent = rootManager.RootView?.FindViewById(Resource.Id.navigationlayout_content);
- if (layoutContent != null && layoutContent.LayoutParameters is ViewGroup.MarginLayoutParams cl)
- {
- cl.BottomMargin = bottomMargin;
- }
- }
+ #region Collection & Page Lifecycle
protected virtual void OnChildrenCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
e.Apply((o, i, c) => SetupPage((Page)o), (o, i) => TeardownPage((Page)o), Reset);
- ViewPager2 pager = _viewPager;
-
- if (pager.Adapter is MultiPageFragmentStateAdapter adapter)
+ if (ViewPager.Adapter is MultiPageFragmentStateAdapter adapter)
{
adapter.CountOverride = Element.Children.Count;
}
- if (IsBottomTabPlacement)
- {
- BottomNavigationView bottomNavigationView = _bottomNavigationView;
-
- NotifyDataSetChanged();
-
- if (Element.Children.Count == 0)
- {
- bottomNavigationView.Menu.Clear();
- }
- else
- {
- SetupBottomNavigationView();
- bottomNavigationView.SetOnItemSelectedListener(_listeners);
- }
-
- UpdateIgnoreContainerAreas();
- }
- else
- {
- TabLayout tabs = _tabLayout;
-
- NotifyDataSetChanged();
- if (Element.Children.Count == 0)
- {
- tabs.RemoveAllTabs();
- tabs.SetupWithViewPager(null);
- _tabLayoutMediator?.Detach();
- _tabLayoutMediator = null;
- }
- else
- {
- if (_tabLayoutMediator == null)
- {
- _tabLayoutMediator = new TabLayoutMediator(tabs, _viewPager, _listeners);
- _tabLayoutMediator.Attach();
- }
-
- UpdateTabIcons();
-#pragma warning disable CS0618 // Type or member is obsolete
- tabs.AddOnTabSelectedListener(_listeners);
-#pragma warning restore CS0618 // Type or member is obsolete
- }
-
- UpdateIgnoreContainerAreas();
- }
+ // TabbedViewManager handles the tab UI refresh via TabsChanged event on the adapter
+ UpdateIgnoreContainerAreas();
}
protected void NotifyDataSetChanged()
{
- var adapter = _viewPager?.Adapter;
- if (adapter is not null)
- {
- var currentIndex = Element.Children.IndexOf(Element.CurrentPage);
+ _tabbedViewManager.NotifyDataSetChanged();
+ }
- // If the modification to the backing collection has changed the position of the current item
- // then we need to update the viewpager so it remains selected
- if (_viewPager.CurrentItem != currentIndex && currentIndex < Element.Children.Count && currentIndex >= 0)
- _viewPager.SetCurrentItem(currentIndex, false);
+ #endregion
- adapter.NotifyDataSetChanged();
- }
- }
+ #region Tab Selection & Navigation
protected virtual void TabSelected(TabLayout.Tab tab)
{
- if (Element == null)
+ if (Element is null)
+ {
return;
+ }
int selectedIndex = tab.Position;
+
if (Element.Children.Count > selectedIndex && selectedIndex >= 0)
+ {
Element.CurrentPage = Element.Children[selectedIndex];
+ }
SetIconColorFilter(Element.CurrentPage, tab, true);
}
+ #endregion
+
+ #region Per-Page Lifecycle
+
void TeardownPage(Page page)
{
- page.PropertyChanged -= OnPagePropertyChanged;
+ page.PropertyChanged -= OnPagePropertyChangedInternal;
}
void SetupPage(Page page)
{
- page.PropertyChanged += OnPagePropertyChanged;
+ page.PropertyChanged += OnPagePropertyChangedInternal;
}
void Reset()
{
foreach (var page in Element.Children)
+ {
SetupPage(page);
+ }
}
protected virtual void OnPagePropertyChanged(Page page, PropertyChangedEventArgs e)
{
- if (e.PropertyName == Page.TitleProperty.PropertyName)
+ if (Element is null)
{
- var index = Element.Children.IndexOf(page);
+ return;
+ }
- if (IsBottomTabPlacement)
- {
- IMenuItem tab = _bottomNavigationView.Menu.GetItem(index);
- tab.SetTitle(page.Title);
- }
- else
- {
- TabLayout.Tab tab = _tabLayout.GetTabAt(index);
- tab.SetText(page.Title);
- }
+ var index = Element.Children.IndexOf(page);
+ if (index < 0)
+ {
+ return;
+ }
+
+ if (e.PropertyName == Page.TitleProperty.PropertyName)
+ {
+ _tabbedViewManager.UpdateTabTitle(index, page.Title);
}
else if (e.PropertyName == Page.IconImageSourceProperty.PropertyName)
{
- var index = Element.Children.IndexOf(page);
- if (IsBottomTabPlacement)
- {
- var menuItem = _bottomNavigationView.Menu.GetItem(index);
- page.IconImageSource.LoadImage(
- _context,
- result =>
- {
- menuItem.SetIcon(result.Value);
- });
- SetupBottomNavigationViewIconColor(page, menuItem, index);
- }
- else
- {
- TabLayout.Tab tab = _tabLayout.GetTabAt(index);
- SetTabIconImageSource(page, tab);
- }
+ _tabbedViewManager.UpdateTabIcon(index);
}
}
- void OnPagePropertyChanged(object sender, PropertyChangedEventArgs e)
+ void OnPagePropertyChangedInternal(object sender, PropertyChangedEventArgs e)
{
OnPagePropertyChanged((Page)sender, e);
}
internal void ScrollToCurrentPage()
{
- if (Element.CurrentPage == null)
- return;
-
- // TODO MAUI
- //if (Platform != null)
- //{
- // Platform.NavAnimationInProgress = true;
- //}
-
- _viewPager.SetCurrentItem(Element.Children.IndexOf(Element.CurrentPage), Element.OnThisPlatform().IsSmoothScrollEnabled());
-
- //if (Platform != null)
- //{
- // Platform.NavAnimationInProgress = false;
- //}
+ _tabbedViewManager.ScrollToCurrentTab();
}
void UpdateIgnoreContainerAreas()
{
foreach (IPageController child in Element.Children)
+ {
child.IgnoresContainerArea = child is NavigationPage;
+ }
}
[Obsolete]
internal void UpdateOffscreenPageLimit()
{
- _viewPager.OffscreenPageLimit = Element.OnThisPlatform().OffscreenPageLimit();
+ _tabbedViewManager.UpdateOffscreenPageLimit();
}
internal void UpdateSwipePaging()
{
- _viewPager.UserInputEnabled = Element.OnThisPlatform().IsSwipePagingEnabled();
+ _tabbedViewManager.UpdateSwipePaging();
}
- List<(string title, ImageSource icon, bool tabEnabled)> CreateTabList()
- {
- var items = new List<(string title, ImageSource icon, bool tabEnabled)>();
+ #endregion
- for (int i = 0; i < Element.Children.Count; i++)
- {
- var item = Element.Children[i];
- items.Add((item.Title, item.IconImageSource, item.IsEnabled));
- }
-
- return items;
- }
+ #region Tab Appearance
protected virtual void SetupBottomNavigationView()
{
- var currentIndex = Element.Children.IndexOf(Element.CurrentPage);
- var items = CreateTabList();
-
- BottomNavigationViewUtils.SetupMenu(
- _bottomNavigationView.Menu,
- _bottomNavigationView.MaxItemCount,
- items,
- currentIndex,
- _bottomNavigationView,
- Element.FindMauiContext());
-
- if (Element.CurrentPage == null && Element.Children.Count > 0)
- Element.CurrentPage = Element.Children[0];
+ _tabbedViewManager.SetupBottomNavigationView();
}
protected virtual void UpdateTabIcons()
{
- TabLayout tabs = _tabLayout;
-
- if (tabs.TabCount != Element.Children.Count)
- return;
-
- for (var i = 0; i < Element.Children.Count; i++)
- {
- Page child = Element.Children[i];
- TabLayout.Tab tab = tabs.GetTabAt(i);
- SetTabIconImageSource(child, tab);
- }
+ _tabbedViewManager.UpdateTabIcons();
}
protected virtual void SetTabIconImageSource(Page page, TabLayout.Tab tab, Drawable icon)
{
- tab.SetIcon(icon);
- SetIconColorFilter(page, tab);
- }
-
- void SetTabIconImageSource(Page page, TabLayout.Tab tab)
- {
- page.IconImageSource.LoadImage(
- _context,
- result =>
- {
- SetTabIconImageSource(page, tab, result?.Value);
- });
- }
-
- public virtual void UpdateBarBackgroundColor()
- {
- if (Element.BarBackground != null)
- return;
+ var tabIndex = Element.Children.IndexOf(page);
+ var tabs = _adapter?.Tabs;
- if (IsBottomTabPlacement)
+ if (tabs is not null && tabIndex >= 0 && tabIndex < tabs.Count)
{
- Color tintColor = Element.BarBackgroundColor;
-
- if (tintColor == null)
- _bottomNavigationView.SetBackground(null);
- else if (tintColor != null)
- _bottomNavigationView.SetBackgroundColor(tintColor.ToPlatform());
- }
- else
- {
- Color tintColor = Element.BarBackgroundColor;
-
- if (tintColor == null)
- _tabLayout.BackgroundTintMode = null;
- else
- {
- _tabLayout.BackgroundTintMode = PorterDuff.Mode.Src;
- _tabLayout.BackgroundTintList = ColorStateList.ValueOf(tintColor.ToPlatform());
- }
+ _tabbedViewManager.SetTabIconImageSource(tabs[tabIndex], tab, icon);
}
}
- public virtual void UpdateBarBackground()
+ public virtual void UpdateBarBackgroundColor()
{
- if (_currentBarBackground == Element.BarBackground)
- return;
-
- if (_currentBarBackground is GradientBrush oldGradientBrush)
- {
- oldGradientBrush.Parent = null;
- oldGradientBrush.InvalidateGradientBrushRequested -= OnBarBackgroundChanged;
- }
-
- _currentBarBackground = Element.BarBackground;
-
- if (_currentBarBackground is GradientBrush newGradientBrush)
- {
- newGradientBrush.Parent = Element;
- newGradientBrush.InvalidateGradientBrushRequested += OnBarBackgroundChanged;
- if (_bottomNavigationView is not null && _bottomNavigationView.Elevation > 0)
- {
- _bottomNavigationView.Elevation = 0;
- }
- }
- else if (_currentBarBackground is SolidColorBrush && _bottomNavigationView is not null && _bottomNavigationView.Elevation == 0)
- {
- _bottomNavigationView.Elevation = _bottomNavigationView.Context.Resources.GetDimension(Resource.Dimension.design_bottom_navigation_elevation);
- }
-
- RefreshBarBackground();
+ _tabbedViewManager.UpdateBarBackgroundColor();
}
- void OnBarBackgroundChanged(object sender, EventArgs e)
+ public virtual void UpdateBarBackground()
{
- RefreshBarBackground();
+ _tabbedViewManager.UpdateBarBackground();
}
protected virtual void RefreshBarBackground()
{
- if (IsBottomTabPlacement)
- _bottomNavigationView.UpdateBackground(_currentBarBackground);
- else
- _tabLayout.UpdateBackground(_currentBarBackground);
+ _tabbedViewManager.RefreshBarBackground();
}
protected virtual ColorStateList GetItemTextColorStates()
{
- if (_originalTabTextColors is null)
- _originalTabTextColors = IsBottomTabPlacement ? _bottomNavigationView.ItemTextColor : _tabLayout.TabTextColors;
-
- Color barItemColor = BarItemColor;
- Color barTextColor = Element.BarTextColor;
- Color barSelectedItemColor = BarSelectedItemColor;
-
- if (barItemColor is null && barTextColor is null && barSelectedItemColor is null)
- return _originalTabTextColors;
-
- if (_newTabTextColors is not null)
- return _newTabTextColors;
-
- int checkedColor;
-
- // The new default color to use may have a color if BarItemColor is not null or the original colors for text
- // are not null either. If it does not happens, this variable will be null and the ColorStateList of the
- // original colors is used.
- int? defaultColor = null;
-
- if (barTextColor is not null)
- {
- checkedColor = barTextColor.ToPlatform().ToArgb();
- defaultColor = checkedColor;
- }
- else
- {
- // UnSelected tabs TextColor
- defaultColor = GetItemTextColor(barItemColor, _originalTabTextColors);
-
- // Selected tabs TextColor
- checkedColor = GetItemTextColor(barSelectedItemColor, _originalTabTextColors);
- }
-
- _newTabTextColors = GetColorStateList(defaultColor.Value, checkedColor);
-
- return _newTabTextColors;
- }
-
- int GetItemTextColor(Color customColor, ColorStateList originalColors)
- {
- return customColor?.ToPlatform().ToArgb() ?? originalColors?.DefaultColor ?? 0;
+ return _tabbedViewManager.GetItemTextColorStates();
}
protected virtual ColorStateList GetItemIconTintColorState(Page page)
{
- if (page.IconImageSource is FontImageSource fontImageSource && fontImageSource.Color is not null)
- {
- return null;
- }
-
- if (_orignalTabIconColors is null)
- {
- _orignalTabIconColors = IsBottomTabPlacement ? _bottomNavigationView.ItemIconTintList : _tabLayout.TabIconTint;
- }
-
- Color barItemColor = BarItemColor;
- Color barSelectedItemColor = BarSelectedItemColor;
-
- if (barItemColor is null && barSelectedItemColor is null)
- {
- return _orignalTabIconColors;
- }
-
- if (_newTabIconColors is not null)
- {
- return _newTabIconColors;
- }
-
- int defaultColor;
- int checkedColor;
-
- if (barItemColor is not null)
- {
- defaultColor = barItemColor.ToPlatform().ToArgb();
- }
- else
- {
- defaultColor = GetDefaultColor();
- }
-
- if (barSelectedItemColor is not null)
- {
- checkedColor = barSelectedItemColor.ToPlatform().ToArgb();
- }
- else
- {
- checkedColor = GetDefaultColor();
- }
-
- _newTabIconColors = GetColorStateList(defaultColor, checkedColor);
- return _newTabIconColors;
- }
-
- int GetDefaultColor()
- {
- int defaultColor;
- var styledAttributes =
- _context.Context.Theme.ObtainStyledAttributes(
- null,
- Resource.Styleable.NavigationBarView,
- Resource.Attribute.bottomNavigationStyle,
- 0);
-
- try
- {
- var defaultColors = styledAttributes.GetColorStateList(Resource.Styleable.NavigationBarView_itemIconTint);
- if (defaultColors is not null)
- {
- defaultColor = defaultColors.DefaultColor;
- }
- else
- {
- // These are the defaults currently set inside android
- // It's very unlikely we'll hit this path because the
- // NavigationBarView_itemIconTint should always resolve
- // But just in case, we'll just hard code to some defaults
- // instead of leaving the application in a broken state
- if (IsDarkTheme)
- {
- defaultColor = AndroidX.Core.Graphics.ColorUtils.SetAlphaComponent(
- ContextCompat.GetColor(_context.Context, Resource.Color.primary_dark_material_light),
- 153); // 60% opacity
- }
- else
- {
- defaultColor = AndroidX.Core.Graphics.ColorUtils.SetAlphaComponent(
- ContextCompat.GetColor(_context.Context, Resource.Color.primary_dark_material_dark),
- 153); // 60% opacity
- }
- }
- }
- finally
- {
- styledAttributes.Recycle();
- }
- return defaultColor;
+ var tabIndex = Element.Children.IndexOf(page);
+ return _tabbedViewManager.GetItemIconTintColorState(tabIndex);
}
protected virtual void OnMoreSheetDismissed(object sender, EventArgs e)
{
var index = Element.Children.IndexOf(Element.CurrentPage);
- using (var menu = _bottomNavigationView.Menu)
+
+ if (BottomNavigationView is not null)
{
- index = Math.Min(index, menu.Size() - 1);
- if (index < 0)
- return;
- using (var menuItem = menu.GetItem(index))
- menuItem.SetChecked(true);
+ _tabbedViewManager.SetSelectedTab(index);
}
if (sender is BottomSheetDialog bsd)
- bsd.DismissEvent -= OnMoreSheetDismissed;
- }
-
- protected virtual void OnMoreItemSelected(int selectedIndex, BottomSheetDialog dialog)
- {
- if (selectedIndex >= 0 && _bottomNavigationView.SelectedItemId != selectedIndex && Element.Children.Count > selectedIndex)
- Element.CurrentPage = Element.Children[selectedIndex];
-
- dialog.Dismiss();
- dialog.DismissEvent -= OnMoreSheetDismissed;
- dialog.Dispose();
- }
-
- void UpdateItemIconColor()
- {
- _newTabIconColors = null;
-
- if (IsBottomTabPlacement)
- {
- for (int i = 0; i < _bottomNavigationView.Menu.Size(); i++)
- {
- var menuItem = _bottomNavigationView.Menu.GetItem(i);
- var page = Element.Children[i];
- SetupBottomNavigationViewIconColor(page, menuItem, i);
- }
- }
- else
{
- for (int i = 0; i < _tabLayout.TabCount; i++)
- {
- TabLayout.Tab tab = _tabLayout.GetTabAt(i);
- var page = Element.Children[i];
- this.SetIconColorFilter(page, tab);
- }
+ bsd.DismissEvent -= OnMoreSheetDismissed;
}
}
- void SetupBottomNavigationViewIconColor(Page page, IMenuItem menuItem, int i)
+ protected virtual void OnMoreItemSelected(int selectedIndex, BottomSheetDialog dialog)
{
- // Updating the icon color of each BottomNavigationView item individually works correctly.
- // This is necessary because `ItemIconTintList` applies the color globally to all items,
- // which doesn't allow for per-item customization.
- // Currently, there is no modern API that provides the desired behavior.
- // Therefore, the obsolete `BottomNavigationItemView` approach is used.
-#pragma warning disable XAOBS001 // Type or member is obsolete
- if (_bottomNavigationView.GetChildAt(0) is BottomNavigationMenuView menuView)
+ if (selectedIndex >= 0 && BottomNavigationView?.SelectedItemId != selectedIndex && Element.Children.Count > selectedIndex)
{
- var itemView = menuView.GetChildAt(i) as BottomNavigationItemView;
-
- if (itemView != null && itemView.Id == menuItem.ItemId)
- {
- ColorStateList colors = GetItemIconTintColorState(page);
-
- itemView.SetIconTintList(colors);
- }
+ Element.CurrentPage = Element.Children[selectedIndex];
}
-#pragma warning restore XAOBS001 // Type or member is obsolete
}
protected virtual void UpdateStyleForTabItem()
{
- Color barItemColor = BarItemColor;
- Color barTextColor = Element.BarTextColor;
- Color barSelectedItemColor = BarSelectedItemColor;
-
- if (_tabItemStyleLoaded &&
- _currentBarItemColor == barItemColor &&
- _currentBarTextColor == barTextColor &&
- _currentBarSelectedItemColor == barSelectedItemColor)
- {
- return;
- }
-
- _tabItemStyleLoaded = true;
- _currentBarItemColor = BarItemColor;
- _currentBarTextColor = Element.BarTextColor;
- _currentBarSelectedItemColor = BarSelectedItemColor;
-
- UpdateBarTextColor();
- UpdateItemIconColor();
+ _tabbedViewManager.UpdateStyleForTabItem();
}
internal void UpdateTabItemStyle()
@@ -872,212 +340,110 @@ internal void UpdateTabItemStyle()
UpdateStyleForTabItem();
}
- void UpdateBarTextColor()
- {
- _newTabTextColors = null;
-
- _currentBarTextColorStateList = GetItemTextColorStates() ?? _originalTabTextColors;
- if (IsBottomTabPlacement)
- _bottomNavigationView.ItemTextColor = _currentBarTextColorStateList;
- else
- _tabLayout.TabTextColors = _currentBarTextColorStateList;
- }
-
- void SetIconColorFilter(Page page, TabLayout.Tab tab)
- {
- SetIconColorFilter(page, tab, _tabLayout.GetTabAt(_tabLayout.SelectedTabPosition) == tab);
- }
-
protected virtual void SetIconColorFilter(Page page, TabLayout.Tab tab, bool selected)
{
- var icon = tab.Icon;
- if (icon == null)
- return;
-
- ColorStateList colors = GetItemIconTintColorState(page);
- if (colors == null)
- ADrawableCompat.SetTintList(icon, null);
- else
- {
- int[] _stateSet = null;
+ var tabIndex = Element.Children.IndexOf(page);
+ _tabbedViewManager.SetIconColorFilter(tabIndex, tab, selected);
+ }
- if (selected)
- _stateSet = GetSelectedStateSet();
- else
- _stateSet = GetEmptyStateSet();
+ #endregion
- if (colors.GetColorForState(_stateSet, _defaultAndroidColor) == _defaultARGBColor)
- ADrawableCompat.SetTintList(icon, null);
- else
- {
- var wrappedIcon = ADrawableCompat.Wrap(icon);
- if (wrappedIcon != icon)
- {
- icon = wrappedIcon;
- tab.SetIcon(wrappedIcon);
- }
-
- icon.Mutate();
- icon.SetState(_stateSet);
-
- // The FontImageSource has its own color, so we don't need to apply the tint list.
- if (page.IconImageSource is not FontImageSource)
- {
- _tabLayout.TabIconTint = colors;
- }
-
- ADrawableCompat.SetTintList(icon, colors);
- }
- }
- icon.InvalidateSelf();
- }
+ #region VP2 Page Change Callbacks
- int[] GetSelectedStateSet()
+ void OnPageSelectedInternal(int position)
{
- if (IsBottomTabPlacement)
+ if (Element is null)
{
- if (_checkedStateSet == null)
- _checkedStateSet = new int[] { global::Android.Resource.Attribute.StateChecked };
-
- return _checkedStateSet;
+ return;
}
- else
- {
- if (_selectedStateSet == null)
- _selectedStateSet = GetStateSet(new TempView(_context.Context).SelectedStateSet);
- return _selectedStateSet;
+ if (previousPage != Element.CurrentPage)
+ {
+ previousPage?.SendDisappearing();
+ previousPage = Element.CurrentPage;
}
- }
-
- int[] GetEmptyStateSet()
- {
- if (_emptyStateSet == null)
- _emptyStateSet = GetStateSet(new TempView(_context.Context).EmptyStateSet);
-
- return _emptyStateSet;
- }
- class TempView : AView
- {
- // These are protected static so need to be inside a View Instance to retrieve these
- public new IList EmptyStateSet => AView.EmptyStateSet;
- public new IList SelectedStateSet => AView.SelectedStateSet;
- public TempView(Context context) : base(context)
+ if (Element.Children.Count > 0 && position < Element.Children.Count)
{
+ Element.CurrentPage = Element.Children[position];
+ Element.CurrentPage.SendAppearing();
}
}
- int[] GetStateSet(IList stateSet)
+ void OnMoreItemSelectedInternal(int selectedIndex, BottomSheetDialog dialog)
{
- var results = new int[stateSet.Count];
- for (int i = 0; i < results.Length; i++)
- results[i] = stateSet[i];
-
- return results;
+ OnMoreItemSelected(selectedIndex, dialog);
}
- ColorStateList GetColorStateList(int defaultColor, int checkedColor)
- {
- int[][] states = new int[2][];
- int[] colors = new int[2];
-
- states[0] = GetSelectedStateSet();
- colors[0] = checkedColor;
- states[1] = GetEmptyStateSet();
- colors[1] = defaultColor;
-
-#pragma warning disable RS0030
- //TODO: port this usage to Java, if this becomes a performance concern
- return new ColorStateList(states, colors);
-#pragma warning restore RS0030
- }
+ #endregion
- class Listeners : ViewPager2.OnPageChangeCallback,
-#pragma warning disable CS0618 // Type or member is obsolete
- TabLayout.IOnTabSelectedListener,
-#pragma warning restore CS0618 // Type or member is obsolete
- NavigationBarView.IOnItemSelectedListener,
- TabLayoutMediator.ITabConfigurationStrategy
+ #region TabbedPageTabbedViewSourceAdapter
+
+ ///
+ /// Adapter that bridges TabbedPage to ITabbedViewSource for TabbedViewManager consumption.
+ ///
+ sealed class TabbedPageTabbedViewSourceAdapter : ITabbedViewSource
{
- readonly TabbedPageManager _tabbedPageManager;
+ readonly TabbedPage _tabbedPage;
- public Listeners(TabbedPageManager tabbedPageManager)
+ public TabbedPageTabbedViewSourceAdapter(TabbedPage tabbedPage)
{
- _tabbedPageManager = tabbedPageManager;
+ _tabbedPage = tabbedPage;
}
- public override void OnPageSelected(int position)
- {
- base.OnPageSelected(position);
-
- var Element = _tabbedPageManager.Element;
-
- if (Element == null)
- return;
-
- var _previousPage = _tabbedPageManager.previousPage;
- var IsBottomTabPlacement = _tabbedPageManager.IsBottomTabPlacement;
- var _bottomNavigationView = _tabbedPageManager._bottomNavigationView;
-
- if (_previousPage != Element.CurrentPage)
- {
- _previousPage?.SendDisappearing();
- _previousPage = Element.CurrentPage;
- _tabbedPageManager.previousPage = Element.CurrentPage;
- }
+ public IReadOnlyList Tabs =>
+ _tabbedPage.Children.Select(p => (ITab)new TabbedPage.PageTabAdapter(p)).ToList();
- // This only happens if all the pages have been removed
- if (Element.Children.Count > 0)
+ public ITab CurrentTab
+ {
+ get => _tabbedPage.CurrentPage is not null ? new TabbedPage.PageTabAdapter(_tabbedPage.CurrentPage) : null;
+ set
{
- Element.CurrentPage = Element.Children[position];
- Element.CurrentPage.SendAppearing();
+ if (value is TabbedPage.PageTabAdapter adapter)
+ _tabbedPage.CurrentPage = adapter.Page;
}
-
- if (IsBottomTabPlacement)
- _bottomNavigationView.SelectedItemId = position;
}
- void TabLayoutMediator.ITabConfigurationStrategy.OnConfigureTab(TabLayout.Tab p0, int p1)
- {
- p0.SetText(_tabbedPageManager.Element.Children[p1].Title);
- }
+ public int CurrentTabIndex =>
+ _tabbedPage.CurrentPage is not null
+ ? _tabbedPage.Children.IndexOf(_tabbedPage.CurrentPage)
+ : -1;
- bool NavigationBarView.IOnItemSelectedListener.OnNavigationItemSelected(IMenuItem item)
- {
- if (_tabbedPageManager.Element == null)
- return false;
+ public Color BarBackgroundColor => _tabbedPage.BarBackgroundColor;
+ public object BarBackground => _tabbedPage.BarBackground;
+ public Color BarTextColor => _tabbedPage.BarTextColor;
- var id = item.ItemId;
- if (id == BottomNavigationViewUtils.MoreTabId)
- {
- var items = _tabbedPageManager.CreateTabList();
- var bottomSheetDialog = BottomNavigationViewUtils.CreateMoreBottomSheet(_tabbedPageManager.OnMoreItemSelected, _tabbedPageManager.Element.FindMauiContext(), items, _tabbedPageManager._bottomNavigationView.MaxItemCount);
- bottomSheetDialog.DismissEvent += _tabbedPageManager.OnMoreSheetDismissed;
- bottomSheetDialog.Show();
- }
- else
- {
- if (_tabbedPageManager._bottomNavigationView.SelectedItemId != item.ItemId && _tabbedPageManager.Element.Children.Count > item.ItemId)
- _tabbedPageManager.Element.CurrentPage = _tabbedPageManager.Element.Children[item.ItemId];
- }
+ public Color UnselectedTabColor =>
+ _tabbedPage.IsSet(TabbedPage.UnselectedTabColorProperty)
+ ? _tabbedPage.UnselectedTabColor
+ : null;
- return true;
- }
+ public Color SelectedTabColor =>
+ _tabbedPage.IsSet(TabbedPage.SelectedTabColorProperty)
+ ? _tabbedPage.SelectedTabColor
+ : null;
+ public TabBarPlacement TabBarPlacement =>
+ _tabbedPage.OnThisPlatform().GetToolbarPlacement() == ToolbarPlacement.Bottom
+ ? TabBarPlacement.Bottom
+ : TabBarPlacement.Top;
- void TabLayout.IOnTabSelectedListener.OnTabReselected(TabLayout.Tab tab)
- {
- }
+ public int OffscreenPageLimit =>
+#pragma warning disable CS0618 // Type or member is obsolete
+ _tabbedPage.OnThisPlatform().OffscreenPageLimit();
+#pragma warning restore CS0618
- void TabLayout.IOnTabSelectedListener.OnTabSelected(TabLayout.Tab tab)
- {
- _tabbedPageManager.TabSelected(tab);
- }
+ public bool IsSwipePagingEnabled => _tabbedPage.OnThisPlatform().IsSwipePagingEnabled();
+ public bool IsSmoothScrollEnabled => _tabbedPage.OnThisPlatform().IsSmoothScrollEnabled();
- void TabLayout.IOnTabSelectedListener.OnTabUnselected(TabLayout.Tab tab)
+ public Element Owner => _tabbedPage;
+
+ public event NotifyCollectionChangedEventHandler TabsChanged
{
- _tabbedPageManager.SetIconColorFilter(_tabbedPageManager.Element.CurrentPage, tab, false);
+ add => ((IPageController)_tabbedPage).InternalChildren.CollectionChanged += value;
+ remove => ((IPageController)_tabbedPage).InternalChildren.CollectionChanged -= value;
}
}
+
+ #endregion
}
\ No newline at end of file
diff --git a/src/Controls/src/Core/Platform/Android/TabbedViewManager.cs b/src/Controls/src/Core/Platform/Android/TabbedViewManager.cs
new file mode 100644
index 000000000000..99e5c811c2e3
--- /dev/null
+++ b/src/Controls/src/Core/Platform/Android/TabbedViewManager.cs
@@ -0,0 +1,1491 @@
+#nullable disable
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using Android.Content;
+using Android.Content.Res;
+using Android.Graphics;
+using Android.Graphics.Drawables;
+using Android.Views;
+using AndroidX.CoordinatorLayout.Widget;
+using AndroidX.Core.Content;
+using AndroidX.Fragment.App;
+using AndroidX.RecyclerView.Widget;
+using AndroidX.ViewPager2.Widget;
+using Google.Android.Material.AppBar;
+using Google.Android.Material.BottomNavigation;
+using Google.Android.Material.BottomSheet;
+using Google.Android.Material.Navigation;
+using Google.Android.Material.Tabs;
+using Microsoft.Maui.ApplicationModel;
+using Microsoft.Maui.Controls.Platform;
+using Microsoft.Maui.Graphics;
+using AColor = Android.Graphics.Color;
+using ADrawableCompat = AndroidX.Core.Graphics.Drawable.DrawableCompat;
+using AView = Android.Views.View;
+using Color = Microsoft.Maui.Graphics.Color;
+
+namespace Microsoft.Maui.Controls.Handlers;
+
+///
+/// Manages tabbed view UI on Android — handles ViewPager2, BottomNavigationView, TabLayout,
+/// fragment placement, and tab appearance. Works against so it can be
+/// used by TabbedPageManager, ShellItemHandler (bottom tabs), and ShellSectionHandler (top tabs).
+///
+internal class TabbedViewManager
+{
+
+ Fragment _tabLayoutFragment;
+ ColorStateList _originalTabTextColors;
+ ColorStateList _originalTabIconColors;
+ ColorStateList _newTabTextColors;
+ ColorStateList _newTabIconColors;
+ FragmentManager _fragmentManager;
+ TabLayout _tabLayout;
+ BottomNavigationView _bottomNavigationView;
+ ViewPager2 _viewPager;
+ int _previousTabIndex = -1;
+ int[] _checkedStateSet = null;
+ int[] _selectedStateSet = null;
+ int[] _emptyStateSet = null;
+ int _defaultARGBColor = Colors.Transparent.ToPlatform().ToArgb();
+ AColor _defaultAndroidColor = Colors.Transparent.ToPlatform();
+ readonly IMauiContext _context;
+ readonly Listeners _listeners;
+ protected ITabbedViewSource Element { get; set; }
+
+ ///
+ /// Gets or sets the TabLayout used for top tabs.
+ /// Set this before calling to provide a pre-configured TabLayout.
+ /// If not set, creates a default TabLayout for top tab placement.
+ ///
+ public TabLayout TabLayout
+ {
+ get => _tabLayout;
+ set => _tabLayout = value;
+ }
+
+ public BottomNavigationView BottomNavigationView => _bottomNavigationView;
+ public ViewPager2 ViewPager => _viewPager;
+ int _tabplacementId;
+ Brush _currentBarBackground;
+ Color _currentBarItemColor;
+ Color _currentBarTextColor;
+ Color _currentBarSelectedItemColor;
+ ColorStateList _currentBarTextColorStateList;
+ bool _tabItemStyleLoaded;
+ TabLayoutMediator _tabLayoutMediator;
+ IDisposable _pendingFragment;
+
+ ///
+ /// Callback invoked when a tab is selected. The consumer (Shell, TabbedPageManager)
+ /// handles the actual navigation/selection.
+ ///
+ public Action OnTabSelected { get; set; }
+
+ ///
+ /// Callback invoked when the ViewPager2 page changes (e.g., via swipe).
+ ///
+ public Action OnPageSelected { get; set; }
+
+ ///
+ /// Callback invoked when the "More" overflow item is selected.
+ ///
+ public Action OnMoreItemSelected { get; set; }
+
+ ///
+ /// Delegate for creating the ViewPager2 adapter. Consumers provide their own adapter
+ /// (e.g., MultiPageFragmentStateAdapter for TabbedPage, ShellSectionFragmentAdapter for Shell).
+ ///
+ public Func CreateAdapter { get; set; }
+
+ ///
+ /// Callback invoked when an already-selected tab is tapped again. Consumers can use this
+ /// to implement scroll-to-top or refresh behavior (matching legacy Shell OnTabReselected).
+ ///
+ public Action OnTabReselected { get; set; }
+
+ ///
+ /// Delegate for creating a custom "More" bottom sheet when 5+ tabs overflow.
+ /// Receives the select callback and tab items list; returns the configured BottomSheetDialog.
+ /// If null, the default BottomNavigationViewUtils.CreateMoreBottomSheet is used.
+ ///
+ public Func, List<(string title, ImageSource icon, bool tabEnabled)>, BottomSheetDialog> CreateMoreBottomSheet { get; set; }
+
+ protected NavigationRootManager NavigationRootManager { get; }
+ public static bool IsDarkTheme => (Application.Current?.RequestedTheme ?? AppInfo.RequestedTheme) == AppTheme.Dark;
+
+ #region Static Factory Methods
+
+ static BottomNavigationView CreateBottomNavigationView(
+ Context context,
+ int defStyleAttr = 0,
+ ViewGroup.LayoutParams layoutParams = null)
+ {
+ var bottomNav = defStyleAttr != 0
+ ? new BottomNavigationView(context, null, defStyleAttr)
+ : new BottomNavigationView(context);
+
+ bottomNav.LayoutParameters = layoutParams ?? new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MatchParent,
+ ViewGroup.LayoutParams.WrapContent);
+ bottomNav.LabelVisibilityMode = Google.Android.Material.BottomNavigation.LabelVisibilityMode.LabelVisibilityLabeled;
+
+ return bottomNav;
+ }
+
+ static ViewPager2 CreateViewPager2(
+ Context context,
+ ViewGroup.LayoutParams layoutParams = null)
+ {
+ return new ViewPager2(context)
+ {
+ OverScrollMode = OverScrollMode.Never,
+ LayoutParameters = layoutParams ?? new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MatchParent,
+ ViewGroup.LayoutParams.MatchParent)
+ };
+ }
+
+ static int GetDefaultColorFromTheme(Context context)
+ {
+ if (context?.Theme is null)
+ {
+ return IsDarkTheme ? unchecked((int)0xB3FFFFFF) : unchecked((int)0x99000000);
+ }
+
+ var styledAttributes = context.Theme.ObtainStyledAttributes(
+ null,
+ Resource.Styleable.NavigationBarView,
+ Resource.Attribute.bottomNavigationStyle,
+ 0);
+
+ try
+ {
+ var defaultColors = styledAttributes.GetColorStateList(
+ Resource.Styleable.NavigationBarView_itemIconTint);
+
+ if (defaultColors is not null)
+ {
+ return defaultColors.DefaultColor;
+ }
+
+ if (IsDarkTheme)
+ {
+ return AndroidX.Core.Graphics.ColorUtils.SetAlphaComponent(
+ ContextCompat.GetColor(context, Resource.Color.primary_dark_material_light),
+ 153);
+ }
+ else
+ {
+ return AndroidX.Core.Graphics.ColorUtils.SetAlphaComponent(
+ ContextCompat.GetColor(context, Resource.Color.primary_dark_material_dark),
+ 153);
+ }
+ }
+ finally
+ {
+ styledAttributes.Recycle();
+ }
+ }
+
+ #endregion
+
+ // When false, TabbedViewManager does not control the ViewPager2 adapter, position,
+ // offscreen limit, or swipe paging. Used by Shell handlers that manage their own VP2.
+ readonly bool _managesViewPager;
+
+ public TabbedViewManager(IMauiContext context)
+ {
+ _context = context;
+ _listeners = new Listeners(this);
+ _managesViewPager = true;
+ _viewPager = CreateViewPager2(context.Context);
+
+ _viewPager.RegisterOnPageChangeCallback(_listeners);
+ }
+
+ ///
+ /// Creates a TabbedViewManager that uses an external ViewPager2.
+ /// The consumer manages the VP2 adapter, position, and lifecycle.
+ /// TabbedViewManager manages BNV/TabLayout creation, fragment placement, and tab appearance.
+ /// The VP2 reference is used for TabLayoutMediator (top tabs) only.
+ ///
+ public TabbedViewManager(IMauiContext context, ViewPager2 externalViewPager)
+ {
+ _context = context;
+ _listeners = new Listeners(this);
+ _viewPager = externalViewPager;
+ _managesViewPager = false;
+ // Do NOT register page change callback — consumer manages VP2 callbacks
+ }
+
+ internal IMauiContext MauiContext => _context;
+ protected FragmentManager FragmentManager => _fragmentManager ??= _context.GetFragmentManager();
+ public bool IsBottomTabPlacement => Element?.TabBarPlacement == TabBarPlacement.Bottom;
+
+ public Color BarItemColor => Element?.UnselectedTabColor;
+
+ public Color BarSelectedItemColor => Element?.SelectedTabColor;
+
+ public virtual void SetElement(ITabbedViewSource tabbedView)
+ {
+ if (Element is not null)
+ {
+ Element.TabsChanged -= OnTabsCollectionChanged;
+ RemoveTabs();
+
+ // Detach TabLayoutMediator and remove tab listener during full cleanup only.
+ // This prevents stale callbacks after the handler is disconnected.
+ // Must NOT be in RemoveTabs() because that's also called during runtime
+ // tab hide/show (e.g., navigation push hides top tabs).
+ _tabLayoutMediator?.Detach();
+ _tabLayoutMediator = null;
+
+ if (_tabLayout is not null)
+ {
+#pragma warning disable CS0618 // Type or member is obsolete
+ _tabLayout.RemoveOnTabSelectedListener(_listeners);
+#pragma warning restore CS0618 // Type or member is obsolete
+ }
+
+ if (_managesViewPager)
+ {
+ _viewPager.UnregisterOnPageChangeCallback(_listeners);
+ _viewPager.Adapter = null;
+ }
+ }
+
+ Element = tabbedView;
+
+ if (Element is not null)
+ {
+ // Let consumer provide the ViewPager2 adapter (only when managing VP2)
+ if (_managesViewPager && CreateAdapter is not null)
+ {
+ _viewPager.Adapter = CreateAdapter(FragmentManager, _context);
+ }
+
+ if (IsBottomTabPlacement)
+ {
+ // Shell (external VP2) uses bottomNavigationViewStyle for M3 active indicator;
+ // TabbedPage (managed VP2) uses the default constructor to match legacy behavior.
+ int styleAttr = _managesViewPager ? 0 : Resource.Attribute.bottomNavigationViewStyle;
+ _bottomNavigationView = CreateBottomNavigationView(
+ _context.Context,
+ styleAttr,
+ new CoordinatorLayout.LayoutParams(AppBarLayout.LayoutParams.MatchParent, AppBarLayout.LayoutParams.WrapContent)
+ {
+ Gravity = (int)GravityFlags.Bottom
+ });
+
+ }
+ else
+ {
+ if (_tabLayout is null)
+ {
+ _tabLayout = new TabLayout(_context.Context)
+ {
+ TabMode = TabLayout.ModeFixed,
+ TabGravity = TabLayout.GravityFill,
+ LayoutParameters = new AppBarLayout.LayoutParams(AppBarLayout.LayoutParams.MatchParent, AppBarLayout.LayoutParams.WrapContent)
+ };
+ }
+ }
+
+ OnTabsCollectionChanged(null, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+
+ if (_managesViewPager)
+ {
+ ScrollToCurrentTab();
+ }
+
+ if (Element.CurrentTab is not null)
+ {
+ _previousTabIndex = Element.CurrentTabIndex;
+ }
+
+ Element.TabsChanged += OnTabsCollectionChanged;
+
+ if (_managesViewPager)
+ {
+ // Unregister first to ensure single registration.
+ _viewPager.UnregisterOnPageChangeCallback(_listeners);
+ _viewPager.RegisterOnPageChangeCallback(_listeners);
+ SetTabLayout();
+ }
+ }
+ }
+
+ public void RemoveTabs()
+ {
+ _pendingFragment?.Dispose();
+ _pendingFragment = null;
+
+ if (_tabLayoutFragment is not null)
+ {
+ var fragment = _tabLayoutFragment;
+ _tabLayoutFragment = null;
+
+ var fragmentManager =
+ _context
+ .GetNavigationRootManager()
+ .FragmentManager;
+
+ if (!fragmentManager.IsDestroyed(_context?.Context))
+ {
+ SetContentBottomMargin(0);
+
+ if (_context?.Context is Context c)
+ {
+ _pendingFragment =
+ fragmentManager
+ .RunOrWaitForResume(c, fm =>
+ {
+ fm
+ .BeginTransaction()
+ .Remove(fragment)
+ .SetReorderingAllowed(true)
+ .Commit();
+ });
+ }
+ }
+
+ _tabplacementId = 0;
+ }
+ }
+
+ protected virtual void RootViewChanged(object sender, EventArgs e)
+ {
+ if (sender is NavigationRootManager rootManager)
+ {
+ rootManager.RootViewChanged -= RootViewChanged;
+ SetTabLayout();
+ }
+ }
+
+ public void SetTabLayout()
+ {
+ _pendingFragment?.Dispose();
+ _pendingFragment = null;
+
+ int id;
+ var rootManager = _context.GetNavigationRootManager();
+
+ _tabItemStyleLoaded = false;
+
+ if (rootManager.RootView is null)
+ {
+ rootManager.RootViewChanged += RootViewChanged;
+ return;
+ }
+
+ if (IsBottomTabPlacement)
+ {
+ id = Resource.Id.navigationlayout_bottomtabs;
+
+ if (_tabplacementId == id)
+ {
+ return;
+ }
+
+ SetContentBottomMargin(RuntimeFeature.IsMaterial3Enabled
+ ? _context.Context.Resources.GetDimensionPixelSize(Resource.Dimension.m3_bottom_nav_min_height)
+ : _context.Context.Resources.GetDimensionPixelSize(Resource.Dimension.design_bottom_navigation_height));
+ }
+ else
+ {
+ id = Resource.Id.navigationlayout_toptabs;
+
+ if (_tabplacementId == id)
+ {
+ return;
+ }
+
+ SetContentBottomMargin(0);
+ }
+
+ if (_context?.Context is Context c)
+ {
+ _pendingFragment =
+ rootManager
+ .FragmentManager
+ .RunOrWaitForResume(c, fm =>
+ {
+ if (IsBottomTabPlacement)
+ {
+ // Detach from old parent before wrapping in new ViewFragment.
+ // During clear+recreate, the old ViewFragment removal may not have
+ // completed yet, so the view still has a parent — causing
+ // "The specified child already has a parent" crash.
+ if (BottomNavigationView?.Parent is ViewGroup bnvParent)
+ {
+ bnvParent.RemoveView(BottomNavigationView);
+ }
+
+ _tabLayoutFragment = new ViewFragment(BottomNavigationView);
+ }
+ else
+ {
+ if (TabLayout?.Parent is ViewGroup tabParent)
+ {
+ tabParent.RemoveView(TabLayout);
+ }
+
+ _tabLayoutFragment = new ViewFragment(TabLayout);
+ }
+
+ _tabplacementId = id;
+
+ fm
+ .BeginTransactionEx()
+ .ReplaceEx(id, _tabLayoutFragment)
+ .SetReorderingAllowed(true)
+ .Commit();
+ });
+ }
+ }
+
+ public void SetContentBottomMargin(int bottomMargin)
+ {
+ var rootManager = _context.GetNavigationRootManager();
+ var layoutContent = rootManager.RootView?.FindViewById(Resource.Id.navigationlayout_content);
+
+ if (layoutContent is not null && layoutContent.LayoutParameters is ViewGroup.MarginLayoutParams cl)
+ {
+ cl.BottomMargin = bottomMargin;
+ }
+ }
+
+ protected virtual void OnTabsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (IsBottomTabPlacement)
+ {
+ BottomNavigationView bottomNavigationView = _bottomNavigationView;
+
+ if (_managesViewPager)
+ {
+ NotifyDataSetChanged();
+ }
+
+ if (Element.Tabs.Count == 0)
+ {
+ bottomNavigationView.Menu.Clear();
+ _bottomNavigationView?.SetOnItemSelectedListener(null);
+ _bottomNavigationView?.SetOnItemReselectedListener(null);
+ }
+ else
+ {
+ SetupBottomNavigationView();
+ }
+ }
+ else
+ {
+ TabLayout tabs = _tabLayout;
+
+ if (_managesViewPager)
+ {
+ NotifyDataSetChanged();
+ }
+
+ if (Element.Tabs.Count == 0)
+ {
+ tabs.RemoveAllTabs();
+ tabs.SetupWithViewPager(null);
+ _tabLayoutMediator?.Detach();
+ _tabLayoutMediator = null;
+ }
+ else
+ {
+ if (_tabLayoutMediator is null && _viewPager is not null)
+ {
+ _tabLayoutMediator = new TabLayoutMediator(tabs, _viewPager, _listeners);
+ _tabLayoutMediator.Attach();
+ }
+
+ UpdateTabIcons();
+#pragma warning disable CS0618 // Type or member is obsolete
+ tabs.RemoveOnTabSelectedListener(_listeners);
+ tabs.AddOnTabSelectedListener(_listeners);
+#pragma warning restore CS0618 // Type or member is obsolete
+ }
+ }
+
+ }
+
+ internal void NotifyDataSetChanged()
+ {
+ if (!_managesViewPager)
+ {
+ return;
+ }
+
+ var adapter = _viewPager?.Adapter;
+
+ if (adapter is not null)
+ {
+ var currentIndex = Element.CurrentTabIndex;
+
+ if (_viewPager.CurrentItem != currentIndex && currentIndex < Element.Tabs.Count && currentIndex >= 0)
+ {
+ _viewPager.SetCurrentItem(currentIndex, false);
+ }
+
+ adapter.NotifyDataSetChanged();
+ }
+ }
+
+ ///
+ /// Programmatically updates the selected tab in the BNV or TabLayout.
+ /// Used by Shell handlers when switching sections/contents programmatically.
+ ///
+ public void SetSelectedTab(int index)
+ {
+ if (IsBottomTabPlacement)
+ {
+ if (_bottomNavigationView is not null && index >= 0)
+ {
+ // Use SetChecked (not SelectedItemId) to update the visual highlight
+ // without triggering OnNavigationItemSelected. This matches the legacy
+ // ShellItemRenderer pattern.
+ var menu = _bottomNavigationView.Menu;
+ if (menu is not null && menu.Size() > 0)
+ {
+ var clampedIndex = Math.Min(index, menu.Size() - 1);
+ menu.GetItem(clampedIndex)?.SetChecked(true);
+ }
+ }
+ }
+ else if (_tabLayout is not null && index >= 0 && index < _tabLayout.TabCount)
+ {
+ _tabLayout.SelectTab(_tabLayout.GetTabAt(index));
+ }
+ }
+
+ ///
+ /// Triggers a full refresh of tab items from the collection.
+ /// Use only for structural changes (add/remove tabs). For individual property changes,
+ /// use , , or .
+ ///
+ public void RefreshTabs()
+ {
+ OnTabsCollectionChanged(null, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+ }
+
+ ///
+ /// Updates the title of a specific tab in-place without rebuilding the menu.
+ /// Matches the approach used by the old ShellItemRenderer and TabbedPageRenderer.
+ ///
+ public void UpdateTabTitle(int index, string title)
+ {
+ if (Element is null || index < 0 || index >= Element.Tabs.Count)
+ {
+ return;
+ }
+
+ if (IsBottomTabPlacement)
+ {
+ if (_bottomNavigationView is null)
+ {
+ return;
+ }
+
+ var menuItem = _bottomNavigationView.Menu.FindItem(index);
+ if (menuItem is not null)
+ {
+ BottomNavigationViewUtils.SetMenuItemTitle(menuItem, title);
+ }
+ }
+ else
+ {
+ if (_tabLayout is null)
+ {
+ return;
+ }
+
+ var tab = _tabLayout.GetTabAt(index);
+ tab?.SetText(title);
+ }
+ }
+
+ ///
+ /// Updates the icon of a specific tab in-place without rebuilding the menu.
+ ///
+ public void UpdateTabIcon(int index)
+ {
+ if (Element is null || index < 0 || index >= Element.Tabs.Count)
+ {
+ return;
+ }
+
+ var tab = Element.Tabs[index];
+
+ if (IsBottomTabPlacement)
+ {
+ if (_bottomNavigationView is null)
+ {
+ return;
+ }
+
+ var menuItem = _bottomNavigationView.Menu.FindItem(index);
+ if (menuItem is not null)
+ {
+ LoadBottomNavIconAsync(menuItem, tab);
+ }
+ }
+ else
+ {
+ if (_tabLayout is null)
+ {
+ return;
+ }
+
+ var tabLayoutTab = _tabLayout.GetTabAt(index);
+ if (tabLayoutTab is not null)
+ {
+ SetTabIconImageSource(tab, tabLayoutTab);
+ }
+ }
+ }
+
+ ///
+ /// Updates the enabled state of a specific tab in-place without rebuilding the menu.
+ ///
+ public void UpdateTabEnabled(int index, bool enabled)
+ {
+ if (Element is null || index < 0 || index >= Element.Tabs.Count)
+ {
+ return;
+ }
+
+ if (IsBottomTabPlacement)
+ {
+ if (_bottomNavigationView is null)
+ {
+ return;
+ }
+
+ var menuItem = _bottomNavigationView.Menu.FindItem(index);
+ menuItem?.SetEnabled(enabled);
+ }
+ }
+
+ protected virtual void TabSelected(TabLayout.Tab tab)
+ {
+ if (Element is null)
+ {
+ return;
+ }
+
+ int selectedIndex = tab.Position;
+
+ if (Element.Tabs.Count > selectedIndex && selectedIndex >= 0)
+ {
+ Element.CurrentTab = Element.Tabs[selectedIndex];
+ }
+
+ SetIconColorFilter(selectedIndex, tab, true);
+
+ OnTabSelected?.Invoke(selectedIndex);
+ }
+
+ internal void ScrollToCurrentTab()
+ {
+ if (!_managesViewPager || Element?.CurrentTab is null)
+ {
+ return;
+ }
+
+ var currentIndex = Element.CurrentTabIndex;
+
+ if (currentIndex >= 0)
+ {
+ _viewPager.SetCurrentItem(currentIndex, Element.IsSmoothScrollEnabled);
+ }
+ }
+
+ internal void UpdateOffscreenPageLimit()
+ {
+ if (!_managesViewPager)
+ {
+ return;
+ }
+
+ _viewPager.OffscreenPageLimit = Element.OffscreenPageLimit;
+ }
+
+ internal void UpdateSwipePaging()
+ {
+ if (!_managesViewPager)
+ {
+ return;
+ }
+
+ _viewPager.UserInputEnabled = Element.IsSwipePagingEnabled;
+ }
+
+ List<(string title, ImageSource icon, bool tabEnabled)> CreateTabList()
+ {
+ var items = new List<(string title, ImageSource icon, bool tabEnabled)>();
+
+ for (int i = 0; i < Element.Tabs.Count; i++)
+ {
+ var tab = Element.Tabs[i];
+ // ITab.Icon is IImageSource; convert to ImageSource if possible for BottomNavigationViewUtils
+ var icon = tab.Icon as ImageSource;
+ items.Add((tab.Title, icon, tab.IsEnabled));
+ }
+
+ return items;
+ }
+
+ internal virtual void SetupBottomNavigationView()
+ {
+ if (_bottomNavigationView is null)
+ {
+ return;
+ }
+
+ var menu = _bottomNavigationView.Menu;
+ menu.Clear();
+
+ var tabs = Element.Tabs;
+ if (tabs is null || tabs.Count == 0)
+ {
+ return;
+ }
+
+ // Hide bottom navigation if only one tab — but only for Shell.
+ // TabbedPage has historically shown the tab bar even with a single tab.
+ if (tabs.Count == 1 && Element?.Owner is not TabbedPage)
+ {
+ _bottomNavigationView.Visibility = ViewStates.Gone;
+ return;
+ }
+
+ _bottomNavigationView.Visibility = ViewStates.Visible;
+
+ int maxItems = Math.Min(_bottomNavigationView.MaxItemCount, BottomNavigationViewUtils.MaxBottomNavigationItems);
+ bool showMore = tabs.Count > maxItems;
+ int end = showMore ? maxItems - 1 : tabs.Count;
+
+ for (int i = 0; i < end; i++)
+ {
+ var tab = tabs[i];
+ var title = !string.IsNullOrWhiteSpace(tab.Title) ? tab.Title : $"Tab {i + 1}";
+ var menuItem = menu.Add(0, i, i, title);
+
+ if (menuItem is null)
+ {
+ continue;
+ }
+
+ if (!tab.IsEnabled)
+ {
+ menuItem.SetEnabled(false);
+ }
+
+ LoadBottomNavIconAsync(menuItem, tab);
+ }
+
+ // Add "More" overflow item if needed
+ if (showMore)
+ {
+ var moreItem = menu.Add(0, BottomNavigationViewUtils.MoreTabId, maxItems - 1, "More");
+ moreItem?.SetIcon(Resource.Drawable.abc_ic_menu_overflow_material);
+ }
+
+ // Set initial selection using pre-computed index (avoids wrapper comparison issues)
+ var currentIndex = Element.CurrentTabIndex;
+ if (currentIndex >= 0 && currentIndex < tabs.Count)
+ {
+ int targetId = currentIndex >= end ? BottomNavigationViewUtils.MoreTabId : currentIndex;
+ _bottomNavigationView.SelectedItemId = targetId;
+ }
+
+ _bottomNavigationView.SetShiftMode(false, false);
+
+ if (Element.CurrentTab is null && tabs.Count > 0)
+ {
+ Element.CurrentTab = tabs[0];
+ }
+
+ // Set listener AFTER menu population and initial selection.
+ // Adding items to an empty BNV auto-selects item 0, firing OnNavigationItemSelected
+ // before we establish the correct selection — poisoning Shell's CurrentItem.
+ _bottomNavigationView.SetOnItemSelectedListener(_listeners);
+ _bottomNavigationView.SetOnItemReselectedListener(_listeners);
+ }
+
+ async void LoadBottomNavIconAsync(IMenuItem menuItem, ITab tab)
+ {
+ try
+ {
+ if (tab.Icon is not ImageSource icon)
+ {
+ return;
+ }
+
+ var result = await icon.GetPlatformImageAsync(_context);
+ if (result?.Value is not null && menuItem.IsAlive())
+ {
+ menuItem.SetIcon(result.Value);
+
+ // Apply per-item icon tint after loading so that:
+ // 1. FontImageSource with explicit Color shows its own color (tint cleared)
+ // 2. FontImageSource without Color gets SelectedTabColor/UnselectedTabColor tint
+ // Without this, the global ItemIconTintList overrides baked-in colors.
+ var tabIndex = menuItem.ItemId;
+ SetupBottomNavigationViewIconColor(tabIndex, menuItem);
+ }
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"LoadBottomNavIconAsync failed: {ex.Message}");
+ }
+ }
+
+ internal virtual void UpdateTabIcons()
+ {
+ TabLayout tabs = _tabLayout;
+
+ if (tabs.TabCount != Element.Tabs.Count)
+ {
+ return;
+ }
+
+ for (var i = 0; i < Element.Tabs.Count; i++)
+ {
+ ITab child = Element.Tabs[i];
+ TabLayout.Tab tab = tabs.GetTabAt(i);
+ SetTabIconImageSource(child, tab);
+ }
+ }
+
+ internal virtual void SetTabIconImageSource(ITab tabItem, TabLayout.Tab tab, Drawable icon)
+ {
+ tab.SetIcon(icon);
+ // Use tab.Position instead of Element.Tabs.IndexOf(tabItem) because
+ // ITabbedViewSource.Tabs may create new wrapper objects on each access
+ // (e.g., TabbedPage.PageTabAdapter), causing IndexOf to return -1
+ // when the async icon load callback fires with a stale reference.
+ SetIconColorFilter(tab.Position, tab);
+ }
+
+ void SetTabIconImageSource(ITab tabItem, TabLayout.Tab tab)
+ {
+ var icon = tabItem.Icon as ImageSource;
+ icon?.LoadImage(
+ _context,
+ result =>
+ {
+ SetTabIconImageSource(tabItem, tab, result?.Value);
+ });
+ }
+
+ public virtual void UpdateBarBackgroundColor()
+ {
+ if (Element.BarBackground is Brush)
+ {
+ return;
+ }
+
+ if (IsBottomTabPlacement)
+ {
+ if (_bottomNavigationView is not null)
+ {
+ if (Element.BarBackgroundColor is null)
+ {
+ _bottomNavigationView.SetBackground(null);
+ }
+ else
+ {
+ _bottomNavigationView.SetBackgroundColor(Element.BarBackgroundColor.ToPlatform());
+ }
+ }
+ }
+ else
+ {
+ Color tintColor = Element.BarBackgroundColor;
+
+ if (tintColor is null)
+ {
+ _tabLayout.BackgroundTintMode = null;
+ }
+ else
+ {
+ _tabLayout.BackgroundTintMode = PorterDuff.Mode.Src;
+ _tabLayout.BackgroundTintList = ColorStateList.ValueOf(tintColor.ToPlatform());
+ }
+ }
+ }
+
+ public virtual void UpdateBarBackground()
+ {
+ var barBackground = Element.BarBackground as Brush;
+
+ if (_currentBarBackground == barBackground)
+ {
+ return;
+ }
+
+ if (_currentBarBackground is GradientBrush oldGradientBrush)
+ {
+ oldGradientBrush.Parent = null;
+ oldGradientBrush.InvalidateGradientBrushRequested -= OnBarBackgroundChanged;
+ }
+
+ _currentBarBackground = barBackground;
+
+ if (_currentBarBackground is GradientBrush newGradientBrush)
+ {
+ newGradientBrush.Parent = Element.Owner;
+ newGradientBrush.InvalidateGradientBrushRequested += OnBarBackgroundChanged;
+
+ if (_bottomNavigationView is not null && _bottomNavigationView.Elevation > 0)
+ {
+ _bottomNavigationView.Elevation = 0;
+ }
+ }
+ else if (_currentBarBackground is SolidColorBrush && _bottomNavigationView is not null && _bottomNavigationView.Elevation == 0)
+ {
+ _bottomNavigationView.Elevation = _bottomNavigationView.Context.Resources.GetDimension(Resource.Dimension.design_bottom_navigation_elevation);
+ }
+
+ RefreshBarBackground();
+ }
+
+ void OnBarBackgroundChanged(object sender, EventArgs e)
+ {
+ RefreshBarBackground();
+ }
+
+ internal virtual void RefreshBarBackground()
+ {
+ if (IsBottomTabPlacement)
+ {
+ _bottomNavigationView?.UpdateBackground(_currentBarBackground);
+ }
+ else
+ {
+ _tabLayout.UpdateBackground(_currentBarBackground);
+ }
+ }
+
+ internal virtual ColorStateList GetItemTextColorStates()
+ {
+ if (_originalTabTextColors is null)
+ {
+ _originalTabTextColors = IsBottomTabPlacement ? _bottomNavigationView.ItemTextColor : _tabLayout.TabTextColors;
+ }
+
+ Color barItemColor = BarItemColor;
+ Color barTextColor = Element.BarTextColor;
+ Color barSelectedItemColor = BarSelectedItemColor;
+
+ if (barItemColor is null && barTextColor is null && barSelectedItemColor is null)
+ {
+ return _originalTabTextColors;
+ }
+
+ if (_newTabTextColors is not null)
+ {
+ return _newTabTextColors;
+ }
+
+ int checkedColor;
+ int? defaultColor = null;
+
+ if (barTextColor is not null)
+ {
+ checkedColor = barTextColor.ToPlatform().ToArgb();
+ defaultColor = checkedColor;
+ }
+ else
+ {
+ defaultColor = GetItemTextColor(barItemColor, _originalTabTextColors);
+ checkedColor = GetItemTextColor(barSelectedItemColor, _originalTabTextColors);
+ }
+
+ _newTabTextColors = GetColorStateList(defaultColor.Value, checkedColor);
+
+ return _newTabTextColors;
+ }
+
+ int GetItemTextColor(Color customColor, ColorStateList originalColors)
+ {
+ return customColor?.ToPlatform().ToArgb() ?? originalColors?.DefaultColor ?? 0;
+ }
+
+ internal virtual ColorStateList GetItemIconTintColorState(int tabIndex)
+ {
+ if (tabIndex < 0 || tabIndex >= Element.Tabs.Count)
+ {
+ return null;
+ }
+
+ var tab = Element.Tabs[tabIndex];
+
+ // If the icon is a FontImageSource with a color, don't apply tint
+ if (tab.Icon is FontImageSource fontImageSource && fontImageSource.Color is not null)
+ {
+ return null;
+ }
+
+ if (_originalTabIconColors is null)
+ {
+ _originalTabIconColors = IsBottomTabPlacement ? _bottomNavigationView.ItemIconTintList : _tabLayout.TabIconTint;
+ }
+
+ Color barItemColor = BarItemColor;
+ Color barSelectedItemColor = BarSelectedItemColor;
+
+ if (barItemColor is null && barSelectedItemColor is null)
+ {
+ return _originalTabIconColors;
+ }
+
+ if (_newTabIconColors is not null)
+ {
+ return _newTabIconColors;
+ }
+
+ int defaultColor;
+ int checkedColor;
+
+ if (barItemColor is not null)
+ {
+ defaultColor = barItemColor.ToPlatform().ToArgb();
+ }
+ else
+ {
+ defaultColor = GetDefaultColor();
+ }
+
+ if (barSelectedItemColor is not null)
+ {
+ checkedColor = barSelectedItemColor.ToPlatform().ToArgb();
+ }
+ else
+ {
+ checkedColor = GetDefaultColor();
+ }
+
+ _newTabIconColors = GetColorStateList(defaultColor, checkedColor);
+ return _newTabIconColors;
+ }
+
+ int GetDefaultColor()
+ {
+ return GetDefaultColorFromTheme(_context.Context);
+ }
+
+ void ShowMoreBottomSheet()
+ {
+ var items = CreateTabList();
+ BottomSheetDialog bottomSheetDialog;
+ if (CreateMoreBottomSheet is not null)
+ {
+ bottomSheetDialog = CreateMoreBottomSheet(
+ OnMoreItemSelectedInternal,
+ items);
+ }
+ else
+ {
+ bottomSheetDialog = BottomNavigationViewUtils.CreateMoreBottomSheet(
+ OnMoreItemSelectedInternal,
+ _context,
+ items,
+ Math.Min(_bottomNavigationView.MaxItemCount, BottomNavigationViewUtils.MaxBottomNavigationItems));
+ }
+ bottomSheetDialog.DismissEvent += (s, e) => OnMoreSheetDismissedInternal(bottomSheetDialog);
+ bottomSheetDialog.Show();
+ }
+
+ void OnMoreSheetDismissedInternal(BottomSheetDialog dialog)
+ {
+ var index = Element.CurrentTabIndex;
+
+ var menu = _bottomNavigationView?.Menu;
+
+ if (menu is null || index < 0)
+ {
+ return;
+ }
+
+ int targetIndex = Math.Min(index, menu.Size() - 1);
+
+ if (targetIndex < 0)
+ {
+ return;
+ }
+
+ menu.GetItem(targetIndex)?.SetChecked(true);
+ }
+
+ void OnMoreItemSelectedInternal(int selectedIndex, BottomSheetDialog dialog)
+ {
+ if (selectedIndex >= 0 && _bottomNavigationView.SelectedItemId != selectedIndex && Element.Tabs.Count > selectedIndex)
+ {
+ Element.CurrentTab = Element.Tabs[selectedIndex];
+ }
+
+ OnMoreItemSelected?.Invoke(selectedIndex, dialog);
+
+ dialog.Dismiss();
+ dialog.Dispose();
+ }
+
+ void UpdateItemIconColor()
+ {
+ _newTabIconColors = null;
+
+ if (IsBottomTabPlacement)
+ {
+ for (int i = 0; i < _bottomNavigationView.Menu.Size(); i++)
+ {
+ var menuItem = _bottomNavigationView.Menu.GetItem(i);
+ SetupBottomNavigationViewIconColor(i, menuItem);
+ }
+ }
+ else
+ {
+ for (int i = 0; i < _tabLayout.TabCount; i++)
+ {
+ TabLayout.Tab tab = _tabLayout.GetTabAt(i);
+ this.SetIconColorFilter(i, tab);
+ }
+ }
+ }
+
+ void SetupBottomNavigationViewIconColor(int tabIndex, IMenuItem menuItem)
+ {
+ ColorStateList colors = GetItemIconTintColorState(tabIndex);
+#pragma warning disable XAOBS001 // Type or member is obsolete
+ if (_bottomNavigationView?.GetChildAt(0) is BottomNavigationMenuView menuView)
+ {
+ if (tabIndex >= 0 && tabIndex < menuView.ChildCount)
+ {
+ var itemView = menuView.GetChildAt(tabIndex) as BottomNavigationItemView;
+ itemView?.SetIconTintList(colors);
+ }
+ }
+#pragma warning restore XAOBS001 // Type or member is obsolete
+ }
+
+ internal virtual void UpdateStyleForTabItem()
+ {
+ Color barItemColor = BarItemColor;
+ Color barTextColor = Element.BarTextColor;
+ Color barSelectedItemColor = BarSelectedItemColor;
+
+ if (_tabItemStyleLoaded &&
+ _currentBarItemColor == barItemColor &&
+ _currentBarTextColor == barTextColor &&
+ _currentBarSelectedItemColor == barSelectedItemColor)
+ {
+ return;
+ }
+
+ _tabItemStyleLoaded = true;
+ _currentBarItemColor = BarItemColor;
+ _currentBarTextColor = Element.BarTextColor;
+ _currentBarSelectedItemColor = BarSelectedItemColor;
+
+ UpdateBarTextColor();
+ UpdateItemIconColor();
+ }
+
+ internal void UpdateTabItemStyle()
+ {
+ UpdateStyleForTabItem();
+ }
+
+ void UpdateBarTextColor()
+ {
+ _newTabTextColors = null;
+
+ _currentBarTextColorStateList = GetItemTextColorStates() ?? _originalTabTextColors;
+
+ if (IsBottomTabPlacement)
+ {
+ _bottomNavigationView.ItemTextColor = _currentBarTextColorStateList;
+ }
+ else
+ {
+ _tabLayout.TabTextColors = _currentBarTextColorStateList;
+ }
+ }
+
+ void SetIconColorFilter(int tabIndex, TabLayout.Tab tab)
+ {
+ SetIconColorFilter(tabIndex, tab, _tabLayout.GetTabAt(_tabLayout.SelectedTabPosition) == tab);
+ }
+
+ internal virtual void SetIconColorFilter(int tabIndex, TabLayout.Tab tab, bool selected)
+ {
+ var icon = tab.Icon;
+
+ if (icon is null)
+ {
+ return;
+ }
+
+ ColorStateList colors = GetItemIconTintColorState(tabIndex);
+
+ if (colors is null)
+ {
+ ADrawableCompat.SetTintList(icon, null);
+ }
+ else
+ {
+ int[] _stateSet = null;
+
+ if (selected)
+ {
+ _stateSet = GetSelectedStateSet();
+ }
+ else
+ {
+ _stateSet = GetEmptyStateSet();
+ }
+
+ if (colors.GetColorForState(_stateSet, _defaultAndroidColor) == _defaultARGBColor)
+ {
+ ADrawableCompat.SetTintList(icon, null);
+ }
+ else
+ {
+ var wrappedIcon = ADrawableCompat.Wrap(icon);
+ if (wrappedIcon != icon)
+ {
+ icon = wrappedIcon;
+ tab.SetIcon(wrappedIcon);
+ }
+
+ icon.Mutate();
+ icon.SetState(_stateSet);
+
+ // FontImageSource has its own color — don't apply tint list
+ if (tabIndex >= 0 && tabIndex < Element.Tabs.Count &&
+ Element.Tabs[tabIndex].Icon is not FontImageSource)
+ {
+ _tabLayout.TabIconTint = colors;
+ }
+
+ ADrawableCompat.SetTintList(icon, colors);
+ }
+ }
+ icon.InvalidateSelf();
+ }
+
+ int[] GetSelectedStateSet()
+ {
+ if (IsBottomTabPlacement)
+ {
+ if (_checkedStateSet is null)
+ {
+ _checkedStateSet = new int[] { global::Android.Resource.Attribute.StateChecked };
+ }
+
+ return _checkedStateSet;
+ }
+ else
+ {
+ if (_selectedStateSet is null)
+ {
+ _selectedStateSet = GetStateSet(new TempView(_context.Context).SelectedStateSet);
+ }
+
+ return _selectedStateSet;
+ }
+ }
+
+ int[] GetEmptyStateSet()
+ {
+ if (_emptyStateSet is null)
+ {
+ _emptyStateSet = GetStateSet(new TempView(_context.Context).EmptyStateSet);
+ }
+
+ return _emptyStateSet;
+ }
+
+ class TempView : AView
+ {
+ public new IList EmptyStateSet => AView.EmptyStateSet;
+ public new IList SelectedStateSet => AView.SelectedStateSet;
+ public TempView(Context context) : base(context)
+ {
+ }
+ }
+
+ int[] GetStateSet(IList stateSet)
+ {
+ var results = new int[stateSet.Count];
+ for (int i = 0; i < results.Length; i++)
+ {
+ results[i] = stateSet[i];
+ }
+
+ return results;
+ }
+
+ ColorStateList GetColorStateList(int defaultColor, int checkedColor)
+ {
+ int[][] states = new int[2][];
+ int[] colors = new int[2];
+
+ states[0] = GetSelectedStateSet();
+ colors[0] = checkedColor;
+ states[1] = GetEmptyStateSet();
+ colors[1] = defaultColor;
+
+#pragma warning disable RS0030
+ return new ColorStateList(states, colors);
+#pragma warning restore RS0030
+ }
+
+ class Listeners : ViewPager2.OnPageChangeCallback,
+#pragma warning disable CS0618 // Type or member is obsolete
+ TabLayout.IOnTabSelectedListener,
+#pragma warning restore CS0618 // Type or member is obsolete
+ NavigationBarView.IOnItemSelectedListener,
+ NavigationBarView.IOnItemReselectedListener,
+ TabLayoutMediator.ITabConfigurationStrategy
+ {
+ readonly TabbedViewManager _manager;
+
+ public Listeners(TabbedViewManager manager)
+ {
+ _manager = manager;
+ }
+
+ public override void OnPageSelected(int position)
+ {
+ base.OnPageSelected(position);
+
+ var element = _manager.Element;
+
+ if (element is null)
+ {
+ return;
+ }
+
+ var IsBottomTabPlacement = _manager.IsBottomTabPlacement;
+ var _bottomNavigationView = _manager._bottomNavigationView;
+
+ if (element.Tabs.Count > 0 && position < element.Tabs.Count)
+ {
+ element.CurrentTab = element.Tabs[position];
+ }
+
+ if (IsBottomTabPlacement)
+ {
+ var menu = _bottomNavigationView.Menu;
+ if (menu.Size() > 0)
+ {
+ int targetIndex = Math.Min(position, menu.Size() - 1);
+ menu.GetItem(targetIndex)?.SetChecked(true);
+ }
+ }
+
+ _manager._previousTabIndex = position;
+ _manager.OnPageSelected?.Invoke(position);
+ }
+
+ void TabLayoutMediator.ITabConfigurationStrategy.OnConfigureTab(TabLayout.Tab p0, int p1)
+ {
+ if (p1 < _manager.Element.Tabs.Count)
+ {
+ p0.SetText(_manager.Element.Tabs[p1].Title);
+ }
+ }
+
+ bool NavigationBarView.IOnItemSelectedListener.OnNavigationItemSelected(IMenuItem item)
+ {
+ if (_manager.Element is null)
+ {
+ return false;
+ }
+
+ var id = item.ItemId;
+ if (id == BottomNavigationViewUtils.MoreTabId)
+ {
+ _manager.ShowMoreBottomSheet();
+ }
+ else
+ {
+ if (_manager._bottomNavigationView.SelectedItemId != item.ItemId && _manager.Element.Tabs.Count > item.ItemId)
+ {
+ var previousIndex = _manager.Element.CurrentTabIndex;
+
+ // If the tapped tab is already the current tab (e.g., during menu rebuild
+ // after tab visibility changes), just allow the selection — it's not a
+ // navigation attempt that can be cancelled.
+ if (previousIndex == item.ItemId)
+ {
+ _manager.OnTabSelected?.Invoke(item.ItemId);
+ return true;
+ }
+
+ _manager.Element.CurrentTab = _manager.Element.Tabs[item.ItemId];
+
+ // If the navigation was cancelled (e.g. Shell.Navigating → e.Cancel()),
+ // CurrentTabIndex stays at the previous value. Return false so Android
+ // reverts the BottomNavigationView highlight to the original tab.
+ if (_manager.Element.CurrentTabIndex == previousIndex)
+ {
+ return false;
+ }
+
+ _manager.OnTabSelected?.Invoke(item.ItemId);
+ }
+ }
+
+ return true;
+ }
+
+ void TabLayout.IOnTabSelectedListener.OnTabReselected(TabLayout.Tab tab)
+ {
+ if (tab?.Position >= 0)
+ {
+ _manager.OnTabReselected?.Invoke(tab.Position);
+ }
+ }
+
+ void NavigationBarView.IOnItemReselectedListener.OnNavigationItemReselected(IMenuItem item)
+ {
+ if (_manager.Element is null)
+ {
+ return;
+ }
+
+ var id = item.ItemId;
+ if (id == BottomNavigationViewUtils.MoreTabId)
+ {
+ // Re-show the More bottom sheet on reselection (e.g., user picks an
+ // overflow item, then taps More again to switch to a different one).
+ _manager.ShowMoreBottomSheet();
+ }
+ else if (id >= 0 && id < _manager.Element.Tabs.Count)
+ {
+ _manager.OnTabReselected?.Invoke(id);
+ }
+ }
+
+ void TabLayout.IOnTabSelectedListener.OnTabSelected(TabLayout.Tab tab)
+ {
+ _manager.TabSelected(tab);
+ }
+
+ void TabLayout.IOnTabSelectedListener.OnTabUnselected(TabLayout.Tab tab)
+ {
+ if (_manager.Element?.CurrentTab is not null)
+ {
+ var currentIndex = _manager.Element.CurrentTabIndex;
+ _manager.SetIconColorFilter(currentIndex, tab, false);
+ }
+ }
+ }
+}
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 9355a3db6c89..5ef17bef599b 100644
--- a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt
+++ b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt
@@ -120,4 +120,76 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui
~static readonly Microsoft.Maui.Controls.ToolbarItem.BadgeTextColorProperty -> Microsoft.Maui.Controls.BindableProperty
~static readonly Microsoft.Maui.Controls.ToolbarItem.BadgeTextProperty -> Microsoft.Maui.Controls.BindableProperty
~virtual Microsoft.Maui.Controls.Platform.Compatibility.ShellItemRenderer.UpdateShellSectionBadge(Microsoft.Maui.Controls.ShellSection shellSection, int index) -> void
-override Microsoft.Maui.Controls.SwipeItemView.IsEnabledCore.get -> bool
\ No newline at end of file
+override Microsoft.Maui.Controls.SwipeItemView.IsEnabledCore.get -> bool
+Microsoft.Maui.Controls.Handlers.ShellHandler
+Microsoft.Maui.Controls.Handlers.ShellHandler.ShellHandler() -> void
+Microsoft.Maui.Controls.Handlers.ShellItemHandler
+Microsoft.Maui.Controls.Handlers.ShellItemHandler.BottomNavigationView.get -> Google.Android.Material.BottomNavigation.BottomNavigationView?
+Microsoft.Maui.Controls.Handlers.ShellItemHandler.ShellItemHandler() -> void
+Microsoft.Maui.Controls.Handlers.ShellSectionHandler
+Microsoft.Maui.Controls.Handlers.ShellSectionHandler.ShellSectionHandler() -> void
+override Microsoft.Maui.Controls.Handlers.ShellHandler.ConnectHandler(Microsoft.Maui.Platform.MauiDrawerLayout! platformView) -> void
+override Microsoft.Maui.Controls.Handlers.ShellHandler.CreatePlatformView() -> Microsoft.Maui.Platform.MauiDrawerLayout!
+override Microsoft.Maui.Controls.Handlers.ShellHandler.DisconnectHandler(Microsoft.Maui.Platform.MauiDrawerLayout! platformView) -> void
+override Microsoft.Maui.Controls.Handlers.ShellItemHandler.ConnectHandler(AndroidX.ViewPager2.Widget.ViewPager2! platformView) -> void
+override Microsoft.Maui.Controls.Handlers.ShellItemHandler.CreatePlatformElement() -> AndroidX.ViewPager2.Widget.ViewPager2!
+override Microsoft.Maui.Controls.Handlers.ShellItemHandler.DisconnectHandler(AndroidX.ViewPager2.Widget.ViewPager2! platformView) -> void
+override Microsoft.Maui.Controls.Handlers.ShellSectionHandler.ConnectHandler(Android.Views.View! platformView) -> void
+override Microsoft.Maui.Controls.Handlers.ShellSectionHandler.CreatePlatformElement() -> Android.Views.View!
+override Microsoft.Maui.Controls.Handlers.ShellSectionHandler.DisconnectHandler(Android.Views.View! platformView) -> void
+~static Microsoft.Maui.Controls.Handlers.ShellHandler.CommandMapper -> Microsoft.Maui.CommandMapper
+static Microsoft.Maui.Controls.Handlers.ShellHandler.MapCurrentItem(Microsoft.Maui.Controls.Handlers.ShellHandler! handler, Microsoft.Maui.Controls.Shell! shell) -> void
+static Microsoft.Maui.Controls.Handlers.ShellHandler.MapFlowDirection(Microsoft.Maui.Controls.Handlers.ShellHandler! handler, Microsoft.Maui.Controls.Shell! shell) -> void
+static Microsoft.Maui.Controls.Handlers.ShellHandler.MapFlyout(Microsoft.Maui.Controls.Handlers.ShellHandler! handler, Microsoft.Maui.Controls.Shell! shell) -> void
+static Microsoft.Maui.Controls.Handlers.ShellHandler.MapFlyoutBackdrop(Microsoft.Maui.Controls.Handlers.ShellHandler! handler, Microsoft.Maui.Controls.Shell! shell) -> void
+static Microsoft.Maui.Controls.Handlers.ShellHandler.MapFlyoutBackground(Microsoft.Maui.Controls.Handlers.ShellHandler! handler, Microsoft.Maui.Controls.Shell! shell) -> void
+static Microsoft.Maui.Controls.Handlers.ShellHandler.MapFlyoutBackgroundImage(Microsoft.Maui.Controls.Handlers.ShellHandler! handler, Microsoft.Maui.Controls.Shell! shell) -> void
+static Microsoft.Maui.Controls.Handlers.ShellHandler.MapFlyoutBehavior(Microsoft.Maui.Controls.Handlers.ShellHandler! handler, Microsoft.Maui.Controls.Shell! shell) -> void
+static Microsoft.Maui.Controls.Handlers.ShellHandler.MapFlyoutFooter(Microsoft.Maui.Controls.Handlers.ShellHandler! handler, Microsoft.Maui.Controls.Shell! shell) -> void
+static Microsoft.Maui.Controls.Handlers.ShellHandler.MapFlyoutHeader(Microsoft.Maui.Controls.Handlers.ShellHandler! handler, Microsoft.Maui.Controls.Shell! shell) -> void
+static Microsoft.Maui.Controls.Handlers.ShellHandler.MapFlyoutHeaderBehavior(Microsoft.Maui.Controls.Handlers.ShellHandler! handler, Microsoft.Maui.Controls.Shell! shell) -> void
+static Microsoft.Maui.Controls.Handlers.ShellHandler.MapFlyoutHeight(Microsoft.Maui.Controls.Handlers.ShellHandler! handler, Microsoft.Maui.Controls.Shell! shell) -> void
+static Microsoft.Maui.Controls.Handlers.ShellHandler.MapFlyoutVerticalScrollMode(Microsoft.Maui.Controls.Handlers.ShellHandler! handler, Microsoft.Maui.Controls.Shell! shell) -> void
+static Microsoft.Maui.Controls.Handlers.ShellHandler.MapFlyoutWidth(Microsoft.Maui.Controls.Handlers.ShellHandler! handler, Microsoft.Maui.Controls.Shell! shell) -> void
+static Microsoft.Maui.Controls.Handlers.ShellHandler.MapIsPresented(Microsoft.Maui.Controls.Handlers.ShellHandler! handler, Microsoft.Maui.Controls.Shell! shell) -> void
+~static Microsoft.Maui.Controls.Handlers.ShellHandler.Mapper -> Microsoft.Maui.PropertyMapper
+static Microsoft.Maui.Controls.Handlers.ShellItemHandler.CommandMapper -> Microsoft.Maui.CommandMapper!
+static Microsoft.Maui.Controls.Handlers.ShellItemHandler.MapCurrentItem(Microsoft.Maui.Controls.Handlers.ShellItemHandler! handler, Microsoft.Maui.Controls.ShellItem! shellItem) -> void
+static Microsoft.Maui.Controls.Handlers.ShellItemHandler.Mapper -> Microsoft.Maui.PropertyMapper!
+static Microsoft.Maui.Controls.Handlers.ShellSectionHandler.CommandMapper -> Microsoft.Maui.CommandMapper!
+static Microsoft.Maui.Controls.Handlers.ShellSectionHandler.MapCurrentItem(Microsoft.Maui.Controls.Handlers.ShellSectionHandler! handler, Microsoft.Maui.Controls.ShellSection! shellSection) -> void
+static Microsoft.Maui.Controls.Handlers.ShellSectionHandler.Mapper -> Microsoft.Maui.PropertyMapper!
+virtual Microsoft.Maui.Controls.Handlers.ShellHandler.CreateBottomNavViewAppearanceTracker(Microsoft.Maui.Controls.ShellItem! shellItem) -> Microsoft.Maui.Controls.Platform.Compatibility.IShellBottomNavViewAppearanceTracker!
+virtual Microsoft.Maui.Controls.Handlers.ShellHandler.CreateFragmentForPage(Microsoft.Maui.Controls.Page! page) -> Microsoft.Maui.Controls.Platform.Compatibility.IShellObservableFragment!
+virtual Microsoft.Maui.Controls.Handlers.ShellHandler.CreateShellFlyoutContentRenderer() -> Microsoft.Maui.Controls.Platform.Compatibility.IShellFlyoutContentRenderer!
+virtual Microsoft.Maui.Controls.Handlers.ShellHandler.CreateShellItemRenderer(Microsoft.Maui.Controls.ShellItem! shellItem) -> Microsoft.Maui.Controls.Platform.Compatibility.IShellItemRenderer!
+virtual Microsoft.Maui.Controls.Handlers.ShellHandler.CreateShellSectionRenderer(Microsoft.Maui.Controls.ShellSection! shellSection) -> Microsoft.Maui.Controls.Platform.Compatibility.IShellSectionRenderer!
+virtual Microsoft.Maui.Controls.Handlers.ShellHandler.CreateTabLayoutAppearanceTracker(Microsoft.Maui.Controls.ShellSection! shellSection) -> Microsoft.Maui.Controls.Platform.Compatibility.IShellTabLayoutAppearanceTracker!
+virtual Microsoft.Maui.Controls.Handlers.ShellHandler.CreateToolbarAppearanceTracker() -> Microsoft.Maui.Controls.Platform.Compatibility.IShellToolbarAppearanceTracker!
+virtual Microsoft.Maui.Controls.Handlers.ShellHandler.CreateTrackerForToolbar(AndroidX.AppCompat.Widget.Toolbar! toolbar) -> Microsoft.Maui.Controls.Platform.Compatibility.IShellToolbarTracker!
+virtual Microsoft.Maui.Controls.Handlers.ShellItemHandler.CreateMoreBottomSheet(System.Action! selectCallback, System.Collections.Generic.List<(string! title, Microsoft.Maui.Controls.ImageSource? icon, bool tabEnabled)>! items) -> Google.Android.Material.BottomSheet.BottomSheetDialog!
+virtual Microsoft.Maui.Controls.Handlers.ShellItemHandler.OnSectionChanged(Microsoft.Maui.Controls.ShellSection! shellSection, bool animate) -> void
+virtual Microsoft.Maui.Controls.Handlers.ShellItemHandler.OnTabReselected(Microsoft.Maui.Controls.ShellSection! shellSection) -> void
+virtual Microsoft.Maui.Controls.Handlers.ShellSectionHandler.OnCreateNavigationAnimation(Android.Content.Context! context, bool isPopping, bool enter) -> Android.Views.Animations.Animation?
+virtual Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutTemplatedContentRenderer.UpdateVerticalScrollMode() -> void
+Microsoft.Maui.Controls.Handlers.ShellContentNavigationFragment
+Microsoft.Maui.Controls.Handlers.ShellContentNavigationFragment.ShellContentNavigationFragment() -> void
+Microsoft.Maui.Controls.Handlers.ShellContentNavigationFragment.ShellContentNavigationFragment(Microsoft.Maui.Controls.ShellContent? shellContent, Microsoft.Maui.IMauiContext? mauiContext, Microsoft.Maui.Controls.Handlers.ShellSectionHandler? handler) -> void
+Microsoft.Maui.Controls.Handlers.ShellItemWrapperFragment
+Microsoft.Maui.Controls.Handlers.ShellItemWrapperFragment.ShellItemWrapperFragment() -> void
+Microsoft.Maui.Controls.Handlers.ShellItemWrapperFragment.ShellItemWrapperFragment(Microsoft.Maui.Controls.Handlers.ShellItemHandler! handler) -> void
+Microsoft.Maui.Controls.Handlers.ShellSectionWrapperFragment
+Microsoft.Maui.Controls.Handlers.ShellSectionWrapperFragment.ShellSectionWrapperFragment() -> void
+Microsoft.Maui.Controls.Handlers.ShellSectionWrapperFragment.ShellSectionWrapperFragment(Microsoft.Maui.Controls.Handlers.ShellSectionHandler! handler) -> void
+override Microsoft.Maui.Controls.Handlers.ShellContentNavigationFragment.Dispose(bool disposing) -> void
+override Microsoft.Maui.Controls.Handlers.ShellContentNavigationFragment.OnCreate(Android.OS.Bundle? savedInstanceState) -> void
+override Microsoft.Maui.Controls.Handlers.ShellContentNavigationFragment.OnCreateView(Android.Views.LayoutInflater! inflater, Android.Views.ViewGroup? container, Android.OS.Bundle? savedInstanceState) -> Android.Views.View!
+override Microsoft.Maui.Controls.Handlers.ShellContentNavigationFragment.OnDestroyView() -> void
+override Microsoft.Maui.Controls.Handlers.ShellItemWrapperFragment.Dispose(bool disposing) -> void
+override Microsoft.Maui.Controls.Handlers.ShellItemWrapperFragment.OnCreateView(Android.Views.LayoutInflater! inflater, Android.Views.ViewGroup? container, Android.OS.Bundle? savedInstanceState) -> Android.Views.View!
+override Microsoft.Maui.Controls.Handlers.ShellItemWrapperFragment.OnViewCreated(Android.Views.View! view, Android.OS.Bundle? savedInstanceState) -> void
+override Microsoft.Maui.Controls.Handlers.ShellSectionWrapperFragment.Dispose(bool disposing) -> void
+override Microsoft.Maui.Controls.Handlers.ShellSectionWrapperFragment.OnCreate(Android.OS.Bundle? savedInstanceState) -> void
+override Microsoft.Maui.Controls.Handlers.ShellSectionWrapperFragment.OnCreateView(Android.Views.LayoutInflater! inflater, Android.Views.ViewGroup? container, Android.OS.Bundle? savedInstanceState) -> Android.Views.View!
+override Microsoft.Maui.Controls.Handlers.ShellSectionWrapperFragment.OnResume() -> void
+override Microsoft.Maui.Controls.Handlers.ShellSectionWrapperFragment.OnViewCreated(Android.Views.View! view, Android.OS.Bundle? savedInstanceState) -> void
diff --git a/src/Controls/src/Core/Shell/Shell.cs b/src/Controls/src/Core/Shell/Shell.cs
index f7422ad4ce21..3dedc99230bf 100644
--- a/src/Controls/src/Core/Shell/Shell.cs
+++ b/src/Controls/src/Core/Shell/Shell.cs
@@ -89,7 +89,7 @@ private static void OnNavBarIsVisibleChanged(BindableObject bindable, object old
{
// Notify about the property change
shell.OnPropertyChanged(NavBarIsVisibleProperty.PropertyName);
-
+
if (shell == null)
{
return;
@@ -826,7 +826,7 @@ void UpdateToolbarAppearanceFeatures(Element pivot, ShellAppearance appearance)
RuntimeFeature.IsMaterial3Enabled ? Color.FromArgb("#FEF7FF") : Color.FromArgb("#2c3e50"),
RuntimeFeature.IsMaterial3Enabled ? Color.FromArgb("#141218") : Color.FromArgb("#1B3147"));
static Color DefaultForegroundColor => ResolveThemeColor(
- RuntimeFeature.IsMaterial3Enabled ? Color.FromArgb("#1D1B20") : Colors.White,
+ RuntimeFeature.IsMaterial3Enabled ? Color.FromArgb("#1D1B20") : Colors.Black,
RuntimeFeature.IsMaterial3Enabled ? Color.FromArgb("#E6E0E9") : Colors.White);
static Color DefaultTitleColor => ResolveThemeColor(
RuntimeFeature.IsMaterial3Enabled ? Color.FromArgb("#1D1B20") : Colors.White,
@@ -1836,8 +1836,8 @@ void SendNavigating(ShellNavigatingEventArgs args)
// correctly reflects the destination page at that point.
_previousPage = CurrentPage;
}
-
- // Unsubscribe Loaded handler if navigating away before page loads to prevent memory leaks.
+
+ // Unsubscribe Loaded handler if navigating away before page loads to prevent memory leaks.
if (CurrentPage != null && !CurrentPage.IsLoadedFired)
{
CurrentPage.Loaded -= OnCurrentPageLoaded;
diff --git a/src/Controls/src/Core/Shell/ShellContent.cs b/src/Controls/src/Core/Shell/ShellContent.cs
index 1faa996f76db..f562cc27d444 100644
--- a/src/Controls/src/Core/Shell/ShellContent.cs
+++ b/src/Controls/src/Core/Shell/ShellContent.cs
@@ -286,10 +286,21 @@ protected override void OnPropertyChanged([System.Runtime.CompilerServices.Calle
if (propertyName == WindowProperty.PropertyName)
{
if (_contentCache?.IsLoaded == true)
+ {
return;
+ }
EvaluateDisconnect();
}
+ else if (propertyName == TitleProperty.PropertyName)
+ {
+ // Propagate child Title change to parent ShellSection's handler
+ // so the mapper can update platform tab titles.
+ if (Parent is ShellSection section)
+ {
+ section.Handler?.UpdateValue(nameof(Title));
+ }
+ }
}
void OnPageUnloaded(object sender, EventArgs e) => EvaluateDisconnect();
diff --git a/src/Controls/src/Core/ShellToolbar.cs b/src/Controls/src/Core/ShellToolbar.cs
index e3b0af67c9e3..1ac432b285f9 100644
--- a/src/Controls/src/Core/ShellToolbar.cs
+++ b/src/Controls/src/Core/ShellToolbar.cs
@@ -94,7 +94,7 @@ internal void ApplyChanges()
#if WINDOWS
_drawerToggleVisible = flyoutBehavior is FlyoutBehavior.Flyout;
#else
- _drawerToggleVisible = stack.Count <= 1 && flyoutBehavior is FlyoutBehavior.Flyout;
+ _drawerToggleVisible = flyoutBehavior is FlyoutBehavior.Flyout && (stack.Count <= 1 || !backButtonVisible);
#endif
BackButtonVisible = backButtonVisible && stack.Count > 1;
BackButtonEnabled = _backButtonBehavior?.IsEnabled ?? true;
diff --git a/src/Controls/src/Core/TabbedPage/TabbedPage.cs b/src/Controls/src/Core/TabbedPage/TabbedPage.cs
index dd27c460bf0a..6e1ec956363d 100644
--- a/src/Controls/src/Core/TabbedPage/TabbedPage.cs
+++ b/src/Controls/src/Core/TabbedPage/TabbedPage.cs
@@ -1,5 +1,8 @@
#nullable disable
using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Linq;
using Microsoft.Maui.Graphics;
namespace Microsoft.Maui.Controls
@@ -128,5 +131,17 @@ void OnPagePropertyChanged(object sender, System.ComponentModel.PropertyChangedE
Handler?.UpdateValue(TabbedPage.ItemsSourceProperty.PropertyName);
}
}
+
+ ///
+ /// Adapts a to the interface for ITabbedView consumption.
+ ///
+ internal sealed class PageTabAdapter : ITab
+ {
+ public PageTabAdapter(Page page) => Page = page;
+ internal Page Page { get; }
+ public string Title => Page.Title;
+ public IImageSource Icon => Page.IconImageSource;
+ public bool IsEnabled => Page.IsEnabled;
+ }
}
}
\ No newline at end of file
diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ActionModeMenuShouldNotBeVisibleAfterSwitchingTab.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ActionModeMenuShouldNotBeVisibleAfterSwitchingTab.png
index e46d3a5a7db7..532b128e70e8 100644
Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ActionModeMenuShouldNotBeVisibleAfterSwitchingTab.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ActionModeMenuShouldNotBeVisibleAfterSwitchingTab.png differ
diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/BackButtonBehavior_IsVisible_False_ProgrammaticNavStillWorks.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/BackButtonBehavior_IsVisible_False_ProgrammaticNavStillWorks.png
index 06e6dcb97e8b..015878cedcfd 100644
Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/BackButtonBehavior_IsVisible_False_ProgrammaticNavStillWorks.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/BackButtonBehavior_IsVisible_False_ProgrammaticNavStillWorks.png differ
diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ShouldUpdateSearchViewOnPageNavigation.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ShouldUpdateSearchViewOnPageNavigation.png
index 174656c547eb..3c7a671e0a3b 100644
Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ShouldUpdateSearchViewOnPageNavigation.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ShouldUpdateSearchViewOnPageNavigation.png differ
diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabBarShouldBeVisibleAfterNavigatingFromModalViaGoToAsync.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabBarShouldBeVisibleAfterNavigatingFromModalViaGoToAsync.png
index 043a8daa3e6d..dfe4156820f2 100644
Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabBarShouldBeVisibleAfterNavigatingFromModalViaGoToAsync.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/TabBarShouldBeVisibleAfterNavigatingFromModalViaGoToAsync.png differ
diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellFlyout_FlyoutIcon.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellFlyout_FlyoutIcon.png
index 88f626624fda..84d23a987b5e 100644
Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellFlyout_FlyoutIcon.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellFlyout_FlyoutIcon.png differ
diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellMenuItemsAlignedInRTL.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellMenuItemsAlignedInRTL.png
index db4bcfe2e519..b1f3df981ffe 100644
Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellMenuItemsAlignedInRTL.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyShellMenuItemsAlignedInRTL.png differ
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue32985_RapidNavigation.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue32985_RapidNavigation.cs
new file mode 100644
index 000000000000..d071b7b65e5b
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue32985_RapidNavigation.cs
@@ -0,0 +1,147 @@
+namespace Maui.Controls.Sample.Issues;
+
+[Issue(IssueTracker.Github, 32985, "Shell rapid overlapping navigations should not drop pages", PlatformAffected.Android)]
+public class Issue32985_RapidNavigation : TestShell
+{
+ protected override void Init()
+ {
+ var statusLabel = new Label
+ {
+ AutomationId = "StatusLabel",
+ Text = "Ready"
+ };
+
+ var stackDepthLabel = new Label
+ {
+ AutomationId = "StackDepthLabel",
+ Text = "Stack: 1"
+ };
+
+ var rapidPushButton = new Button
+ {
+ Text = "Rapid Push Two Pages",
+ AutomationId = "RapidPushButton",
+ Command = new Command(async () =>
+ {
+ statusLabel.Text = "Pushing...";
+
+ // Fire two rapid PushAsync calls without awaiting the first.
+ // StackNavigationManager should queue the second instead of throwing.
+ var push1 = Navigation.PushAsync(new ContentPage
+ {
+ Title = "Page 2",
+ Content = new VerticalStackLayout
+ {
+ Spacing = 15,
+ VerticalOptions = LayoutOptions.Center,
+ Children =
+ {
+ new Label
+ {
+ Text = "Page 2 (first rapid push)",
+ AutomationId = "Page2Label",
+ HorizontalOptions = LayoutOptions.Center
+ },
+ new Button
+ {
+ Text = "Go Back from Page 2",
+ AutomationId = "GoBackFromPage2Button",
+ Command = new Command(async () => await Navigation.PopAsync())
+ }
+ }
+ }
+ });
+
+ var push2 = Navigation.PushAsync(new ContentPage
+ {
+ Title = "Page 3",
+ Content = new VerticalStackLayout
+ {
+ Spacing = 15,
+ Padding = new Thickness(20),
+ VerticalOptions = LayoutOptions.Center,
+ Children =
+ {
+ new Label
+ {
+ Text = "Page 3 (second rapid push)",
+ AutomationId = "Page3Label",
+ HorizontalOptions = LayoutOptions.Center
+ },
+ new Label
+ {
+ AutomationId = "FinalStackDepth",
+ HorizontalOptions = LayoutOptions.Center
+ },
+ new Button
+ {
+ Text = "Check Stack Depth",
+ AutomationId = "CheckStackButton",
+ Command = new Command(() =>
+ {
+ var depth = Navigation.NavigationStack.Count;
+ if (Shell.Current?.CurrentPage is ContentPage cp &&
+ cp.Content is VerticalStackLayout vsl)
+ {
+ foreach (var child in vsl.Children)
+ {
+ if (child is Label l && l.AutomationId == "FinalStackDepth")
+ {
+ l.Text = $"Depth: {depth}";
+ break;
+ }
+ }
+ }
+ })
+ },
+ new Button
+ {
+ Text = "Go Back",
+ AutomationId = "GoBackButton",
+ Command = new Command(async () => await Navigation.PopAsync())
+ }
+ }
+ }
+ });
+
+ await Task.WhenAll(push1, push2);
+ statusLabel.Text = "Done";
+ stackDepthLabel.Text = $"Stack: {Navigation.NavigationStack.Count}";
+ })
+ };
+
+ var page = new ContentPage
+ {
+ Title = "Rapid Navigation Test",
+ Content = new VerticalStackLayout
+ {
+ Spacing = 15,
+ Padding = new Thickness(20),
+ VerticalOptions = LayoutOptions.Center,
+ Children =
+ {
+ new Label
+ {
+ Text = "Rapid Overlapping Navigation Test",
+ FontSize = 20,
+ FontAttributes = FontAttributes.Bold,
+ HorizontalOptions = LayoutOptions.Center
+ },
+ new Label
+ {
+ Text = "Tests that two rapid PushAsync calls both complete without dropping pages.",
+ FontSize = 13,
+ TextColor = Colors.Gray,
+ HorizontalTextAlignment = TextAlignment.Center,
+ HorizontalOptions = LayoutOptions.Center
+ },
+ rapidPushButton,
+ statusLabel,
+ stackDepthLabel
+ }
+ }
+ };
+
+ AddContentPage(page);
+ }
+}
diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32985_RapidNavigation.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32985_RapidNavigation.cs
new file mode 100644
index 000000000000..38edc4e1db5c
--- /dev/null
+++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32985_RapidNavigation.cs
@@ -0,0 +1,42 @@
+using NUnit.Framework;
+using UITest.Appium;
+using UITest.Core;
+
+namespace Microsoft.Maui.TestCases.Tests.Issues;
+
+public class Issue32985_RapidNavigation : _IssuesUITest
+{
+ public Issue32985_RapidNavigation(TestDevice testDevice) : base(testDevice)
+ {
+ }
+
+ public override string Issue => "Shell rapid overlapping navigations should not drop pages";
+
+ [Test]
+ [Category(UITestCategories.Shell)]
+ public void RapidOverlappingPushShouldNotDropPages()
+ {
+ // Tap button that fires two rapid PushAsync calls
+ App.WaitForElement("RapidPushButton");
+ App.Tap("RapidPushButton");
+
+ // Page 3 (second push) should be visible.
+ App.WaitForElement("Page3Label");
+
+ // Verify stack depth is 3 (root + page2 + page3)
+ App.WaitForElement("CheckStackButton");
+ App.Tap("CheckStackButton");
+
+ var depth = App.WaitForElement("FinalStackDepth").GetText();
+ Assert.That(depth, Is.EqualTo("Depth: 3"),
+ "Navigation stack should have 3 pages (root + 2 rapid pushes)");
+
+ // Go back to page 2
+ App.Tap("GoBackButton");
+ App.WaitForElement("Page2Label");
+
+ // Go back to root
+ App.Tap("GoBackFromPage2Button");
+ App.WaitForElement("RapidPushButton");
+ }
+}
diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/ShellSearchHandlerItemSizing.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/ShellSearchHandlerItemSizing.cs
index 77ce22ef3e5c..d4c4db291a7a 100644
--- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/ShellSearchHandlerItemSizing.cs
+++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/ShellSearchHandlerItemSizing.cs
@@ -37,7 +37,8 @@ public void VerifySearchHandlerItemsAreVisible()
#else
App.EnterText(AppiumQuery.ByXPath("//android.widget.EditText"), "Hello");
#endif
- VerifyScreenshot();
+ // Use retryTimeout to give some time for the search results to appear and hide scrollbars before taking the screenshot.
+ VerifyScreenshot(tolerance: 0.5, retryTimeout: TimeSpan.FromSeconds(2));
}
#endif
}
\ No newline at end of file
diff --git a/src/Core/src/Core/ITab.cs b/src/Core/src/Core/ITab.cs
new file mode 100644
index 000000000000..227adabfe4d3
--- /dev/null
+++ b/src/Core/src/Core/ITab.cs
@@ -0,0 +1,23 @@
+namespace Microsoft.Maui
+{
+ ///
+ /// Represents a single tab within an .
+ ///
+ public interface ITab
+ {
+ ///
+ /// Gets the title text displayed for this tab.
+ ///
+ string Title { get; }
+
+ ///
+ /// Gets the icon image source for this tab.
+ ///
+ IImageSource? Icon { get; }
+
+ ///
+ /// Gets whether this tab is enabled and can be selected.
+ ///
+ bool IsEnabled { get; }
+ }
+}
diff --git a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs
index fafb0d901ca6..6fc959f9b7a3 100644
--- a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs
+++ b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs
@@ -1,39 +1,37 @@
using System;
-using System.Threading.Tasks;
-using Android.App.Roles;
-using Android.Runtime;
using Android.Views;
using AndroidX.AppCompat.Widget;
using AndroidX.CoordinatorLayout.Widget;
-using AndroidX.Core.View;
-using AndroidX.DrawerLayout.Widget;
-using AndroidX.Fragment.App;
-using AndroidX.Lifecycle;
namespace Microsoft.Maui.Handlers
{
- public partial class FlyoutViewHandler : ViewHandler
+ public partial class FlyoutViewHandler : ViewHandler
{
View? _flyoutView;
const uint DefaultScrimColor = 0x99000000;
View? _navigationRoot;
- LinearLayoutCompat? _sideBySideView;
- DrawerLayout DrawerLayout => (DrawerLayout)PlatformView;
ScopedFragment? _detailViewFragment;
- protected override View CreatePlatformView()
+ // MauiDrawerLayout provides: _sideBySideView, layout methods, lock modes
+ MauiDrawerLayout MauiDrawerLayout => PlatformView;
+
+ protected override MauiDrawerLayout CreatePlatformView()
{
var li = MauiContext?.GetLayoutInflater();
_ = li ?? throw new InvalidOperationException($"LayoutInflater cannot be null");
- var dl = li.Inflate(Resource.Layout.drawer_layout, null)
- .JavaCast()
- ?? throw new InvalidOperationException($"Resource.Layout.drawer_layout missing");
+ // Create MauiDrawerLayout instead of raw DrawerLayout
+ var dl = new MauiDrawerLayout(Context);
+ // Create navigation root from XML
_navigationRoot = li.Inflate(Resource.Layout.navigationlayout, null)
?? throw new InvalidOperationException($"Resource.Layout.navigationlayout missing");
_navigationRoot.Id = View.GenerateViewId();
+
+ // Set navigation root as content view in MauiDrawerLayout
+ dl.SetContentView(_navigationRoot);
+
return dl;
}
@@ -141,159 +139,64 @@ void UpdateFlyout()
_flyoutView.SetBackgroundColor(new global::Android.Graphics.Color(colors.GetColor(0, 0)));
}
- LayoutViews();
+ // Use MauiDrawerLayout to set the flyout view
+ MauiDrawerLayout.FlyoutWidth = FlyoutWidth;
+ MauiDrawerLayout.SetFlyoutView(_flyoutView);
+
+ // Set layout mode based on behavior
+ UpdateFlyoutBehavior();
}
void LayoutViews()
{
+ // Layout is now handled by MauiDrawerLayout internally
+ // Just ensure behavior is set correctly
if (_flyoutView == null)
return;
- if (VirtualView.FlyoutBehavior == FlyoutBehavior.Locked)
- LayoutSideBySide();
- else
- LayoutAsFlyout();
- }
-
- void LayoutSideBySide()
- {
- var flyoutView = _flyoutView;
- if (MauiContext == null || _navigationRoot == null || flyoutView == null)
- return;
-
- if (_sideBySideView == null)
- {
- _sideBySideView = new LinearLayoutCompat(Context)
- {
- Orientation = LinearLayoutCompat.Horizontal,
- LayoutParameters = new DrawerLayout.LayoutParams(
- DrawerLayout.LayoutParams.MatchParent,
- DrawerLayout.LayoutParams.MatchParent)
- };
- }
-
- if (_navigationRoot.Parent != _sideBySideView)
- {
- _navigationRoot.RemoveFromParent();
-
- var layoutParameters =
- new LinearLayoutCompat.LayoutParams(
- LinearLayoutCompat.LayoutParams.MatchParent,
- LinearLayoutCompat.LayoutParams.MatchParent,
- 1);
-
- _sideBySideView.AddView(_navigationRoot, layoutParameters);
- UpdateDetailsFragmentView();
- }
-
- if (flyoutView.Parent != _sideBySideView)
- {
- // When the Flyout is acting as a flyout Android will set the Visibility to GONE when it's off screen
- // This makes sure it's visible
- flyoutView.Visibility = ViewStates.Visible;
- flyoutView.RemoveFromParent();
- var layoutParameters =
- new LinearLayoutCompat.LayoutParams(
- (int)FlyoutWidth,
- LinearLayoutCompat.LayoutParams.MatchParent,
- 0);
-
- _sideBySideView.AddView(flyoutView, 0, layoutParameters);
- }
-
- if (_sideBySideView.Parent != PlatformView)
- DrawerLayout.AddView(_sideBySideView);
- else
- UpdateDetailsFragmentView();
-
- if (VirtualView is IToolbarElement te && te.Toolbar?.Handler is ToolbarHandler th)
- th.SetupWithDrawerLayout(null);
- }
-
- void LayoutAsFlyout()
- {
- var flyoutView = _flyoutView;
- if (MauiContext == null || _navigationRoot == null || flyoutView == null)
- return;
-
- _sideBySideView?.RemoveAllViews();
- _sideBySideView?.RemoveFromParent();
-
- if (_navigationRoot.Parent != PlatformView)
- {
- _navigationRoot.RemoveFromParent();
-
- var layoutParameters =
- new LinearLayoutCompat.LayoutParams(
- LinearLayoutCompat.LayoutParams.MatchParent,
- LinearLayoutCompat.LayoutParams.MatchParent);
-
- DrawerLayout.AddView(_navigationRoot, 0, layoutParameters);
- }
+ MauiDrawerLayout.FlyoutLayoutModeValue = VirtualView.FlyoutBehavior == FlyoutBehavior.Locked
+ ? MauiDrawerLayout.FlyoutLayoutMode.SideBySide
+ : MauiDrawerLayout.FlyoutLayoutMode.Flyout;
+ // Update fragment view after layout
UpdateDetailsFragmentView();
- if (flyoutView.Parent != PlatformView)
+ // Update toolbar integration
+ if (VirtualView is IToolbarElement te && te.Toolbar?.Handler is ToolbarHandler th)
{
- flyoutView.RemoveFromParent();
-
- var layoutParameters =
- new DrawerLayout.LayoutParams(
- (int)FlyoutWidth,
- DrawerLayout.LayoutParams.MatchParent,
- (int)GravityFlags.Start);
-
- // Flyout has to get added after the content otherwise clicking anywhere
- // on the flyout will cause it to close and gesture
- // recognizers inside the flyout won't fire
- DrawerLayout.AddView(flyoutView, layoutParameters);
+ th.SetupWithDrawerLayout(VirtualView.FlyoutBehavior == FlyoutBehavior.Locked ? null : MauiDrawerLayout);
}
-
- if (VirtualView is IToolbarElement te && te.Toolbar?.Handler is ToolbarHandler th)
- th.SetupWithDrawerLayout(DrawerLayout);
-
- // Apply drawer lock mode during layout so IsGestureEnabled is respected
- // even before the detail platform view is ready
- DrawerLayout.SetDrawerLockMode(
- VirtualView.FlyoutBehavior == FlyoutBehavior.Flyout && VirtualView.IsGestureEnabled
- ? DrawerLayout.LockModeUnlocked
- : DrawerLayout.LockModeLockedClosed);
}
+ // LayoutSideBySide and LayoutAsFlyout are now handled by MauiDrawerLayout
+
void UpdateIsPresented()
{
- if (_flyoutView?.Parent == DrawerLayout)
- {
- if (VirtualView.IsPresented)
- DrawerLayout.OpenDrawer(_flyoutView);
- else
- DrawerLayout.CloseDrawer(_flyoutView);
- }
+ // Use MauiDrawerLayout's open/close methods
+ if (VirtualView.IsPresented)
+ MauiDrawerLayout.OpenFlyout();
+ else
+ MauiDrawerLayout.CloseFlyout();
}
void UpdateFlyoutBehavior()
{
var behavior = VirtualView.FlyoutBehavior;
- if (_detailViewFragment?.DetailView?.Handler?.PlatformView == null)
- return;
// Important to create the layout views before setting the lock mode
LayoutViews();
- switch (behavior)
+ // Use MauiDrawerLayout's SetBehavior method for consistent behavior handling
+ MauiDrawerLayout.SetBehavior(behavior);
+
+ // Also set gesture enabled state
+ if (behavior == FlyoutBehavior.Flyout)
{
- case FlyoutBehavior.Disabled:
- case FlyoutBehavior.Locked:
- DrawerLayout.CloseDrawers();
- DrawerLayout.SetDrawerLockMode(DrawerLayout.LockModeLockedClosed);
- break;
- case FlyoutBehavior.Flyout:
- DrawerLayout.SetDrawerLockMode(VirtualView.IsGestureEnabled ? DrawerLayout.LockModeUnlocked : DrawerLayout.LockModeLockedClosed);
- break;
+ MauiDrawerLayout.SetGestureEnabled(VirtualView.IsGestureEnabled);
}
}
- protected override void ConnectHandler(View platformView)
+ protected override void ConnectHandler(MauiDrawerLayout platformView)
{
MauiWindowInsetListener.RegisterParentForChildViews(platformView);
@@ -302,14 +205,12 @@ protected override void ConnectHandler(View platformView)
MauiWindowInsetListener.SetupViewWithLocalListener(cl);
}
- if (platformView is DrawerLayout dl)
- {
- dl.DrawerStateChanged += OnDrawerStateChanged;
- dl.ViewAttachedToWindow += DrawerLayoutAttached;
- }
+ // Subscribe to MauiDrawerLayout events
+ platformView.OnPresentedChanged += HandlePresentedChanged;
+ platformView.ViewAttachedToWindow += DrawerLayoutAttached;
}
- protected override void DisconnectHandler(View platformView)
+ protected override void DisconnectHandler(MauiDrawerLayout platformView)
{
MauiWindowInsetListener.UnregisterView(platformView);
if (_navigationRoot is CoordinatorLayout cl)
@@ -318,11 +219,12 @@ protected override void DisconnectHandler(View platformView)
_navigationRoot = null;
}
- if (platformView is DrawerLayout dl)
- {
- dl.DrawerStateChanged -= OnDrawerStateChanged;
- dl.ViewAttachedToWindow -= DrawerLayoutAttached;
- }
+ // Unsubscribe from MauiDrawerLayout events
+ platformView.OnPresentedChanged -= HandlePresentedChanged;
+ platformView.ViewAttachedToWindow -= DrawerLayoutAttached;
+
+ // Use MauiDrawerLayout's Disconnect method for cleanup
+ platformView.Disconnect();
if (VirtualView is IToolbarElement te)
{
@@ -335,10 +237,11 @@ void DrawerLayoutAttached(object? sender, View.ViewAttachedToWindowEventArgs e)
UpdateDetailsFragmentView();
}
- void OnDrawerStateChanged(object? sender, DrawerLayout.DrawerStateChangedEventArgs e)
+ void HandlePresentedChanged(bool isPresented)
{
- if (e.NewState == DrawerLayout.StateIdle && VirtualView.FlyoutBehavior == FlyoutBehavior.Flyout && _flyoutView != null)
- VirtualView.IsPresented = DrawerLayout.IsDrawerVisible(_flyoutView);
+ // Sync the virtual view's IsPresented property with the actual drawer state
+ if (VirtualView.FlyoutBehavior == FlyoutBehavior.Flyout)
+ VirtualView.IsPresented = isPresented;
}
public static void MapDetail(IFlyoutViewHandler handler, IFlyoutView flyoutView)
@@ -386,7 +289,7 @@ public static void MapToolbar(IFlyoutViewHandler handler, IFlyoutView view)
handler.VirtualView is IToolbarElement te &&
te.Toolbar?.Handler is ToolbarHandler th)
{
- th.SetupWithDrawerLayout(platformHandler.DrawerLayout);
+ th.SetupWithDrawerLayout(platformHandler.MauiDrawerLayout);
}
}
diff --git a/src/Core/src/Platform/Android/MauiDrawerLayout.cs b/src/Core/src/Platform/Android/MauiDrawerLayout.cs
new file mode 100644
index 000000000000..96e08cd8d494
--- /dev/null
+++ b/src/Core/src/Platform/Android/MauiDrawerLayout.cs
@@ -0,0 +1,570 @@
+using System;
+using Android.Content;
+using Android.Runtime;
+using Android.Util;
+using Android.Views;
+using AndroidX.AppCompat.Widget;
+using AndroidX.DrawerLayout.Widget;
+using AView = Android.Views.View;
+
+namespace Microsoft.Maui.Platform
+{
+ ///
+ /// Shared DrawerLayout wrapper for FlyoutViewHandler and ShellHandler.
+ /// Provides common flyout functionality following Google Material Design guidelines.
+ ///
+ public class MauiDrawerLayout : DrawerLayout
+ {
+ AView? _flyoutView;
+ AView? _contentView;
+ LinearLayoutCompat? _sideBySideView;
+ FlyoutBehavior _currentBehavior = FlyoutBehavior.Flyout;
+ bool _gestureEnabled = true;
+ double _flyoutWidth = -1;
+ double _defaultFlyoutWidth;
+ FlyoutLayoutMode _layoutMode = FlyoutLayoutMode.Flyout;
+ bool _isListening;
+
+ ///
+ /// Defines how the flyout is laid out when in Locked behavior.
+ ///
+ public enum FlyoutLayoutMode
+ {
+ ///
+ /// Standard flyout mode - drawer slides over content.
+ ///
+ Flyout,
+
+ ///
+ /// Side-by-side mode - flyout and content are placed horizontally.
+ /// Used by FlyoutViewHandler for tablet locked mode.
+ ///
+ SideBySide,
+
+ ///
+ /// Padding mode - content is padded to make room for flyout.
+ /// Used by ShellFlyoutRenderer for locked mode.
+ ///
+ Padding
+ }
+
+ #region Constructors
+
+ public MauiDrawerLayout(Context context) : base(context)
+ {
+ Initialize(context);
+ }
+
+ public MauiDrawerLayout(Context context, IAttributeSet? attrs) : base(context, attrs)
+ {
+ Initialize(context);
+ }
+
+ public MauiDrawerLayout(Context context, IAttributeSet? attrs, int defStyleAttr) : base(context, attrs, defStyleAttr)
+ {
+ Initialize(context);
+ }
+
+ protected MauiDrawerLayout(IntPtr javaReference, JniHandleOwnership transfer) : base(javaReference, transfer)
+ {
+ }
+
+ void Initialize(Context context)
+ {
+ _defaultFlyoutWidth = CalculateDefaultFlyoutWidth(context);
+ }
+
+ #endregion
+
+ #region Properties
+
+ ///
+ /// Gets the current flyout view.
+ ///
+ public AView? FlyoutView => _flyoutView;
+
+ ///
+ /// Gets the current content view.
+ ///
+ public AView? ContentView => _contentView;
+
+ ///
+ /// Gets whether the flyout is currently open.
+ ///
+ public bool IsFlyoutOpen => _flyoutView != null && _flyoutView.Parent == this && IsDrawerOpen(_flyoutView);
+
+ ///
+ /// Gets or sets the flyout width in pixels. Pass MatchParent (-1) for full-width drawers.
+ /// Shell converts its -1 (unset) to DefaultFlyoutWidth before setting this property.
+ ///
+ public double FlyoutWidth
+ {
+ get => _flyoutWidth;
+ set
+ {
+ _flyoutWidth = value;
+ UpdateFlyoutViewWidth();
+ }
+ }
+
+ ///
+ /// Gets the default flyout width calculated from Google design guidelines.
+ ///
+ public double DefaultFlyoutWidth => _defaultFlyoutWidth;
+
+ ///
+ /// Gets or sets the flyout layout mode for locked behavior.
+ ///
+ public FlyoutLayoutMode FlyoutLayoutModeValue
+ {
+ get => _layoutMode;
+ set
+ {
+ if (_layoutMode != value)
+ {
+ _layoutMode = value;
+ if (_currentBehavior == FlyoutBehavior.Locked)
+ {
+ UpdateLayout();
+ }
+ }
+ }
+ }
+
+ ///
+ /// Gets the current flyout behavior.
+ ///
+ public FlyoutBehavior CurrentBehavior => _currentBehavior;
+
+ ///
+ /// Gets whether gesture-based opening is enabled.
+ ///
+ public bool IsGestureEnabled => _gestureEnabled;
+
+ #endregion
+
+ #region Events
+
+ ///
+ /// Raised when the flyout presented state changes.
+ ///
+ public event Action? OnPresentedChanged;
+
+ ///
+ /// Raised when the drawer slides.
+ ///
+ public event Action? OnSlide;
+
+ #endregion
+
+ #region Touch Handling
+
+ ///
+ /// When the flyout is locked open, allow touch events to pass through to
+ /// the content area instead of being intercepted by the DrawerLayout.
+ /// Matches the old ShellFlyoutRenderer.OnInterceptTouchEvent behavior.
+ ///
+ public override bool OnInterceptTouchEvent(MotionEvent? ev)
+ {
+ bool result = base.OnInterceptTouchEvent(ev);
+
+ if (_flyoutView is not null && _flyoutView.Parent == this && GetDrawerLockMode(_flyoutView) == LockModeLockedOpen)
+ return false;
+
+ return result;
+ }
+
+ #endregion
+
+ #region Public Methods
+
+ ///
+ /// Sets the content view (the main content behind the flyout).
+ ///
+ public void SetContentView(AView contentView)
+ {
+ if (_contentView == contentView)
+ {
+ return;
+ }
+
+ _contentView?.RemoveFromParent();
+ _contentView = contentView;
+
+ UpdateLayout();
+ }
+
+ ///
+ /// Sets the flyout view (the drawer content).
+ ///
+ public void SetFlyoutView(AView flyoutView)
+ {
+ if (_flyoutView == flyoutView)
+ {
+ return;
+ }
+
+ _flyoutView?.RemoveFromParent();
+ _flyoutView = flyoutView;
+
+ UpdateLayout();
+ EnsureListening();
+ }
+
+ ///
+ /// Opens the flyout drawer.
+ ///
+ public void OpenFlyout(bool animate = true)
+ {
+ if (_flyoutView is null || _flyoutView.Parent != this)
+ {
+ return;
+ }
+
+ if (animate)
+ {
+ OpenDrawer(_flyoutView);
+ }
+ else
+ {
+ OpenDrawer(_flyoutView, false);
+ }
+ }
+
+ ///
+ /// Closes the flyout drawer.
+ ///
+ public void CloseFlyout(bool animate = true)
+ {
+ if (_flyoutView is null)
+ {
+ CloseDrawers();
+ return;
+ }
+
+ if (_flyoutView.Parent != this)
+ {
+ return;
+ }
+
+ if (animate)
+ {
+ CloseDrawer(_flyoutView);
+ }
+ else
+ {
+ CloseDrawer(_flyoutView, false);
+ }
+ }
+
+ ///
+ /// Sets the flyout behavior (Disabled/Flyout/Locked).
+ ///
+ public void SetBehavior(FlyoutBehavior behavior)
+ {
+ bool closeAfterUpdate = (behavior == FlyoutBehavior.Flyout && _currentBehavior == FlyoutBehavior.Locked);
+ _currentBehavior = behavior;
+
+ UpdateLayout();
+ UpdateLockMode();
+
+ if (closeAfterUpdate && _flyoutView != null && _flyoutView.Parent == this)
+ {
+ CloseDrawer(_flyoutView, false);
+ }
+ }
+
+ ///
+ /// Sets whether gesture-based flyout opening is enabled.
+ ///
+ public void SetGestureEnabled(bool enabled)
+ {
+ _gestureEnabled = enabled;
+
+ if (_currentBehavior == FlyoutBehavior.Flyout)
+ {
+ UpdateLockMode();
+ }
+ }
+
+ ///
+ /// Gets the start padding needed for content when flyout is locked open (padding mode).
+ ///
+ public int GetLockedContentPadding()
+ {
+ if (_currentBehavior != FlyoutBehavior.Locked || _layoutMode != FlyoutLayoutMode.Padding)
+ {
+ return 0;
+ }
+
+ // Use default width if FlyoutWidth is unset (-1/MatchParent)
+ var width = _flyoutWidth <= 0 ? _defaultFlyoutWidth : _flyoutWidth;
+ return (int)width;
+ }
+
+ ///
+ /// Disconnects event listeners and cleans up.
+ ///
+ public virtual void Disconnect()
+ {
+ if (_isListening)
+ {
+ DrawerStateChanged -= OnDrawerStateChanged;
+ DrawerOpened -= OnDrawerOpened;
+ DrawerClosed -= OnDrawerClosed;
+ DrawerSlide -= OnDrawerSlide;
+ _isListening = false;
+ }
+
+ OnPresentedChanged = null;
+ OnSlide = null;
+ }
+
+ #endregion
+
+ #region Layout Methods
+
+ void UpdateLayout()
+ {
+ if (_flyoutView is null || _contentView is null)
+ {
+ return;
+ }
+
+ if (_currentBehavior == FlyoutBehavior.Locked && _layoutMode == FlyoutLayoutMode.SideBySide)
+ {
+ LayoutSideBySide();
+ }
+ else
+ {
+ LayoutAsFlyout();
+ }
+ }
+
+ ///
+ /// Layouts the flyout and content side by side (for tablets in locked mode).
+ ///
+ protected virtual void LayoutSideBySide()
+ {
+ if (_flyoutView is null || _contentView is null)
+ {
+ return;
+ }
+
+ // Create side-by-side container if needed
+ if (_sideBySideView is null)
+ {
+ _sideBySideView = new LinearLayoutCompat(Context!)
+ {
+ Orientation = LinearLayoutCompat.Horizontal,
+ LayoutParameters = new LayoutParams(ViewGroup.LayoutParams.MatchParent,
+ ViewGroup.LayoutParams.MatchParent)
+ };
+ }
+
+ // Add content to side-by-side view
+ if (_contentView.Parent != _sideBySideView)
+ {
+ _contentView.RemoveFromParent();
+
+ var contentParams = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MatchParent,
+ ViewGroup.LayoutParams.MatchParent, 1); // weight 1 to fill remaining space
+
+ _sideBySideView.AddView(_contentView, contentParams);
+ }
+
+ // Add flyout to side-by-side view
+ if (_flyoutView.Parent != _sideBySideView)
+ {
+ _flyoutView.Visibility = ViewStates.Visible;
+ _flyoutView.RemoveFromParent();
+
+ var flyoutParams = new LinearLayoutCompat.LayoutParams((int)FlyoutWidth,
+ ViewGroup.LayoutParams.MatchParent, 0); // weight 0 for fixed width
+
+ _sideBySideView.AddView(_flyoutView, 0, flyoutParams); // Add at index 0 (left)
+ }
+
+ // Add side-by-side view to drawer
+ if (_sideBySideView.Parent != this)
+ {
+ AddView(_sideBySideView);
+ }
+ }
+
+ ///
+ /// Layouts as a standard flyout (drawer over content).
+ ///
+ protected virtual void LayoutAsFlyout()
+ {
+ if (_flyoutView is null || _contentView is null)
+ {
+ return;
+ }
+
+ // Remove side-by-side view
+ _sideBySideView?.RemoveAllViews();
+ _sideBySideView?.RemoveFromParent();
+
+ // Add content to drawer
+ if (_contentView.Parent != this)
+ {
+ _contentView.RemoveFromParent();
+
+ var contentParams = new LayoutParams(
+ ViewGroup.LayoutParams.MatchParent,
+ ViewGroup.LayoutParams.MatchParent);
+
+ AddView(_contentView, 0, contentParams);
+ }
+
+ // Add flyout to drawer (must be after content for proper gesture handling)
+ if (_flyoutView.Parent != this)
+ {
+ _flyoutView.RemoveFromParent();
+
+ var flyoutParams = new LayoutParams((int)FlyoutWidth,
+ ViewGroup.LayoutParams.MatchParent, (int)GravityFlags.Start);
+
+ AddView(_flyoutView, flyoutParams);
+ }
+ }
+
+ #endregion
+
+ #region Lock Mode
+
+ void UpdateLockMode()
+ {
+ int lockMode = _currentBehavior switch
+ {
+ FlyoutBehavior.Disabled => LockModeLockedClosed,
+ FlyoutBehavior.Locked => _layoutMode == FlyoutLayoutMode.SideBySide
+ ? LockModeLockedClosed // In side-by-side, drawer is not used
+ : LockModeLockedOpen,
+ FlyoutBehavior.Flyout => _gestureEnabled
+ ? LockModeUnlocked
+ : LockModeLockedClosed,
+ _ => LockModeUnlocked
+ };
+
+ SetDrawerLockMode(lockMode);
+
+ if (_currentBehavior == FlyoutBehavior.Disabled)
+ {
+ CloseDrawers();
+ }
+ }
+
+ #endregion
+
+ #region Event Handling
+
+ void EnsureListening()
+ {
+ if (_isListening)
+ {
+ return;
+ }
+
+ DrawerStateChanged += OnDrawerStateChanged;
+ DrawerOpened += OnDrawerOpened;
+ DrawerClosed += OnDrawerClosed;
+ DrawerSlide += OnDrawerSlide;
+ _isListening = true;
+ }
+
+ void OnDrawerStateChanged(object? sender, DrawerStateChangedEventArgs e)
+ {
+ if (_flyoutView is null || _flyoutView.Parent != this)
+ {
+ return;
+ }
+
+ // Same pattern as legacy ShellFlyoutRenderer: when the drawer settles to idle,
+ // sync the presented state to match the actual drawer position. This acts as a
+ // safety net ensuring state is correct after the animation completes.
+ if (StateIdle == e.NewState)
+ {
+ var isOpen = IsDrawerOpen(_flyoutView);
+ OnPresentedChanged?.Invoke(isOpen);
+ }
+ }
+
+ void OnDrawerOpened(object? sender, DrawerOpenedEventArgs e)
+ {
+ // Follows the same pattern as the legacy ShellFlyoutRenderer:
+ // set IsPresented directly from DrawerOpened/DrawerClosed callbacks.
+ OnPresentedChanged?.Invoke(true);
+ }
+
+ void OnDrawerClosed(object? sender, DrawerClosedEventArgs e)
+ {
+ // Follows the same pattern as the legacy ShellFlyoutRenderer:
+ // set IsPresented directly from DrawerOpened/DrawerClosed callbacks.
+ OnPresentedChanged?.Invoke(false);
+ }
+
+ void OnDrawerSlide(object? sender, DrawerSlideEventArgs e)
+ {
+ OnSlide?.Invoke(e.SlideOffset);
+ }
+
+ #endregion
+
+ #region Width Calculation
+
+ void UpdateFlyoutViewWidth()
+ {
+ if (_flyoutView?.LayoutParameters is null)
+ {
+ return;
+ }
+
+ _flyoutView.LayoutParameters.Width = (int)FlyoutWidth;
+ _flyoutView.RequestLayout();
+ }
+
+ ///
+ /// Calculates the default flyout width based on Google Material Design guidelines.
+ ///
+ ///
+ /// The right edge of the drawer should be Max(56dp, actionBarSize) from the right edge.
+ /// Maximum width is 6 * actionBarSize.
+ ///
+ static double CalculateDefaultFlyoutWidth(Context context)
+ {
+ var metrics = context.Resources?.DisplayMetrics;
+ if (metrics is null)
+ {
+ return 300; // fallback
+ }
+
+ var width = Math.Min(metrics.WidthPixels, metrics.HeightPixels);
+ var actionBarHeight = (int)context.GetActionBarHeight();
+
+ width -= actionBarHeight;
+
+ var maxWidth = actionBarHeight * 6;
+ width = Math.Min(width, maxWidth);
+
+ return width;
+ }
+
+ #endregion
+
+ #region Dispose
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ Disconnect();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Core/src/Platform/Android/Navigation/NavigationViewFragment.cs b/src/Core/src/Platform/Android/Navigation/NavigationViewFragment.cs
index a5fd1a999391..f562201903ef 100644
--- a/src/Core/src/Platform/Android/Navigation/NavigationViewFragment.cs
+++ b/src/Core/src/Platform/Android/Navigation/NavigationViewFragment.cs
@@ -96,54 +96,17 @@ public override void OnDestroy()
public override Animation OnCreateAnimation(int transit, bool enter, int nextAnim)
{
- int id = 0;
-
- Animation? returnValue;
-
// This means the operation currently being processed shouldn't be animated
// This will happen if a user inserts or removes a root page
if (NavigationManager.IsPopping == null || !NavigationManager.IsAnimated)
{
- returnValue = null;
- }
- else
- {
- // Once we have Function Mappers figured out all of this code can
- // move to a function mapper as a way to customize animations from code
- if (NavigationManager.IsPopping.Value)
- {
- if (!enter)
- {
- id = Resource.Animation.nav_default_pop_exit_anim;
- }
- else
- {
- id = Resource.Animation.nav_default_pop_enter_anim;
- }
- }
- else
- {
- if (enter)
- {
- id = Resource.Animation.nav_default_enter_anim;
- }
- else
- {
- id = Resource.Animation.nav_default_exit_anim;
- }
- }
-
- if (id > 0)
- {
- returnValue = AnimationUtils.LoadAnimation(Context, id);
- }
- else
- {
- returnValue = base.OnCreateAnimation(transit, enter, id);
- }
+ return null!;
}
- return returnValue!;
+ // Delegate to StackNavigationManager which can be overridden for custom animations
+ var animation = NavigationManager.OnCreateNavigationAnimation(Context!, NavigationManager.IsPopping.Value, enter);
+
+ return (animation ?? base.OnCreateAnimation(transit, enter, 0))!;
}
}
}
diff --git a/src/Core/src/Platform/Android/Navigation/StackNavigationManager.cs b/src/Core/src/Platform/Android/Navigation/StackNavigationManager.cs
index a7c298ad5067..9416c2796036 100644
--- a/src/Core/src/Platform/Android/Navigation/StackNavigationManager.cs
+++ b/src/Core/src/Platform/Android/Navigation/StackNavigationManager.cs
@@ -3,6 +3,7 @@
using Android.Content;
using Android.OS;
using Android.Views;
+using Android.Views.Animations;
using AndroidX.Fragment.App;
using AndroidX.Navigation;
using AndroidX.Navigation.Fragment;
@@ -22,6 +23,9 @@ public class StackNavigationManager
FragmentManager? _fragmentManager;
FragmentContainerView? _fragmentContainerView;
Type _navigationViewFragmentType = typeof(NavigationViewFragment);
+ Queue _navigationQueue = new Queue();
+
+ bool _processingQueue;
internal IView? VirtualView { get; private set; }
internal IStackNavigation? NavigationView { get; private set; }
@@ -83,10 +87,9 @@ void ApplyNavigationRequest(NavigationRequest args)
{
if (IsNavigating && OnResumeRequestedArgs is null)
{
- // This should really never fire for the developer. Our xplat code should be handling waiting for navigation to
- // complete before requesting another navigation from Core
- // Maybe some day we'll put a navigation queue into Core? For now we won't
- throw new InvalidOperationException("Previous Navigation Request is still Processing");
+ // Queue the navigation request to be processed after the current one completes
+ _navigationQueue.Enqueue(args);
+ return;
}
if (args.NavigationStack.Count == 0)
@@ -247,12 +250,63 @@ public virtual FragmentNavigator.Destination AddFragmentDestination()
return destination;
}
+ ///
+ /// Creates the animation for a navigation transition. Override to provide custom page transition animations.
+ /// Called by during push/pop navigation.
+ ///
+ /// The Android context for loading animation resources.
+ /// True if navigating back, false if navigating forward.
+ /// True for the entering fragment's animation, false for the exiting fragment's animation.
+ /// The animation to use, or null to skip animation.
+ public virtual Animation? OnCreateNavigationAnimation(Context context, bool isPopping, bool enter)
+ {
+ int id;
+ if (isPopping)
+ {
+ id = enter
+ ? Resource.Animation.nav_default_pop_enter_anim
+ : Resource.Animation.nav_default_pop_exit_anim;
+ }
+ else
+ {
+ id = enter
+ ? Resource.Animation.nav_default_enter_anim
+ : Resource.Animation.nav_default_exit_anim;
+ }
+
+ return id > 0 ? AnimationUtils.LoadAnimation(context, id) : null;
+ }
+
internal void NavigationFinished(IStackNavigation? navigationView)
{
IsInitialNavigation = false;
IsPopping = null;
ActiveRequestedArgs = null;
navigationView?.NavigationFinished(NavigationStack);
+
+ // Process queued navigation requests
+ ProcessNavigationQueue();
+ }
+
+ void ProcessNavigationQueue()
+ {
+ if (_processingQueue || _navigationQueue.Count == 0)
+ return;
+
+ _processingQueue = true;
+
+ try
+ {
+ while (_navigationQueue.Count > 0 && !IsNavigating)
+ {
+ var nextRequest = _navigationQueue.Dequeue();
+ ApplyNavigationRequest(nextRequest);
+ }
+ }
+ finally
+ {
+ _processingQueue = false;
+ }
}
// This occurs when the navigation page is first being renderer so we sync up the
@@ -301,6 +355,8 @@ void UpdateNavigationStack(IReadOnlyList newPageStack)
public virtual void Disconnect()
{
+ _navigationQueue.Clear();
+
if (IsNavigating)
NavigationFinished(NavigationView);
@@ -323,23 +379,42 @@ public virtual void Disconnect()
_fragmentManager = null;
}
- public virtual void Connect(IView navigationView)
+ // Extended to accept an optional FragmentContainerView for Shell sections that
+ // create their own container externally. The default (null) preserves backward
+ // compatibility — callers using the original Connect(IView) signature still work.
+ public virtual void Connect(IView navigationView, FragmentContainerView? fragmentContainerView = null)
{
VirtualView = navigationView;
NavigationView = (IStackNavigation)navigationView;
- _fragmentContainerView = navigationView.Handler?.PlatformView as FragmentContainerView;
+ // For Shell sections the FragmentContainerView is created externally
+ // so we accept it as a parameter.
+ if (fragmentContainerView is not null)
+ {
+ _fragmentContainerView = fragmentContainerView;
+ }
+ else
+ {
+ _fragmentContainerView = navigationView.Handler?.PlatformView as FragmentContainerView;
+ }
_fragmentManager = MauiContext?.GetFragmentManager();
_ = _fragmentManager ?? throw new InvalidOperationException($"GetFragmentManager returned null");
_ = NavigationView ?? throw new InvalidOperationException($"VirtualView cannot be null");
- var navHostFragment = _fragmentManager.FindFragmentById(Resource.Id.nav_host);
+ // Use the container's actual ID instead of hardcoded Resource.Id.nav_host
+ // This allows each Shell tab to have its own unique navigation container
+ var containerId = _fragmentContainerView?.Id ?? Resource.Id.nav_host;
+
+ var navHostFragment = _fragmentManager.FindFragmentById(containerId);
+
SetNavHost(navHostFragment as NavHostFragment);
- if (_navHost == null)
+ if (_navHost is null)
+ {
throw new InvalidOperationException($"No NavHostFragment found");
+ }
if (_fragmentContainerView is not null)
{
diff --git a/src/Core/src/Platform/Android/Resources/Layout/shellitemlayout.axml b/src/Core/src/Platform/Android/Resources/Layout/shellitemlayout.axml
new file mode 100644
index 000000000000..db5d30ef210c
--- /dev/null
+++ b/src/Core/src/Platform/Android/Resources/Layout/shellitemlayout.axml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Core/src/Platform/Android/Resources/Layout/shellsectionlayout.axml b/src/Core/src/Platform/Android/Resources/Layout/shellsectionlayout.axml
new file mode 100644
index 000000000000..ff530cd84603
--- /dev/null
+++ b/src/Core/src/Platform/Android/Resources/Layout/shellsectionlayout.axml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/src/Core/src/Platform/Android/Resources/values/styles.xml b/src/Core/src/Platform/Android/Resources/values/styles.xml
index 2c49a4c21f79..311d7adc47b7 100644
--- a/src/Core/src/Platform/Android/Resources/values/styles.xml
+++ b/src/Core/src/Platform/Android/Resources/values/styles.xml
@@ -21,6 +21,13 @@
+
+
+
+
diff --git a/src/Core/src/Primitives/TabBarPlacement.cs b/src/Core/src/Primitives/TabBarPlacement.cs
new file mode 100644
index 000000000000..19f1906e717d
--- /dev/null
+++ b/src/Core/src/Primitives/TabBarPlacement.cs
@@ -0,0 +1,18 @@
+namespace Microsoft.Maui
+{
+ ///
+ /// Specifies the placement of the tab bar within a tabbed view.
+ ///
+ public enum TabBarPlacement
+ {
+ ///
+ /// Tabs are placed at the top of the view.
+ ///
+ Top = 0,
+
+ ///
+ /// Tabs are placed at the bottom of the view.
+ ///
+ Bottom = 1
+ }
+}
diff --git a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
index b143c1a00237..5a9c15041b88 100644
--- a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
@@ -8,6 +8,7 @@ abstract Microsoft.Maui.HybridWebViewInvoker.InvokeMethodAsync(string! methodNam
*REMOVED*override Microsoft.Maui.Graphics.MauiDrawable.Dispose(bool disposing) -> void
*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
+virtual Microsoft.Maui.Platform.StackNavigationManager.OnCreateNavigationAnimation(Android.Content.Context! context, bool isPopping, bool enter) -> Android.Views.Animations.Animation?
Microsoft.Maui.Handlers.ActivityIndicatorHandler2
Microsoft.Maui.Handlers.ActivityIndicatorHandler2.ActivityIndicatorHandler2() -> void
Microsoft.Maui.Handlers.DatePickerHandler2
@@ -283,3 +284,51 @@ static Microsoft.Maui.Platform.TimePickerExtensions.UpdateTime(this Microsoft.Ma
static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges
virtual Microsoft.Maui.Handlers.DatePickerHandler2.CreateDatePickerDialog(int year, int month, int day) -> Google.Android.Material.DatePicker.MaterialDatePicker?
virtual Microsoft.Maui.Handlers.TimePickerHandler2.CreateTimePickerDialog(int hour, int minute) -> Google.Android.Material.TimePicker.MaterialTimePicker?
+Microsoft.Maui.ITab
+Microsoft.Maui.ITab.Icon.get -> Microsoft.Maui.IImageSource?
+Microsoft.Maui.ITab.IsEnabled.get -> bool
+Microsoft.Maui.ITab.Title.get -> string!
+Microsoft.Maui.Platform.MauiDrawerLayout
+Microsoft.Maui.Platform.MauiDrawerLayout.CloseFlyout(bool animate = true) -> void
+Microsoft.Maui.Platform.MauiDrawerLayout.ContentView.get -> Android.Views.View?
+Microsoft.Maui.Platform.MauiDrawerLayout.CurrentBehavior.get -> Microsoft.Maui.FlyoutBehavior
+Microsoft.Maui.Platform.MauiDrawerLayout.DefaultFlyoutWidth.get -> double
+Microsoft.Maui.Platform.MauiDrawerLayout.FlyoutLayoutMode
+Microsoft.Maui.Platform.MauiDrawerLayout.FlyoutLayoutMode.Flyout = 0 -> Microsoft.Maui.Platform.MauiDrawerLayout.FlyoutLayoutMode
+Microsoft.Maui.Platform.MauiDrawerLayout.FlyoutLayoutMode.Padding = 2 -> Microsoft.Maui.Platform.MauiDrawerLayout.FlyoutLayoutMode
+Microsoft.Maui.Platform.MauiDrawerLayout.FlyoutLayoutMode.SideBySide = 1 -> Microsoft.Maui.Platform.MauiDrawerLayout.FlyoutLayoutMode
+Microsoft.Maui.Platform.MauiDrawerLayout.FlyoutLayoutModeValue.get -> Microsoft.Maui.Platform.MauiDrawerLayout.FlyoutLayoutMode
+Microsoft.Maui.Platform.MauiDrawerLayout.FlyoutLayoutModeValue.set -> void
+Microsoft.Maui.Platform.MauiDrawerLayout.FlyoutView.get -> Android.Views.View?
+Microsoft.Maui.Platform.MauiDrawerLayout.FlyoutWidth.get -> double
+Microsoft.Maui.Platform.MauiDrawerLayout.FlyoutWidth.set -> void
+Microsoft.Maui.Platform.MauiDrawerLayout.GetLockedContentPadding() -> int
+Microsoft.Maui.Platform.MauiDrawerLayout.IsFlyoutOpen.get -> bool
+Microsoft.Maui.Platform.MauiDrawerLayout.IsGestureEnabled.get -> bool
+Microsoft.Maui.Platform.MauiDrawerLayout.MauiDrawerLayout(Android.Content.Context! context) -> void
+Microsoft.Maui.Platform.MauiDrawerLayout.MauiDrawerLayout(Android.Content.Context! context, Android.Util.IAttributeSet? attrs) -> void
+Microsoft.Maui.Platform.MauiDrawerLayout.MauiDrawerLayout(Android.Content.Context! context, Android.Util.IAttributeSet? attrs, int defStyleAttr) -> void
+Microsoft.Maui.Platform.MauiDrawerLayout.MauiDrawerLayout(nint javaReference, Android.Runtime.JniHandleOwnership transfer) -> void
+Microsoft.Maui.Platform.MauiDrawerLayout.OnPresentedChanged -> System.Action?
+Microsoft.Maui.Platform.MauiDrawerLayout.OnSlide -> System.Action?
+Microsoft.Maui.Platform.MauiDrawerLayout.OpenFlyout(bool animate = true) -> void
+Microsoft.Maui.Platform.MauiDrawerLayout.SetBehavior(Microsoft.Maui.FlyoutBehavior behavior) -> void
+Microsoft.Maui.Platform.MauiDrawerLayout.SetContentView(Android.Views.View! contentView) -> void
+Microsoft.Maui.Platform.MauiDrawerLayout.SetFlyoutView(Android.Views.View! flyoutView) -> void
+Microsoft.Maui.Platform.MauiDrawerLayout.SetGestureEnabled(bool enabled) -> void
+Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement
+*REMOVED*override Microsoft.Maui.Handlers.FlyoutViewHandler.ConnectHandler(Android.Views.View! platformView) -> void
+override Microsoft.Maui.Handlers.FlyoutViewHandler.ConnectHandler(Microsoft.Maui.Platform.MauiDrawerLayout! platformView) -> void
+*REMOVED*override Microsoft.Maui.Handlers.FlyoutViewHandler.CreatePlatformView() -> Android.Views.View!
+override Microsoft.Maui.Handlers.FlyoutViewHandler.CreatePlatformView() -> Microsoft.Maui.Platform.MauiDrawerLayout!
+*REMOVED*override Microsoft.Maui.Handlers.FlyoutViewHandler.DisconnectHandler(Android.Views.View! platformView) -> void
+override Microsoft.Maui.Handlers.FlyoutViewHandler.DisconnectHandler(Microsoft.Maui.Platform.MauiDrawerLayout! platformView) -> void
+override Microsoft.Maui.Platform.MauiDrawerLayout.Dispose(bool disposing) -> void
+override Microsoft.Maui.Platform.MauiDrawerLayout.OnInterceptTouchEvent(Android.Views.MotionEvent? ev) -> bool
+virtual Microsoft.Maui.Platform.MauiDrawerLayout.Disconnect() -> void
+virtual Microsoft.Maui.Platform.MauiDrawerLayout.LayoutAsFlyout() -> void
+virtual Microsoft.Maui.Platform.MauiDrawerLayout.LayoutSideBySide() -> void
+*REMOVED*virtual Microsoft.Maui.Platform.StackNavigationManager.Connect(Microsoft.Maui.IView! navigationView) -> void
+virtual Microsoft.Maui.Platform.StackNavigationManager.Connect(Microsoft.Maui.IView! navigationView, AndroidX.Fragment.App.FragmentContainerView? fragmentContainerView = null) -> void
diff --git a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
index f404adc88419..095f8961dec0 100644
--- a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
@@ -17,3 +17,10 @@ static Microsoft.Maui.Handlers.EditorHandler.MapBackground(Microsoft.Maui.Handle
static Microsoft.Maui.Handlers.EntryHandler.MapBackground(Microsoft.Maui.Handlers.IEntryHandler! handler, Microsoft.Maui.IEntry! entry) -> void
static Microsoft.Maui.Handlers.RadioButtonHandler.MapBackground(Microsoft.Maui.Handlers.IRadioButtonHandler! handler, Microsoft.Maui.IRadioButton! radioButton) -> void
static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges
+Microsoft.Maui.ITab
+Microsoft.Maui.ITab.Icon.get -> Microsoft.Maui.IImageSource?
+Microsoft.Maui.ITab.IsEnabled.get -> bool
+Microsoft.Maui.ITab.Title.get -> string!
+Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement
diff --git a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
index f404adc88419..095f8961dec0 100644
--- a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
@@ -17,3 +17,10 @@ static Microsoft.Maui.Handlers.EditorHandler.MapBackground(Microsoft.Maui.Handle
static Microsoft.Maui.Handlers.EntryHandler.MapBackground(Microsoft.Maui.Handlers.IEntryHandler! handler, Microsoft.Maui.IEntry! entry) -> void
static Microsoft.Maui.Handlers.RadioButtonHandler.MapBackground(Microsoft.Maui.Handlers.IRadioButtonHandler! handler, Microsoft.Maui.IRadioButton! radioButton) -> void
static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges
+Microsoft.Maui.ITab
+Microsoft.Maui.ITab.Icon.get -> Microsoft.Maui.IImageSource?
+Microsoft.Maui.ITab.IsEnabled.get -> bool
+Microsoft.Maui.ITab.Title.get -> string!
+Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement
diff --git a/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
index 7c0b1ed097b2..5d4674da0aa6 100644
--- a/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
@@ -5,4 +5,11 @@ Microsoft.Maui.HybridWebViewInvoker.HybridWebViewInvoker(object? invokeJavaScrip
Microsoft.Maui.IHybridWebView.Invoker.get -> Microsoft.Maui.HybridWebViewInvoker!
Microsoft.Maui.IHybridWebView.Invoker.set -> void
abstract Microsoft.Maui.HybridWebViewInvoker.InvokeMethodAsync(string! methodName, string![]? paramJsonValues) -> System.Threading.Tasks.Task!
+Microsoft.Maui.ITab
+Microsoft.Maui.ITab.Icon.get -> Microsoft.Maui.IImageSource?
+Microsoft.Maui.ITab.IsEnabled.get -> bool
+Microsoft.Maui.ITab.Title.get -> string!
+Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement
static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(string! value) -> Microsoft.Maui.GridLength
diff --git a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
index 8a82c536155e..5396517fb8eb 100644
--- a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
@@ -7,5 +7,12 @@ Microsoft.Maui.IHybridWebView.SetInvokeJavaScriptTarget(T! target, System.Tex
abstract Microsoft.Maui.HybridWebViewInvoker.InvokeMethodAsync(string! methodName, string![]? paramJsonValues) -> System.Threading.Tasks.Task!
override Microsoft.Maui.Platform.ContentPanel.OnCreateAutomationPeer() -> Microsoft.UI.Xaml.Automation.Peers.AutomationPeer!
override Microsoft.Maui.Platform.MauiPasswordTextBox.OnCreateAutomationPeer() -> Microsoft.UI.Xaml.Automation.Peers.AutomationPeer!
+Microsoft.Maui.ITab
+Microsoft.Maui.ITab.Icon.get -> Microsoft.Maui.IImageSource?
+Microsoft.Maui.ITab.IsEnabled.get -> bool
+Microsoft.Maui.ITab.Title.get -> string!
+Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement
static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(string! value) -> Microsoft.Maui.GridLength
static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges
diff --git a/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt
index 5316c956a136..faf5d5a701e1 100644
--- a/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt
@@ -5,5 +5,12 @@ Microsoft.Maui.IHybridWebView.Invoker.get -> Microsoft.Maui.HybridWebViewInvoker
Microsoft.Maui.IHybridWebView.Invoker.set -> void
Microsoft.Maui.IHybridWebView.SetInvokeJavaScriptTarget(T! target, System.Text.Json.Serialization.JsonSerializerContext! jsonSerializerContext) -> void
abstract Microsoft.Maui.HybridWebViewInvoker.InvokeMethodAsync(string! methodName, string![]? paramJsonValues) -> System.Threading.Tasks.Task!
+Microsoft.Maui.ITab
+Microsoft.Maui.ITab.Icon.get -> Microsoft.Maui.IImageSource?
+Microsoft.Maui.ITab.IsEnabled.get -> bool
+Microsoft.Maui.ITab.Title.get -> string!
+Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement
static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(string! value) -> Microsoft.Maui.GridLength
static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges
diff --git a/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
index 465ee052aac0..cb1a85056343 100644
--- a/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
@@ -6,3 +6,10 @@ Microsoft.Maui.IHybridWebView.Invoker.set -> void
Microsoft.Maui.IHybridWebView.SetInvokeJavaScriptTarget(T! target, System.Text.Json.Serialization.JsonSerializerContext! jsonSerializerContext) -> void
abstract Microsoft.Maui.HybridWebViewInvoker.InvokeMethodAsync(string! methodName, string![]? paramJsonValues) -> System.Threading.Tasks.Task!
static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges
+Microsoft.Maui.ITab
+Microsoft.Maui.ITab.Icon.get -> Microsoft.Maui.IImageSource?
+Microsoft.Maui.ITab.IsEnabled.get -> bool
+Microsoft.Maui.ITab.Title.get -> string!
+Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement
diff --git a/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
index 465ee052aac0..cb1a85056343 100644
--- a/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
@@ -6,3 +6,10 @@ Microsoft.Maui.IHybridWebView.Invoker.set -> void
Microsoft.Maui.IHybridWebView.SetInvokeJavaScriptTarget(T! target, System.Text.Json.Serialization.JsonSerializerContext! jsonSerializerContext) -> void
abstract Microsoft.Maui.HybridWebViewInvoker.InvokeMethodAsync(string! methodName, string![]? paramJsonValues) -> System.Threading.Tasks.Task!
static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges
+Microsoft.Maui.ITab
+Microsoft.Maui.ITab.Icon.get -> Microsoft.Maui.IImageSource?
+Microsoft.Maui.ITab.IsEnabled.get -> bool
+Microsoft.Maui.ITab.Title.get -> string!
+Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement