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