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