diff --git a/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets b/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets
index 6685e1c0515f..06c8f223c880 100644
--- a/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets
+++ b/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets
@@ -380,6 +380,10 @@
Condition="'$(UseMaterial3)' != ''"
Value="$(UseMaterial3)"
Trim="true" />
+
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 75c19f1ffb26..befd3a709215 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, _windowsListener);
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 597a57ddb6ea..97623b17ac79 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 d9a5480a7140..d23ede1530a5 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;
@@ -478,7 +482,9 @@ protected virtual async void UpdateLeftBarButtonItem(Context context, AToolbar t
defaultDrawerArrowDrawable = true;
}
- icon?.Progress = (CanNavigateBack) ? 1 : 0;
+ var canNav = CanNavigateBack;
+ var progress = canNav ? 1 : 0;
+ icon?.Progress = progress;
if (command != null || CanNavigateBack)
{
@@ -507,6 +513,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);
@@ -651,6 +662,18 @@ protected virtual void UpdateToolbarItems(AToolbar toolbar, Page page)
if (SearchHandler != 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 != null && _searchView.SearchHandler != SearchHandler)
+ {
+ _searchView.View.RemoveFromParent();
+ _searchView.View.ViewAttachedToWindow -= OnSearchViewAttachedToWindow;
+ _searchView.SearchConfirmed -= OnSearchConfirmed;
+ _searchView.Dispose();
+ _searchView = null;
+ }
+
if (_searchView == null)
{
_searchView = GetSearchView(context);
@@ -691,6 +714,13 @@ 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 != null)
{
_searchView.View.RemoveFromParent();
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..bcc75a614346
--- /dev/null
+++ b/src/Controls/src/Core/Handlers/Shell/ShellHandler.Android.cs
@@ -0,0 +1,720 @@
+#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;
+ _navigationRoot.SetPadding(padding, _navigationRoot.PaddingTop, _navigationRoot.PaddingRight, _navigationRoot.PaddingBottom);
+ }
+ else
+ {
+ _navigationRoot?.SetPadding(0, _navigationRoot.PaddingTop, _navigationRoot.PaddingRight, _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();
+ }
+ }
+ }
+
+ 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)
+ {
+ // Use the new handler-based architecture
+ var handler = 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);
+ }
+
+ #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 new ShellContentFragment(this, page);
+ }
+
+ IShellFlyoutContentRenderer IShellContext.CreateShellFlyoutContentRenderer()
+ {
+ return new ShellFlyoutTemplatedContentRenderer(this);
+ }
+
+ IShellItemRenderer IShellContext.CreateShellItemRenderer(ShellItem shellItem)
+ {
+ return CreateShellItemRenderer(shellItem);
+ }
+
+ IShellSectionRenderer IShellContext.CreateShellSectionRenderer(ShellSection shellSection)
+ {
+ var handler = new ShellSectionHandler();
+ handler.SetMauiContext(MauiContext!);
+ handler.SetVirtualView(shellSection);
+
+ return new ShellSectionHandlerAdapter(handler, MauiContext!);
+ }
+
+ IShellToolbarTracker IShellContext.CreateTrackerForToolbar(AToolbar toolbar)
+ {
+ return new ShellToolbarTracker(this, toolbar, MauiDrawerLayout);
+ }
+
+ IShellToolbarAppearanceTracker IShellContext.CreateToolbarAppearanceTracker()
+ {
+ return new ShellToolbarAppearanceTracker(this);
+ }
+
+ IShellTabLayoutAppearanceTracker IShellContext.CreateTabLayoutAppearanceTracker(ShellSection shellSection)
+ {
+ return new ShellTabLayoutAppearanceTracker(this);
+ }
+
+ IShellBottomNavViewAppearanceTracker IShellContext.CreateBottomNavViewAppearanceTracker(ShellItem shellItem)
+ {
+ return new ShellBottomNavViewAppearanceTracker(this, 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();
+ 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 47b43fed67e1..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,29 +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..bbeb87c7a40f
--- /dev/null
+++ b/src/Controls/src/Core/Handlers/Shell/ShellItemHandler.Android.cs
@@ -0,0 +1,1385 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+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.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;
+ ShellSection? _shellSection;
+ Page? _displayedPage;
+ bool _isNavigating; // Prevent recursive navigation
+ bool _preserveFragmentResources; // During SwitchToShellItem, preserve fragment-level resources
+ bool _switchingShellItem; // During SwitchToShellItem, suppress mapper-triggered SwitchToSection
+
+ // 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
+ {
+ // First time: create adapter and register callback
+ if (_pageChangeCallback is not null)
+ {
+ _viewPager.UnregisterOnPageChangeCallback(_pageChangeCallback);
+ _pageChangeCallback = null;
+ }
+
+ _adapter = new ShellSectionFragmentAdapter(
+ _parentFragment.ChildFragmentManager,
+ _parentFragment.Lifecycle,
+ shellSections,
+ _shellContext);
+
+ _viewPager.Adapter = _adapter;
+
+ _pageChangeCallback = new ShellItemPageChangeCallback(this);
+ _viewPager.RegisterOnPageChangeCallback(_pageChangeCallback);
+ }
+
+ // 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);
+ }
+
+ ///
+ /// 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)
+ _tabbedViewManager = new TabbedViewManager(MauiContext, _viewPager);
+ _tabbedViewManager.OnTabSelected = OnTabbedViewTabSelected;
+
+ // 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;
+ }
+
+ ///
+ /// Callback from TabbedViewManager when a bottom tab is selected.
+ /// Routes to Shell section switching via ProposeSection.
+ ///
+ void OnTabbedViewTabSelected(int index)
+ {
+ if (VirtualView is null || _isNavigating)
+ {
+ return;
+ }
+
+ var items = ((IShellItemController)VirtualView).GetItems();
+
+ if (items is null || index < 0 || index >= items.Count)
+ {
+ return;
+ }
+
+ // Update ViewPager2 position
+ if (_viewPager is not null && _viewPager.CurrentItem != index)
+ {
+ _isNavigating = true;
+ _viewPager.SetCurrentItem(index, true);
+ _isNavigating = false;
+ }
+
+ var selectedSection = items[index];
+
+ if (selectedSection != VirtualView.CurrentItem)
+ {
+ ((IShellItemController)VirtualView).ProposeSection(selectedSection);
+ }
+ }
+
+ ///
+ /// Called when ViewPager2 page changes.
+ ///
+ internal void OnPageSelected(int position)
+ {
+ if (VirtualView is null)
+ {
+ return;
+ }
+
+ var items = ((IShellItemController)VirtualView).GetItems();
+
+ if (items is null || position < 0 || position >= items.Count)
+ {
+ return;
+ }
+
+ var selectedSection = items[position];
+
+ // Skip the rest if we're already navigating programmatically
+ if (_isNavigating)
+ {
+ return;
+ }
+
+ _isNavigating = true;
+
+ // Remember previous section for top tab cleanup
+ var previousSection = _shellSection;
+
+ // Update bottom navigation selection
+ _tabbedViewManager?.SetSelectedTab(position);
+
+ // Use ProposeSection instead of direct property set to fire Shell.Navigating event
+ // and support navigation cancellation (matches old ShellItemRenderer.ChangeSection behavior)
+ if (selectedSection != VirtualView.CurrentItem)
+ {
+ ((IShellItemController)VirtualView).ProposeSection(selectedSection);
+ }
+
+ // Track the current section
+ _shellSection = selectedSection;
+
+ // Update top tabs: remove old section's tabs and evaluate new section's
+ NotifyTopTabsForSectionSwitch(previousSection, selectedSection);
+
+ // Track displayed page changes
+ ((IShellSectionController)selectedSection).AddDisplayedPageObserver(this, UpdateDisplayedPage);
+
+ _isNavigating = false;
+
+ // Update toolbar title/items for the new section AFTER CurrentItem is set
+ // This handles title updates - appearance is updated via the observer pattern
+ UpdateToolbarForSection(selectedSection);
+ }
+
+ ///
+ /// 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;
+ }
+
+ // Switch ViewPager2 to the new section
+ if (_viewPager.CurrentItem != index)
+ {
+ _isNavigating = true;
+ _viewPager.SetCurrentItem(index, animate);
+ _isNavigating = false;
+ }
+
+ // 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;
+ SetVirtualView(newItem);
+ _switchingShellItem = false;
+
+ _preserveFragmentResources = false;
+
+ // Rebuild ViewPager2 adapter for new ShellItem's sections
+ SetupViewPagerAdapter();
+
+ // Rebuild bottom navigation for new ShellItem's sections via TabbedViewManager
+ RebuildBottomNavigation();
+
+ // 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
+
+ ///
+ /// Hook up property change events for a shell section.
+ /// Kept as empty virtual for backward compatibility — property changes are now
+ /// handled via ShellSectionHandler mapper (Title, Icon, IsEnabled).
+ ///
+ protected virtual void HookChildEvents(ShellSection shellSection)
+ {
+ }
+
+ ///
+ /// Unhook property change events for a shell section.
+ ///
+ protected virtual void UnhookChildEvents(ShellSection shellSection)
+ {
+ if (shellSection is null)
+ {
+ return;
+ }
+
+ ((IShellSectionController)shellSection).RemoveDisplayedPageObserver(this);
+ }
+
+ ///
+ /// 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);
+ }
+ }
+
+ ///
+ /// 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();
+ }
+
+ ///
+ /// 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. Returns true if navigation was handled, false otherwise.
+ /// Back navigation is delegated to the current section's StackNavigationManager.
+ ///
+ internal bool OnBackButtonPressed()
+ {
+ if (_shellSection is null)
+ {
+ return false;
+ }
+
+ var stack = _shellSection.Stack;
+
+ // If we're at the root page, don't handle back - let the system handle it
+ if (stack.Count <= 1)
+ {
+ return false;
+ }
+
+ // We have pages in the stack, so we can pop
+ Task.Run(async () =>
+ {
+ try
+ {
+ await _shellSection.Navigation.PopAsync();
+ }
+ catch (Exception)
+ {
+ }
+ });
+
+ return true; // We handled the back press
+ }
+
+ #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.SwitchToSection(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)
+ {
+ // 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)
+ {
+ var shellSections = ((IShellItemController)VirtualView).GetItems();
+ _viewPager.OffscreenPageLimit = Math.Max(shellSections.Count, 1);
+ _adapter.UpdateSections(shellSections);
+ }
+
+ // Rebuild the bottom navigation menu for the updated sections via TabbedViewManager
+ _tabbedViewManager?.RefreshTabs();
+ UpdateTabBarVisibility();
+ }
+
+ 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
+ var shell = VirtualView?.FindParentOfType();
+
+ if (shell is not null)
+ {
+ ((IShellController)shell).RemoveAppearanceObserver(this);
+ }
+
+ // Dispose per-item appearance tracker (ConnectHandler recreates for new item)
+ _appearanceTracker?.Dispose();
+ _appearanceTracker = 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;
+
+ if (_pageChangeCallback is not null)
+ {
+ platformView.UnregisterOnPageChangeCallback(_pageChangeCallback);
+ _pageChangeCallback = 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)
+ {
+ ((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();
+ }
+
+ // Force back button visibility update
+ _shellToolbar?.Handler?.UpdateValue(nameof(IToolbar.BackButtonVisible));
+
+ // 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);
+ _wrapperFragment?.Dispose();
+ _wrapperFragment = null;
+ }
+
+ ///
+ /// 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).
+ ///
+ class ShellItemWrapperFragment : Fragment
+ {
+ readonly ShellItemHandler _handler;
+ CoordinatorLayout? _rootLayout;
+ ShellBackPressedCallback? _backPressedCallback;
+
+ 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)
+ {
+ // 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);
+
+ // Setup back button handling
+ _backPressedCallback = new ShellBackPressedCallback(_handler);
+ 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();
+
+ // 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;
+
+ public ShellBackPressedCallback(ShellItemHandler handler) : base(true)
+ {
+ _handler = handler;
+ }
+
+ public override void HandleOnBackPressed()
+ {
+ // Let the handler try to handle the back press
+ if (!_handler.OnBackButtonPressed())
+ {
+ // Handler didn't handle it (we're at root), let system handle it
+ this.Enabled = false;
+ // The system will handle app exit
+ this.Enabled = true;
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// ViewPager2 page change callback for ShellItemHandler.
+ ///
+ internal class ShellItemPageChangeCallback : ViewPager2.OnPageChangeCallback
+ {
+ readonly ShellItemHandler _handler;
+
+ public ShellItemPageChangeCallback(ShellItemHandler handler)
+ {
+ _handler = handler;
+ }
+
+ public override void OnPageSelected(int 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);
+
+ // Clear position-based renderers (positions may differ for new sections)
+ _renderers.Clear();
+
+ _sections = newSections;
+ NotifyDataSetChanged();
+ }
+
+ public override Fragment CreateFragment(int position)
+ {
+ var section = _sections[position];
+
+ // Create or reuse section renderer
+ if (!_renderers.TryGetValue(position, out var renderer))
+ {
+ renderer = _shellContext.CreateShellSectionRenderer(section);
+ renderer.ShellSection = section;
+ _renderers[position] = renderer;
+ }
+
+ // Get the fragment from the renderer
+ if (renderer is IShellObservableFragment observableFragment)
+ {
+ return observableFragment.Fragment;
+ }
+
+ throw new InvalidOperationException($"ShellSectionRenderer for {section.Title} is not an IShellObservableFragment");
+ }
+
+ public IShellSectionRenderer? GetRenderer(int position)
+ {
+ _renderers.TryGetValue(position, out var renderer);
+ return renderer;
+ }
+ }
+
+ ///
+ /// Helper class to implement NavigationBarView.IOnItemSelectedListener
+ ///
+ internal class GenericNavigationItemSelectedListener : Java.Lang.Object, NavigationBarView.IOnItemSelectedListener
+ {
+ readonly Func _callback;
+
+ public GenericNavigationItemSelectedListener(Func callback)
+ {
+ _callback = callback ?? throw new ArgumentNullException(nameof(callback));
+ }
+
+ public bool OnNavigationItemSelected(IMenuItem item)
+ {
+ return _callback(item);
+ }
+ }
+}
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..2c6fcc2cdcdc
--- /dev/null
+++ b/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Android.cs
@@ -0,0 +1,1415 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Linq;
+using Android.OS;
+using Android.Views;
+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 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;
+ LinearLayout? _rootLayout;
+ ViewPager2? _viewPager;
+ TabLayout? _contentTabLayout;
+ ShellContentFragmentAdapter? _adapter;
+ TabbedViewManager? _tabbedViewManager;
+ ShellSectionTabbedViewAdapter? _shellSectionAdapter;
+
+ ///
+ /// 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,
+ };
+
+ ///
+ /// 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;
+ }
+
+ 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
+ var 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)
+ {
+ ((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;
+ }
+
+ // 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;
+
+ // Remove top tabs via TabbedViewManager
+ RemoveTopTabs();
+
+ // Cleanup TabbedViewManager
+ _tabbedViewManager?.SetElement(null);
+ _tabbedViewManager = null;
+ _shellSectionAdapter = null;
+
+ // Cleanup adapter
+ _adapter = null;
+ _viewPager?.Adapter = null;
+ _viewPager = null;
+
+ // Cleanup TabLayout
+ _contentTabLayout = null;
+
+ // Unregister appearance observer
+ var shell = VirtualView?.FindParentOfType();
+ if (shell is not null)
+ {
+ ((IShellController)shell).RemoveAppearanceObserver(this);
+ }
+
+ // 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)
+ {
+ 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);
+ }
+ }
+
+ ///
+ /// 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;
+ 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).
+ ///
+ public void OnItemsCollectionChanged()
+ {
+ _visibleItems = SectionController.GetItems();
+ }
+
+ 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);
+ }
+
+ public override long GetItemId(int position)
+ {
+ if (_visibleItems is null || position >= _visibleItems.Count)
+ {
+ return -1;
+ }
+ return _visibleItems[position].GetHashCode();
+ }
+
+ public override bool ContainsItem(long itemId)
+ {
+ if (_visibleItems is null)
+ {
+ return false;
+ }
+ foreach (var item in _visibleItems)
+ {
+ if (item.GetHashCode() == itemId)
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ ///
+ /// ViewPager2 page change callback to update toolbar when swiping between content tabs.
+ ///
+ internal class ViewPagerPageChangeCallback : ViewPager2.OnPageChangeCallback
+ {
+ readonly ShellSectionHandler _handler;
+
+ public ViewPagerPageChangeCallback(ShellSectionHandler handler)
+ {
+ _handler = handler;
+ }
+
+ public override void OnPageSelected(int position)
+ {
+ base.OnPageSelected(position);
+
+ 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];
+ var page = ((IShellContentController)newCurrentItem).GetOrCreateContent();
+
+ if (page is null)
+ {
+ return;
+ }
+
+ // Update toolbar title
+ var toolbarTracker = _handler.ToolbarTracker;
+ toolbarTracker?.Page = page;
+
+ // Update CurrentItem
+ if (virtualView.CurrentItem != newCurrentItem)
+ {
+ virtualView.CurrentItem = newCurrentItem;
+ }
+
+ // Trigger appearance update
+ var shell = virtualView.FindParentOfType();
+ if (shell is not null)
+ {
+ ((IShellController)shell).AppearanceChanged(page, false);
+ }
+ }
+ }
+
+ ///
+ /// Fragment that hosts a ShellContent page with its own StackNavigationManager.
+ /// This enables each content tab to have independent navigation.
+ ///
+ internal class ShellContentNavigationFragment : Fragment, IStackNavigation
+ {
+ ShellContent? _shellContent;
+ IMauiContext? _mauiContext;
+ ShellSectionHandler? _handler;
+ StackNavigationManager? _stackNavigationManager;
+ FragmentContainerView? _navigationContainer;
+ Page? _rootPage;
+ int _navigationContainerId;
+ ShellContentStackNavigationView? _navigationViewAdapter;
+
+ // 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)
+ {
+ // 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 null!;
+ }
+
+ // 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)
+ {
+ ((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 = 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);
+
+ // 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)
+ {
+ ((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));
+
+ // Clear any pending navigation requests — they are already reflected in
+ // the shellSection.Stack that we used to build the initial stack above.
+ // Processing them would re-apply pushes that are already in the stack,
+ // corrupting the navigation state (e.g., duplicate pages).
+ _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)
+ {
+ _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();
+
+ List BuildNavigationStack(NavigationRequestedEventArgs e)
+ {
+ var currentStack = _stackNavigationManager?.NavigationStack ?? 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)
+ {
+ // 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();
+ }
+ }
+
+ public override void OnDestroyView()
+ {
+ // 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();
+
+ if (_subscribedToNavigationRequested)
+ {
+ var shellSection = _shellContent?.Parent as ShellSection;
+ if (shellSection is not null)
+ {
+ ((IShellSectionController)shellSection).NavigationRequested -= OnNavigationRequested;
+ }
+ _subscribedToNavigationRequested = false;
+ }
+
+ base.OnDestroyView();
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _navigationContainer?.ViewAttachedToWindow -= OnNavigationContainerAttached;
+
+ if (_subscribedToNavigationRequested)
+ {
+ var shellSection = _shellContent?.Parent as ShellSection;
+ if (shellSection is not null)
+ {
+ ((IShellSectionController)shellSection).NavigationRequested -= OnNavigationRequested;
+ }
+ _subscribedToNavigationRequested = false;
+ }
+
+ _pendingNavigationRequests.Clear();
+ _navigationViewAdapter = null;
+ _stackNavigationManager?.Disconnect();
+ _stackNavigationManager = null;
+ _navigationContainer = 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;
+ }
+
+ ///
+ /// 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);
+ _wrapperFragment?.Dispose();
+ _wrapperFragment = null;
+ }
+
+ ///
+ /// Simple wrapper fragment - just returns the handler's unified view.
+ ///
+ 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..a0ac92ad8d95
--- /dev/null
+++ b/src/Controls/src/Core/Handlers/Shell/ShellTabbedViewAdapters.Android.cs
@@ -0,0 +1,166 @@
+#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 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
+ {
+ if (value is ShellContentTab tab)
+ {
+ _shellSection.CurrentItem = tab.Content;
+ }
+ }
+ }
+
+ 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 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;
+ }
+
+ ///
+ /// 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;
+ }
+}
diff --git a/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs b/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs
index cb496527128f..87d6b8b947e5 100644
--- a/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs
+++ b/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs
@@ -221,13 +221,24 @@ 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
+ if (RuntimeFeature.UseAndroidShellHandlers)
+ {
+ handlersCollection.AddHandler();
+ handlersCollection.AddHandler();
+ handlersCollection.AddHandler();
+ }
+ else
+ {
+ handlersCollection.AddHandler();
+ }
#elif TIZEN
handlersCollection.AddHandler();
handlersCollection.AddHandler();
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..6c0673396be8
--- /dev/null
+++ b/src/Controls/src/Core/Platform/Android/ITabbedViewSource.cs
@@ -0,0 +1,32 @@
+#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; }
+ event NotifyCollectionChangedEventHandler TabsChanged;
+}
diff --git a/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs b/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs
index d1f243c972c5..e8fd4b4f590f 100644
--- a/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs
+++ b/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs
@@ -3,173 +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)
+ // 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)
{
- _bottomNavigationView = new BottomNavigationView(_context.Context)
- {
- LayoutParameters = new CoordinatorLayout.LayoutParams(AppBarLayout.LayoutParams.MatchParent, AppBarLayout.LayoutParams.WrapContent)
- {
- Gravity = (int)GravityFlags.Bottom
- }
- };
- }
- else
- {
- 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;
}
}
@@ -178,52 +113,14 @@ 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)
{
- RemoveTabs();
+ _tabbedViewManager.RemoveTabs();
}
protected virtual void OnTabbedPageAppearing(object sender, EventArgs e)
{
- SetTabLayout();
+ _tabbedViewManager.SetTabLayout();
}
protected virtual void RootViewChanged(object sender, EventArgs e)
@@ -231,621 +128,208 @@ 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;
-
- SetContentBottomMargin(_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();
- });
- }
- }
+ #endregion
- 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);
- });
- }
+ var tabIndex = Element.Children.IndexOf(page);
+ var tabs = ((ITabbedView)Element).Tabs;
- public virtual void UpdateBarBackgroundColor()
- {
- if (Element.BarBackground != null)
- return;
-
- if (IsBottomTabPlacement)
+ if (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;
- }
-
- 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)
+ 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);
- }
- }
- }
-
- void SetupBottomNavigationViewIconColor(Page page, IMenuItem menuItem, int i)
- {
- // 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)
- {
- var itemView = menuView.GetChildAt(i) as BottomNavigationItemView;
-
- if (itemView != null && itemView.Id == menuItem.ItemId)
- {
- ColorStateList colors = GetItemIconTintColorState(page);
-
- itemView.SetIconTintList(colors);
- }
- }
-#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()
@@ -853,212 +337,108 @@ 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;
+ var tabIndex = Element.Children.IndexOf(page);
+ _tabbedViewManager.SetIconColorFilter(tabIndex, tab, selected);
+ }
- ColorStateList colors = GetItemIconTintColorState(page);
- if (colors == null)
- ADrawableCompat.SetTintList(icon, null);
- else
- {
- int[] _stateSet = null;
+ #endregion
- if (selected)
- _stateSet = GetSelectedStateSet();
- else
- _stateSet = GetEmptyStateSet();
+ #region VP2 Page Change Callbacks
- 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();
- }
-
- 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;
+ public IReadOnlyList Tabs =>
+ _tabbedPage.Children.Select(p => (ITab)new TabbedPage.PageTabAdapter(p)).ToList();
- if (_previousPage != Element.CurrentPage)
- {
- _previousPage?.SendDisappearing();
- _previousPage = Element.CurrentPage;
- _tabbedPageManager.previousPage = Element.CurrentPage;
- }
-
- // 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 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..6a9720ccb716
--- /dev/null
+++ b/src/Controls/src/Core/Platform/Android/TabbedViewManager.cs
@@ -0,0 +1,1385 @@
+#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 _orignalTabIconColors;
+ ColorStateList _newTabTextColors;
+ ColorStateList _newTabIconColors;
+ FragmentManager _fragmentManager;
+ TabLayout _tabLayout;
+ BottomNavigationView _bottomNavigationView;
+ ColorStateList _originalBnvItemTextColors;
+ ColorStateList _originalBnvItemIconTintColors;
+ 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; }
+
+ protected NavigationRootManager NavigationRootManager { get; }
+ public static bool IsDarkTheme => (Application.Current?.RequestedTheme ?? AppInfo.RequestedTheme) == AppTheme.Dark;
+
+ #region Static Factory Methods
+
+ static BottomNavigationView CreateBottomNavigationView(
+ Context context,
+ ViewGroup.LayoutParams layoutParams = null)
+ {
+ var bottomNav = new BottomNavigationView(context, null, Resource.Attribute.bottomNavigationViewStyle)
+ {
+ LayoutParameters = layoutParams ?? new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MatchParent,
+ ViewGroup.LayoutParams.WrapContent),
+ 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();
+
+ if (_managesViewPager)
+ {
+ _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)
+ {
+ _bottomNavigationView = CreateBottomNavigationView(
+ _context.Context,
+ new CoordinatorLayout.LayoutParams(AppBarLayout.LayoutParams.MatchParent, AppBarLayout.LayoutParams.WrapContent)
+ {
+ Gravity = (int)GravityFlags.Bottom
+ });
+
+ // Store original colors for restoration when custom colors are cleared
+ _originalBnvItemTextColors = _bottomNavigationView.ItemTextColor;
+ _originalBnvItemIconTintColors = _bottomNavigationView.ItemIconTintList;
+ }
+ 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)
+ {
+ 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(_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 (_managesViewPager)
+ {
+ ViewPager2 pager = _viewPager;
+ pager.Adapter?.NotifyDataSetChanged();
+ }
+
+ if (IsBottomTabPlacement)
+ {
+ BottomNavigationView bottomNavigationView = _bottomNavigationView;
+
+ if (_managesViewPager)
+ {
+ NotifyDataSetChanged();
+ }
+
+ if (Element.Tabs.Count == 0)
+ {
+ bottomNavigationView.Menu.Clear();
+ _bottomNavigationView?.SetOnItemSelectedListener(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.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 && _bottomNavigationView.SelectedItemId != index)
+ {
+ _bottomNavigationView.SelectedItemId = index;
+ }
+ }
+ 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
+ if (tabs.Count == 1)
+ {
+ _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);
+ }
+
+ 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);
+ }
+ }
+ catch (Exception)
+ {
+ }
+ }
+
+ 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);
+ SetIconColorFilter(Element.Tabs.IndexOf(tabItem), 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 as Element;
+ newGradientBrush.InvalidateGradientBrushRequested += OnBarBackgroundChanged;
+ }
+
+ 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 (_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()
+ {
+ return GetDefaultColorFromTheme(_context.Context);
+ }
+
+ 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;
+
+ if (IsBottomTabPlacement)
+ {
+ if (_bottomNavigationView is not null)
+ {
+ if (barItemColor is null && barSelectedItemColor is null)
+ {
+ _bottomNavigationView.ItemTextColor = _originalBnvItemTextColors;
+ _bottomNavigationView.ItemIconTintList = _originalBnvItemIconTintColors;
+ }
+ else
+ {
+ int unselectedArgb = barItemColor?.ToPlatform().ToArgb() ?? GetDefaultColorFromTheme(_context.Context);
+ int selectedArgb = barSelectedItemColor?.ToPlatform().ToArgb() ?? GetDefaultColorFromTheme(_context.Context);
+ var colorStateList = GetColorStateList(unselectedArgb, selectedArgb);
+ _bottomNavigationView.ItemTextColor = colorStateList;
+ _bottomNavigationView.ItemIconTintList = colorStateList;
+ }
+ }
+ }
+ else
+ {
+ 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,
+ 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)
+ {
+ _bottomNavigationView.SelectedItemId = position;
+ }
+
+ _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)
+ {
+ var items = _manager.CreateTabList();
+ var bottomSheetDialog = BottomNavigationViewUtils.CreateMoreBottomSheet(
+ _manager.OnMoreItemSelectedInternal,
+ _manager._context,
+ items,
+ Math.Min(_manager._bottomNavigationView.MaxItemCount, BottomNavigationViewUtils.MaxBottomNavigationItems));
+ bottomSheetDialog.DismissEvent += (s, e) => _manager.OnMoreSheetDismissedInternal(bottomSheetDialog);
+ bottomSheetDialog.Show();
+ }
+ else
+ {
+ if (_manager._bottomNavigationView.SelectedItemId != item.ItemId && _manager.Element.Tabs.Count > item.ItemId)
+ {
+ _manager.Element.CurrentTab = _manager.Element.Tabs[item.ItemId];
+ _manager.OnTabSelected?.Invoke(item.ItemId);
+ }
+ }
+
+ return true;
+ }
+
+ void TabLayout.IOnTabSelectedListener.OnTabReselected(TabLayout.Tab tab)
+ {
+ }
+
+ 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 591649f38abb..80e1525bdb9e 100644
--- a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt
+++ b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt
@@ -47,3 +47,46 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui
~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.OnInterceptTouchEvent(Android.Views.MotionEvent e) -> bool
~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.OnTouchEvent(Android.Views.MotionEvent e) -> bool
~static Microsoft.Maui.Controls.VisualStateManager.GetVisualStateGroups(Microsoft.Maui.Controls.VisualElement visualElement) -> Microsoft.Maui.Controls.VisualStateGroupList
+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.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.ShellItemHandler.HookChildEvents(Microsoft.Maui.Controls.ShellSection! shellSection) -> void
+virtual Microsoft.Maui.Controls.Handlers.ShellItemHandler.UnhookChildEvents(Microsoft.Maui.Controls.ShellSection! shellSection) -> void
+virtual Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutTemplatedContentRenderer.UpdateVerticalScrollMode() -> void
diff --git a/src/Controls/src/Core/Shell/ShellContent.cs b/src/Controls/src/Core/Shell/ShellContent.cs
index 68c3f10a8ceb..9a25cdf3ca5f 100644
--- a/src/Controls/src/Core/Shell/ShellContent.cs
+++ b/src/Controls/src/Core/Shell/ShellContent.cs
@@ -283,10 +283,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/TabbedPage/TabbedPage.cs b/src/Controls/src/Core/TabbedPage/TabbedPage.cs
index dd27c460bf0a..12ac0aff96f5 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,58 @@ void OnPagePropertyChanged(object sender, System.ComponentModel.PropertyChangedE
Handler?.UpdateValue(TabbedPage.ItemsSourceProperty.PropertyName);
}
}
+
+ #region ITabbedView explicit implementation
+
+ IReadOnlyList ITabbedView.Tabs => Children.Select(p => (ITab)new PageTabAdapter(p)).ToList();
+
+ ITab ITabbedView.CurrentTab
+ {
+ get => CurrentPage is not null ? new PageTabAdapter(CurrentPage) : null;
+ set
+ {
+ // Find the matching page by title/icon
+ if (value is PageTabAdapter adapter)
+ CurrentPage = adapter.Page;
+ }
+ }
+
+ object ITabbedView.BarBackground => BarBackground;
+
+ TabBarPlacement ITabbedView.TabBarPlacement =>
+ PlatformConfiguration.AndroidSpecific.TabbedPage.GetToolbarPlacement(this) == PlatformConfiguration.AndroidSpecific.ToolbarPlacement.Bottom
+ ? TabBarPlacement.Bottom
+ : TabBarPlacement.Top;
+
+ int ITabbedView.OffscreenPageLimit =>
+#pragma warning disable CS0618 // Type or member is obsolete
+ PlatformConfiguration.AndroidSpecific.TabbedPage.GetOffscreenPageLimit(this);
+#pragma warning restore CS0618
+
+ bool ITabbedView.IsSwipePagingEnabled =>
+ PlatformConfiguration.AndroidSpecific.TabbedPage.GetIsSwipePagingEnabled(this);
+
+ bool ITabbedView.IsSmoothScrollEnabled =>
+ PlatformConfiguration.AndroidSpecific.TabbedPage.GetIsSmoothScrollEnabled(this);
+
+ event NotifyCollectionChangedEventHandler ITabbedView.TabsChanged
+ {
+ add => PagesChanged += value;
+ remove => PagesChanged -= value;
+ }
+
+ #endregion
+
+ ///
+ /// 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/DeviceTests/Controls.DeviceTests.csproj b/src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj
index 0112ffec5537..4ff7c216786e 100644
--- a/src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj
+++ b/src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj
@@ -15,6 +15,8 @@
android-arm64;android-x64
true
true
+
+ true
diff --git a/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj b/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj
index d6befbbd7449..0a2f5ceb57ad 100644
--- a/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj
+++ b/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj
@@ -18,6 +18,8 @@
false
+
+ true
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/Core/ITabbedView.cs b/src/Core/src/Core/ITabbedView.cs
index 1998cba343de..cd92e8b3b430 100644
--- a/src/Core/src/Core/ITabbedView.cs
+++ b/src/Core/src/Core/ITabbedView.cs
@@ -1,4 +1,8 @@
-namespace Microsoft.Maui
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using Microsoft.Maui.Graphics;
+
+namespace Microsoft.Maui
{
///
/// Represents a View that consists of a list of tabs and a larger detail area,
@@ -6,5 +10,64 @@
///
public interface ITabbedView : IView
{
+ ///
+ /// Gets the collection of tabs.
+ ///
+ IReadOnlyList Tabs { get; }
+
+ ///
+ /// Gets or sets the currently selected tab.
+ ///
+ ITab? CurrentTab { get; set; }
+
+ ///
+ /// Gets the background color of the tab bar.
+ ///
+ Color? BarBackgroundColor { get; }
+
+ ///
+ /// Gets the background of the tab bar. Typically a Brush or Paint (supports gradients).
+ ///
+ object BarBackground { get; }
+
+ ///
+ /// Gets the text color of the tab bar items.
+ ///
+ Color? BarTextColor { get; }
+
+ ///
+ /// Gets the color for unselected tab items.
+ ///
+ Color? UnselectedTabColor { get; }
+
+ ///
+ /// Gets the color for the selected tab item.
+ ///
+ Color? SelectedTabColor { get; }
+
+ ///
+ /// Gets the placement of the tab bar (top or bottom).
+ ///
+ TabBarPlacement TabBarPlacement { get; }
+
+ ///
+ /// Gets the number of offscreen pages to retain in the ViewPager.
+ ///
+ int OffscreenPageLimit { get; }
+
+ ///
+ /// Gets whether swipe-based paging between tabs is enabled.
+ ///
+ bool IsSwipePagingEnabled { get; }
+
+ ///
+ /// Gets whether smooth scroll animation is used when switching tabs.
+ ///
+ bool IsSmoothScrollEnabled { get; }
+
+ ///
+ /// Raised when the tab collection changes.
+ ///
+ event NotifyCollectionChangedEventHandler? TabsChanged;
}
}
diff --git a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs
index fac7d6e86772..97e6397cb031 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;
}
@@ -138,127 +136,44 @@ 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);
}
+ // 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()
@@ -270,20 +185,17 @@ void UpdateFlyoutBehavior()
// 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);
@@ -292,14 +204,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)
@@ -308,11 +218,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)
{
@@ -325,10 +236,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)
@@ -376,7 +288,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..a2ee8ca426e1
--- /dev/null
+++ b/src/Core/src/Platform/Android/MauiDrawerLayout.cs
@@ -0,0 +1,557 @@
+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 && IsDrawerOpen(_flyoutView);
+
+ ///
+ /// Gets or sets the flyout width. -1 for default width.
+ ///
+ public double FlyoutWidth
+ {
+ get => _flyoutWidth == -1 ? _defaultFlyoutWidth : _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 && 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 left padding needed for content when flyout is locked open (padding mode).
+ ///
+ public int GetLockedContentPadding()
+ {
+ return _currentBehavior == FlyoutBehavior.Locked && _layoutMode == FlyoutLayoutMode.Padding
+ ? (int)FlyoutWidth
+ : 0;
+ }
+
+ ///
+ /// 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)
+ {
+ return;
+ }
+
+ if (StateIdle == e.NewState)
+ {
+ var isOpen = IsDrawerOpen(_flyoutView);
+ OnPresentedChanged?.Invoke(isOpen);
+ }
+ }
+
+ void OnDrawerOpened(object? sender, DrawerOpenedEventArgs e)
+ {
+ OnPresentedChanged?.Invoke(true);
+ }
+
+ void OnDrawerClosed(object? sender, DrawerClosedEventArgs e)
+ {
+ 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/StackNavigationManager.cs b/src/Core/src/Platform/Android/Navigation/StackNavigationManager.cs
index a7c298ad5067..58c159d528f5 100644
--- a/src/Core/src/Platform/Android/Navigation/StackNavigationManager.cs
+++ b/src/Core/src/Platform/Android/Navigation/StackNavigationManager.cs
@@ -22,6 +22,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 +86,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)
@@ -253,6 +255,30 @@ internal void NavigationFinished(IStackNavigation? navigationView)
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 +327,8 @@ void UpdateNavigationStack(IReadOnlyList newPageStack)
public virtual void Disconnect()
{
+ _navigationQueue.Clear();
+
if (IsNavigating)
NavigationFinished(NavigationView);
@@ -323,23 +351,39 @@ public virtual void Disconnect()
_fragmentManager = null;
}
- public virtual void Connect(IView navigationView)
+ internal 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 91219f7e7d3c..45925293d496 100644
--- a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
@@ -9,5 +9,66 @@ override Microsoft.Maui.PlatformDrawable.ThresholdType.get -> System.Type!
*REMOVED*override Microsoft.Maui.Graphics.MauiDrawable.OnBoundsChange(Android.Graphics.Rect! bounds) -> void
*REMOVED*override Microsoft.Maui.Graphics.MauiDrawable.OnDraw(Android.Graphics.Drawables.Shapes.Shape? shape, Android.Graphics.Canvas? canvas, Android.Graphics.Paint? paint) -> void
override Microsoft.Maui.Handlers.LabelHandler.GetDesiredSize(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size
+override Microsoft.Maui.Platform.MauiDrawerLayout.Dispose(bool disposing) -> void
static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(string! value) -> Microsoft.Maui.GridLength
Microsoft.Maui.Handlers.PickerHandler._dialog -> AndroidX.AppCompat.App.AlertDialog?
+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.ITabbedView.BarBackground.get -> object!
+Microsoft.Maui.ITabbedView.BarBackgroundColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.BarTextColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.CurrentTab.get -> Microsoft.Maui.ITab?
+Microsoft.Maui.ITabbedView.CurrentTab.set -> void
+Microsoft.Maui.ITabbedView.IsSmoothScrollEnabled.get -> bool
+Microsoft.Maui.ITabbedView.IsSwipePagingEnabled.get -> bool
+Microsoft.Maui.ITabbedView.OffscreenPageLimit.get -> int
+Microsoft.Maui.ITabbedView.SelectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.TabBarPlacement.get -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.ITabbedView.Tabs.get -> System.Collections.Generic.IReadOnlyList!
+Microsoft.Maui.ITabbedView.TabsChanged -> System.Collections.Specialized.NotifyCollectionChangedEventHandler?
+Microsoft.Maui.ITabbedView.UnselectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+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
diff --git a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
index 6bb7431a3601..b180f2b70f0c 100644
--- a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
@@ -2,3 +2,23 @@
override Microsoft.Maui.Handlers.StepperHandler.GetDesiredSize(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size
override Microsoft.Maui.Platform.MauiView.DidUpdateFocus(UIKit.UIFocusUpdateContext! context, UIKit.UIFocusAnimationCoordinator! coordinator) -> void
static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(string! value) -> Microsoft.Maui.GridLength
+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.ITabbedView.BarBackground.get -> object!
+Microsoft.Maui.ITabbedView.BarBackgroundColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.BarTextColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.CurrentTab.get -> Microsoft.Maui.ITab?
+Microsoft.Maui.ITabbedView.CurrentTab.set -> void
+Microsoft.Maui.ITabbedView.IsSmoothScrollEnabled.get -> bool
+Microsoft.Maui.ITabbedView.IsSwipePagingEnabled.get -> bool
+Microsoft.Maui.ITabbedView.OffscreenPageLimit.get -> int
+Microsoft.Maui.ITabbedView.SelectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.TabBarPlacement.get -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.ITabbedView.Tabs.get -> System.Collections.Generic.IReadOnlyList!
+Microsoft.Maui.ITabbedView.TabsChanged -> System.Collections.Specialized.NotifyCollectionChangedEventHandler?
+Microsoft.Maui.ITabbedView.UnselectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+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 6bb7431a3601..b180f2b70f0c 100644
--- a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
@@ -2,3 +2,23 @@
override Microsoft.Maui.Handlers.StepperHandler.GetDesiredSize(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size
override Microsoft.Maui.Platform.MauiView.DidUpdateFocus(UIKit.UIFocusUpdateContext! context, UIKit.UIFocusAnimationCoordinator! coordinator) -> void
static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(string! value) -> Microsoft.Maui.GridLength
+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.ITabbedView.BarBackground.get -> object!
+Microsoft.Maui.ITabbedView.BarBackgroundColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.BarTextColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.CurrentTab.get -> Microsoft.Maui.ITab?
+Microsoft.Maui.ITabbedView.CurrentTab.set -> void
+Microsoft.Maui.ITabbedView.IsSmoothScrollEnabled.get -> bool
+Microsoft.Maui.ITabbedView.IsSwipePagingEnabled.get -> bool
+Microsoft.Maui.ITabbedView.OffscreenPageLimit.get -> int
+Microsoft.Maui.ITabbedView.SelectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.TabBarPlacement.get -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.ITabbedView.Tabs.get -> System.Collections.Generic.IReadOnlyList!
+Microsoft.Maui.ITabbedView.TabsChanged -> System.Collections.Specialized.NotifyCollectionChangedEventHandler?
+Microsoft.Maui.ITabbedView.UnselectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+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 66a516d720b9..7115b0879359 100644
--- a/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
@@ -1,2 +1,22 @@
#nullable enable
+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.ITabbedView.BarBackground.get -> object
+Microsoft.Maui.ITabbedView.BarBackgroundColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.BarTextColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.CurrentTab.get -> Microsoft.Maui.ITab?
+Microsoft.Maui.ITabbedView.CurrentTab.set -> void
+Microsoft.Maui.ITabbedView.IsSmoothScrollEnabled.get -> bool
+Microsoft.Maui.ITabbedView.IsSwipePagingEnabled.get -> bool
+Microsoft.Maui.ITabbedView.OffscreenPageLimit.get -> int
+Microsoft.Maui.ITabbedView.SelectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.TabBarPlacement.get -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.ITabbedView.Tabs.get -> System.Collections.Generic.IReadOnlyList!
+Microsoft.Maui.ITabbedView.TabsChanged -> System.Collections.Specialized.NotifyCollectionChangedEventHandler?
+Microsoft.Maui.ITabbedView.UnselectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+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 66a516d720b9..7115b0879359 100644
--- a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
@@ -1,2 +1,22 @@
#nullable enable
+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.ITabbedView.BarBackground.get -> object
+Microsoft.Maui.ITabbedView.BarBackgroundColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.BarTextColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.CurrentTab.get -> Microsoft.Maui.ITab?
+Microsoft.Maui.ITabbedView.CurrentTab.set -> void
+Microsoft.Maui.ITabbedView.IsSmoothScrollEnabled.get -> bool
+Microsoft.Maui.ITabbedView.IsSwipePagingEnabled.get -> bool
+Microsoft.Maui.ITabbedView.OffscreenPageLimit.get -> int
+Microsoft.Maui.ITabbedView.SelectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.TabBarPlacement.get -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.ITabbedView.Tabs.get -> System.Collections.Generic.IReadOnlyList!
+Microsoft.Maui.ITabbedView.TabsChanged -> System.Collections.Specialized.NotifyCollectionChangedEventHandler?
+Microsoft.Maui.ITabbedView.UnselectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+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/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt
index 66a516d720b9..b602c03f1818 100644
--- a/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt
@@ -1,2 +1,22 @@
-#nullable enable
+#nullable enable
+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.ITabbedView.BarBackground.get -> object!
+Microsoft.Maui.ITabbedView.BarBackgroundColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.BarTextColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.CurrentTab.get -> Microsoft.Maui.ITab?
+Microsoft.Maui.ITabbedView.CurrentTab.set -> void
+Microsoft.Maui.ITabbedView.IsSmoothScrollEnabled.get -> bool
+Microsoft.Maui.ITabbedView.IsSwipePagingEnabled.get -> bool
+Microsoft.Maui.ITabbedView.OffscreenPageLimit.get -> int
+Microsoft.Maui.ITabbedView.SelectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.TabBarPlacement.get -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.ITabbedView.Tabs.get -> System.Collections.Generic.IReadOnlyList!
+Microsoft.Maui.ITabbedView.TabsChanged -> System.Collections.Specialized.NotifyCollectionChangedEventHandler?
+Microsoft.Maui.ITabbedView.UnselectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+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/netstandard/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
index 7dc5c58110bf..290bd8fb9e3b 100644
--- a/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
@@ -1 +1,21 @@
-#nullable enable
+#nullable enable
+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.ITabbedView.BarBackground.get -> object!
+Microsoft.Maui.ITabbedView.BarBackgroundColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.BarTextColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.CurrentTab.get -> Microsoft.Maui.ITab?
+Microsoft.Maui.ITabbedView.CurrentTab.set -> void
+Microsoft.Maui.ITabbedView.IsSmoothScrollEnabled.get -> bool
+Microsoft.Maui.ITabbedView.IsSwipePagingEnabled.get -> bool
+Microsoft.Maui.ITabbedView.OffscreenPageLimit.get -> int
+Microsoft.Maui.ITabbedView.SelectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.TabBarPlacement.get -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.ITabbedView.Tabs.get -> System.Collections.Generic.IReadOnlyList!
+Microsoft.Maui.ITabbedView.TabsChanged -> System.Collections.Specialized.NotifyCollectionChangedEventHandler?
+Microsoft.Maui.ITabbedView.UnselectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+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 7dc5c58110bf..290bd8fb9e3b 100644
--- a/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
@@ -1 +1,21 @@
-#nullable enable
+#nullable enable
+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.ITabbedView.BarBackground.get -> object!
+Microsoft.Maui.ITabbedView.BarBackgroundColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.BarTextColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.CurrentTab.get -> Microsoft.Maui.ITab?
+Microsoft.Maui.ITabbedView.CurrentTab.set -> void
+Microsoft.Maui.ITabbedView.IsSmoothScrollEnabled.get -> bool
+Microsoft.Maui.ITabbedView.IsSwipePagingEnabled.get -> bool
+Microsoft.Maui.ITabbedView.OffscreenPageLimit.get -> int
+Microsoft.Maui.ITabbedView.SelectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+Microsoft.Maui.ITabbedView.TabBarPlacement.get -> Microsoft.Maui.TabBarPlacement
+Microsoft.Maui.ITabbedView.Tabs.get -> System.Collections.Generic.IReadOnlyList!
+Microsoft.Maui.ITabbedView.TabsChanged -> System.Collections.Specialized.NotifyCollectionChangedEventHandler?
+Microsoft.Maui.ITabbedView.UnselectedTabColor.get -> Microsoft.Maui.Graphics.Color?
+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/RuntimeFeature.cs b/src/Core/src/RuntimeFeature.cs
index 68e67c6588e5..18bc800970cc 100644
--- a/src/Core/src/RuntimeFeature.cs
+++ b/src/Core/src/RuntimeFeature.cs
@@ -28,6 +28,7 @@ static class RuntimeFeature
const bool IsMeterSupportedByDefault = true;
const bool EnableAspireByDefault = true;
const bool IsMaterial3EnabledByDefault = false;
+ const bool UseAndroidShellHandlersByDefault = false;
const bool IsCssEnabledByDefault = true;
#pragma warning disable IL4000 // Return value does not match FeatureGuardAttribute 'System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute'.
@@ -159,6 +160,14 @@ internal set
#pragma warning restore IL4000
+#if NET11_0_OR_GREATER
+ [FeatureSwitchDefinition($"{FeatureSwitchPrefix}.{nameof(UseAndroidShellHandlers)}")]
+#endif
+ public static bool UseAndroidShellHandlers =>
+ AppContext.TryGetSwitch($"{FeatureSwitchPrefix}.{nameof(UseAndroidShellHandlers)}", out bool isEnabled)
+ ? isEnabled
+ : UseAndroidShellHandlersByDefault;
+
#if NET9_0_OR_GREATER
[FeatureSwitchDefinition($"{FeatureSwitchPrefix}.{nameof(IsCssEnabled)}")]
#endif