diff --git a/src/Controls/src/Core/Handlers/Shell/ShellItemHandler.Android.cs b/src/Controls/src/Core/Handlers/Shell/ShellItemHandler.Android.cs index 76eed3e9f8b9..136f185536ae 100644 --- a/src/Controls/src/Core/Handlers/Shell/ShellItemHandler.Android.cs +++ b/src/Controls/src/Core/Handlers/Shell/ShellItemHandler.Android.cs @@ -173,14 +173,15 @@ internal void SetupViewPagerAdapter() /// internal void SetupTabbedViewManager() { - if (_viewPager is null || VirtualView is null || MauiContext is null) + if (_tabbedViewManager is not null || _viewPager is null || VirtualView is null || MauiContext is null) { return; } - var shellSections = ((IShellItemController)VirtualView).GetItems(); + var shellItemController = (IShellItemController)VirtualView; + var shellSections = shellItemController.GetItems(); - if (shellSections is null || shellSections.Count == 0) + if (shellSections is null || shellSections.Count == 0 || !shellItemController.ShowTabs) { return; } @@ -217,6 +218,22 @@ internal void SetupTabbedViewManager() // Get BNV reference for appearance tracker _bottomNavigationView = _tabbedViewManager.BottomNavigationView; + RecreateBottomNavigationAppearanceTracker(); + + // Initial setup registers the appearance observer immediately afterward. When + // setup was deferred, replay state for the newly-created bottom tabs. + if (_registeredShell is not null && _displayedPage is not null) + { + UpdateAllBadges(); + ((IShellController)_registeredShell).AppearanceChanged(_displayedPage, false); + } + } + + void RecreateBottomNavigationAppearanceTracker() + { + _appearanceTracker?.Dispose(); + _shellContext ??= GetShellContext(); + _appearanceTracker = _shellContext.CreateBottomNavViewAppearanceTracker(VirtualView); } /// @@ -236,6 +253,15 @@ internal void RebuildBottomNavigation() // Update BNV reference (SetElement creates a new BNV) _bottomNavigationView = _tabbedViewManager.BottomNavigationView; + RecreateBottomNavigationAppearanceTracker(); + } + + void RemoveBottomNavigationInfrastructure() + { + _tabbedViewManager?.SetElement(null); + _tabbedViewManager = null; + _shellItemAdapter = null; + _bottomNavigationView = null; } /// @@ -342,22 +368,26 @@ internal void SwitchToShellItem(ShellItem newItem) // 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) { + if (_tabbedViewManager is null) + { + SetupTabbedViewManager(); + } + else + { + // Rebuild bottom navigation for new ShellItem's sections via TabbedViewManager + RebuildBottomNavigation(); + } + + UpdateAllBadges(); _tabbedViewManager?.SetTabLayout(); } else { - _tabbedViewManager?.RemoveTabs(); + RemoveBottomNavigationInfrastructure(); } // Re-register appearance observer with new ShellItem @@ -616,7 +646,7 @@ void UpdateDisplayedPage(Page page) void UpdateTabBarVisibility() { - if (_tabbedViewManager is null || _displayedPage is null || ((ElementHandler)this).VirtualView is null) + if (_switchingShellItem || _displayedPage is null || ((ElementHandler)this).VirtualView is null) { return; } @@ -625,11 +655,12 @@ void UpdateTabBarVisibility() if (showTabs) { - _tabbedViewManager.SetTabLayout(); + SetupTabbedViewManager(); + _tabbedViewManager?.SetTabLayout(); } else { - _tabbedViewManager.RemoveTabs(); + _tabbedViewManager?.RemoveTabs(); } } @@ -727,9 +758,9 @@ protected override void ConnectHandler(ViewPager2 platformView) ((IShellItemController)VirtualView).ItemsCollectionChanged += OnShellItemsChanged; } - // Initialize shell context and appearance tracker early + // Initialize the Shell context early. The bottom navigation appearance tracker + // is created with the deferred TabbedViewManager when tabs are actually needed. _shellContext ??= GetShellContext(); - _appearanceTracker = _shellContext.CreateBottomNavViewAppearanceTracker(VirtualView); // NOTE: Appearance observer registration is deferred to RegisterAppearanceObserver() // called from OnViewCreated in the wrapper fragment. At ConnectHandler time, @@ -776,11 +807,14 @@ void OnShellItemsChanged(object? sender, System.Collections.Specialized.NotifyCo // 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(); + var existingTabbedViewManager = _tabbedViewManager; + SetupTabbedViewManager(); + + // Rebuild an existing bottom navigation menu. A newly-created manager was + // already populated by SetElement in SetupTabbedViewManager. + existingTabbedViewManager?.RefreshTabs(); UpdateTabBarVisibility(); // Signal that the adapter was just rebuilt. The next SwitchToSection call @@ -837,7 +871,7 @@ protected override void DisconnectHandler(ViewPager2 platformView) _registeredShell = null; } - // Dispose per-item appearance tracker (ConnectHandler recreates for new item) + // Dispose per-item appearance tracker; tab setup or rebuild recreates it as needed. _appearanceTracker?.Dispose(); _appearanceTracker = null; @@ -851,13 +885,7 @@ protected override void DisconnectHandler(ViewPager2 platformView) if (!_preserveFragmentResources) { // Full disconnect: fragment is being destroyed — clean everything - if (_tabbedViewManager is not null) - { - _tabbedViewManager.RemoveTabs(); - _tabbedViewManager.SetElement(null); - _tabbedViewManager = null; - } - _shellItemAdapter = null; + RemoveBottomNavigationInfrastructure(); _toolbarAppearanceTracker?.Dispose(); _toolbarAppearanceTracker = null; diff --git a/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Android.cs b/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Android.cs index 96ef56958f48..db0173af35e6 100644 --- a/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Android.cs +++ b/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Android.cs @@ -149,21 +149,6 @@ protected override AView CreatePlatformElement() _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"); - - int actionBarHeight = context.GetActionBarHeight(); - - _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; } @@ -253,18 +238,7 @@ internal void SetupViewPagerAdapter() 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); + SetupTabbedViewManager(); // Update TabLayout visibility based on item count UpdateTabLayoutVisibility(); @@ -275,10 +249,6 @@ internal void SetupViewPagerAdapter() // 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) { @@ -305,6 +275,53 @@ internal void SetupViewPagerAdapter() } } + void SetupTabbedViewManager() + { + if (_tabbedViewManager is not null || VirtualView is null || _viewPager is null || MauiContext is null || _shellContext is null) + { + return; + } + + if (SectionController.GetItems().Count <= 1) + { + return; + } + + var context = MauiContext.Context + ?? throw new InvalidOperationException("MauiContext.Context cannot be null"); + + _contentTabLayout = new TabLayout(context) + { + Id = AView.GenerateViewId(), + LayoutParameters = new LP(LP.MatchParent, context.GetActionBarHeight()), + Visibility = ViewStates.Gone, + TabMode = TabLayout.ModeScrollable + }; + + _shellSectionAdapter = new ShellSectionTabbedViewAdapter(VirtualView); + _tabbedViewManager = new TabbedViewManager(MauiContext, _viewPager) + { + TabLayout = _contentTabLayout + }; + _tabbedViewManager.SetElement(_shellSectionAdapter); + + _pageChangedCallback = new ViewPagerPageChangeCallback(this); + _viewPager.RegisterOnPageChangeCallback(_pageChangedCallback); + + _tabLayoutAppearanceTracker = _shellContext.CreateTabLayoutAppearanceTracker(VirtualView); + + // Initial setup registers the appearance observer immediately afterward. When + // setup was deferred, replay the appearance for the newly-created TabLayout. + if (_registeredShell is not null && IsCurrentlyActiveSection() && VirtualView.CurrentItem is ShellContent currentContent) + { + var page = ((IShellContentController)currentContent).GetOrCreateContent(); + if (page is not null) + { + ((IShellController)_registeredShell).AppearanceChanged(page, false); + } + } + } + void SetInitialPosition() { if (VirtualView?.CurrentItem is null || _viewPager is null) @@ -322,7 +339,7 @@ void SetInitialPosition() void UpdateTabLayoutVisibility() { - if (_tabbedViewManager is null || VirtualView is null) + if (VirtualView is null) { return; } @@ -646,6 +663,7 @@ void OnItemsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e var visibleCount = SectionController.GetItems().Count; _viewPager?.OffscreenPageLimit = Math.Max(visibleCount, 1); + SetupTabbedViewManager(); UpdateTabLayoutVisibility(); UpdateViewPagerUserInput(); } @@ -1017,7 +1035,7 @@ public override void OnPageSelected(int position) toolbarTracker?.Page = page; // Update CurrentItem - virtualView.CurrentItem = newCurrentItem; + virtualView.SetValueFromRenderer(ShellSection.CurrentItemProperty, newCurrentItem); // Trigger appearance update if (shell is not null) diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellHandlerSubclasses.Android.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellHandlerSubclasses.Android.cs index 359d43b2fa9f..ca41864d63ce 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellHandlerSubclasses.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellHandlerSubclasses.Android.cs @@ -1,26 +1,279 @@ +using System.Threading.Tasks; +using Android.Graphics.Drawables; +using AndroidX.AppCompat.Graphics.Drawable; +using AndroidX.CoordinatorLayout.Widget; +using AndroidX.DrawerLayout.Widget; +using Google.Android.Material.AppBar; using Microsoft.Maui.Controls; using Microsoft.Maui.Controls.Handlers; using Microsoft.Maui.Controls.Handlers.Items; +using Microsoft.Maui.Controls.Platform; using Microsoft.Maui.Controls.Platform.Compatibility; using Microsoft.Maui.DeviceTests.Stubs; +using Microsoft.Maui.Graphics; using Microsoft.Maui.Handlers; using Microsoft.Maui.Hosting; -using AndroidX.DrawerLayout.Widget; -using AndroidX.AppCompat.Graphics.Drawable; -using AndroidX.CoordinatorLayout.Widget; -using Google.Android.Material.AppBar; using Microsoft.Maui.Platform; using Xunit; using AView = Android.Views.View; +using AViewStates = Android.Views.ViewStates; using NativeShellHandler = Microsoft.Maui.Controls.Handlers.ShellHandler; namespace Microsoft.Maui.DeviceTests { + public class StartupTrackingShellHandler : NativeShellHandler + { + public int TabLayoutAppearanceTrackerCreationCount { get; private set; } + + public int BottomNavAppearanceTrackerCreationCount { get; private set; } + + protected override IShellTabLayoutAppearanceTracker CreateTabLayoutAppearanceTracker(ShellSection shellSection) + { + TabLayoutAppearanceTrackerCreationCount++; + return base.CreateTabLayoutAppearanceTracker(shellSection); + } + + protected override IShellBottomNavViewAppearanceTracker CreateBottomNavViewAppearanceTracker(ShellItem shellItem) + { + BottomNavAppearanceTrackerCreationCount++; + return base.CreateBottomNavViewAppearanceTracker(shellItem); + } + } + [Category(TestCategory.Shell)] [Collection(ControlsHandlerTestBase.RunInNewWindowCollection)] [Trait(RendererHandlerVariant.TraitName, RendererHandlerVariant.AndroidShellHandler)] // See RendererHandlerVariant.cs public partial class ShellHandlerTests_Shell : ShellTests { + [Fact] + public async Task SinglePageShellCreatesTabInfrastructureOnlyWhenNeeded() + { + SetupBuilder(); + + var firstPage = new ContentPage(); + var firstContent = new ShellContent { Content = firstPage }; + var expectedBadgeText = "7"; + var section = new ShellSection + { + BadgeText = expectedBadgeText, + Items = { firstContent } + }; + var item = new FlyoutItem { Items = { section } }; + var secondSection = new ShellSection + { + Title = "Second section", + Items = { new ShellContent { Content = new ContentPage() } } + }; + var secondContent = new ShellContent + { + Title = "Second content", + Content = new ContentPage() + }; + var expectedTabBackground = Colors.Red; + var shell = await CreateShellAsync(shell => + { + Shell.SetBackgroundColor(shell, expectedTabBackground); + shell.Items.Add(item); + }); + + await CreateHandlerAndAddToWindow(shell, async () => + { + await OnLoadedAsync(firstPage); + + var shellHandler = Assert.IsType(shell.Handler); + var itemHandler = Assert.IsType(item.Handler); + var sectionHandler = Assert.IsType(section.Handler); + + Assert.Null(itemHandler._tabbedViewManager); + Assert.Null(itemHandler._shellItemAdapter); + Assert.Null(itemHandler.BottomNavigationView); + Assert.Null(sectionHandler.ContentTabLayout); + Assert.Equal(0, shellHandler.BottomNavAppearanceTrackerCreationCount); + Assert.Equal(0, shellHandler.TabLayoutAppearanceTrackerCreationCount); + var topTabsContainer = shellHandler.PlatformView.FindViewById(Resource.Id.navigationlayout_toptabs); + Assert.NotNull(topTabsContainer); + Assert.Equal(AViewStates.Gone, topTabsContainer.Visibility); + + item.Items.Add(secondSection); + section.Items.Add(secondContent); + + Assert.NotNull(itemHandler._tabbedViewManager); + Assert.NotNull(sectionHandler.ContentTabLayout); + var bottomBackground = Assert.IsType(itemHandler.BottomNavigationView.Background); + Assert.Equal(expectedTabBackground.ToPlatform(), bottomBackground.EndColor); + var badge = itemHandler.BottomNavigationView.GetBadge(0); + Assert.NotNull(badge); + Assert.Equal(expectedBadgeText, badge.Text); + var background = Assert.IsType(sectionHandler.ContentTabLayout.Background); + Assert.Equal(expectedTabBackground.ToPlatform(), background.Color); + Assert.Equal(1, shellHandler.BottomNavAppearanceTrackerCreationCount); + Assert.Equal(1, shellHandler.TabLayoutAppearanceTrackerCreationCount); + + var tabbedViewManager = itemHandler._tabbedViewManager; + var shellItemAdapter = itemHandler._shellItemAdapter; + var bottomNavigationView = itemHandler.BottomNavigationView; + var contentTabLayout = sectionHandler.ContentTabLayout; + + item.Items.Remove(secondSection); + section.Items.Remove(secondContent); + Assert.Equal(AViewStates.Gone, topTabsContainer.Visibility); + item.Items.Add(secondSection); + section.Items.Add(secondContent); + + Assert.Same(tabbedViewManager, itemHandler._tabbedViewManager); + Assert.Same(shellItemAdapter, itemHandler._shellItemAdapter); + Assert.Same(bottomNavigationView, itemHandler.BottomNavigationView); + Assert.Same(contentTabLayout, sectionHandler.ContentTabLayout); + Assert.Equal(1, shellHandler.BottomNavAppearanceTrackerCreationCount); + Assert.Equal(1, shellHandler.TabLayoutAppearanceTrackerCreationCount); + }); + } + + [Fact] + public async Task SwitchingShellItemsCreatesBottomTabsOnlyWhenNeeded() + { + SetupBuilder(); + + var firstPage = new ContentPage(); + var firstItem = new FlyoutItem + { + Title = "First item", + Items = + { + new ShellSection + { + Items = { new ShellContent { Content = firstPage } } + } + } + }; + var secondPage = new ContentPage(); + var secondItem = new FlyoutItem + { + Title = "Second item", + Items = + { + new ShellSection + { + Title = "First section", + Items = { new ShellContent { Content = secondPage } } + }, + new ShellSection + { + Title = "Second section", + Items = { new ShellContent { Content = new ContentPage() } } + } + } + }; + var expectedTabBackground = Colors.Blue; + var shell = await CreateShellAsync(shell => + { + Shell.SetTabBarBackgroundColor(secondItem, expectedTabBackground); + shell.Items.Add(firstItem); + shell.Items.Add(secondItem); + shell.CurrentItem = firstItem; + }); + + await CreateHandlerAndAddToWindow(shell, async () => + { + await OnLoadedAsync(firstPage); + + var shellHandler = Assert.IsType(shell.Handler); + Assert.Equal(0, shellHandler.BottomNavAppearanceTrackerCreationCount); + + shell.CurrentItem = secondItem; + await OnLoadedAsync(secondPage); + + var itemHandler = Assert.IsType(secondItem.Handler); + Assert.Equal(1, shellHandler.BottomNavAppearanceTrackerCreationCount); + Assert.NotNull(itemHandler._tabbedViewManager); + var background = Assert.IsType(itemHandler.BottomNavigationView.Background); + Assert.Equal(expectedTabBackground.ToPlatform(), background.EndColor); + + shell.CurrentItem = firstItem; + await OnLoadedAsync(firstPage); + + itemHandler = Assert.IsType(firstItem.Handler); + Assert.Equal(1, shellHandler.BottomNavAppearanceTrackerCreationCount); + Assert.Null(itemHandler._tabbedViewManager); + Assert.Null(itemHandler.BottomNavigationView); + + shell.CurrentItem = secondItem; + await OnLoadedAsync(secondPage); + + itemHandler = Assert.IsType(secondItem.Handler); + Assert.Equal(2, shellHandler.BottomNavAppearanceTrackerCreationCount); + Assert.NotNull(itemHandler._tabbedViewManager); + }); + } + + [Fact] + public async Task SwitchingShellItemsRecreatesBottomNavAppearanceTracker() + { + SetupBuilder(); + + var firstPage = new ContentPage(); + var firstItem = new FlyoutItem + { + Title = "First item", + Items = + { + new ShellSection + { + Title = "First section", + Items = { new ShellContent { Content = firstPage } } + }, + new ShellSection + { + Title = "Second section", + Items = { new ShellContent { Content = new ContentPage() } } + } + } + }; + var secondPage = new ContentPage(); + var secondItem = new FlyoutItem + { + Title = "Second item", + Items = + { + new ShellSection + { + Title = "First section", + Items = { new ShellContent { Content = secondPage } } + }, + new ShellSection + { + Title = "Second section", + Items = { new ShellContent { Content = new ContentPage() } } + } + } + }; + var expectedTabBackground = Colors.Blue; + var shell = await CreateShellAsync(shell => + { + Shell.SetTabBarBackgroundColor(firstItem, Colors.Red); + Shell.SetTabBarBackgroundColor(secondItem, expectedTabBackground); + shell.Items.Add(firstItem); + shell.Items.Add(secondItem); + shell.CurrentItem = firstItem; + }); + + await CreateHandlerAndAddToWindow(shell, async () => + { + await OnLoadedAsync(firstPage); + + var shellHandler = Assert.IsType(shell.Handler); + Assert.Equal(1, shellHandler.BottomNavAppearanceTrackerCreationCount); + + shell.CurrentItem = secondItem; + await OnLoadedAsync(secondPage); + + var itemHandler = Assert.IsType(secondItem.Handler); + Assert.Equal(2, shellHandler.BottomNavAppearanceTrackerCreationCount); + var background = Assert.IsType(itemHandler.BottomNavigationView.Background); + Assert.Equal(expectedTabBackground.ToPlatform(), background.EndColor); + }); + } + protected override void SetupBuilder() { EnsureHandlerCreated(builder => @@ -30,7 +283,7 @@ protected override void SetupBuilder() // Register all standard handlers first (Layout, Image, Label, Page, Toolbar, MenuBar, etc.) SetupShellHandlers(handlers); // Override Shell with the new NativeShellHandler - handlers.AddHandler(typeof(Controls.Shell), typeof(NativeShellHandler)); + handlers.AddHandler(typeof(Controls.Shell), typeof(StartupTrackingShellHandler)); handlers.AddHandler(typeof(ShellItem), typeof(ShellItemHandler)); handlers.AddHandler(typeof(ShellSection), typeof(ShellSectionHandler)); handlers.AddHandler(typeof(NavigationPage), typeof(NavigationViewHandler)); @@ -45,6 +298,11 @@ protected override void SetupBuilder() }); } + protected override void SetupShellTabColorsTest(Shell shell) + { + Shell.SetTabBarIsVisible(shell, true); + } + // NativeShellHandler uses MauiDrawerLayout (not ShellFlyoutRenderer), so cast to MauiDrawerLayout. protected override DrawerLayout GetDrawerLayout(IShellContext shellContext) { diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs index 86e7a4c4d86d..670be390ca77 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs @@ -585,6 +585,7 @@ public async Task ShellTabColorsDefaultToWhite() var shell = await CreateShellAsync(shell => { shell.Items.Add(new Tab() { Items = { new ContentPage() }, Title = "Tab 1" }); + SetupShellTabColorsTest(shell); }); await CreateHandlerAndAddToWindow(shell, () => @@ -603,6 +604,10 @@ await CreateHandlerAndAddToWindow(shell, () => }); } + protected virtual void SetupShellTabColorsTest(Shell shell) + { + } + [Fact(DisplayName = "ShellContentFragment.Destroy handles null _shellContext gracefully")] public async Task ShellContentFragmentDestroyHandlesNullShellContext() {