diff --git a/src/Controls/src/Core/Platform/Android/BottomNavigationViewUtils.cs b/src/Controls/src/Core/Platform/Android/BottomNavigationViewUtils.cs index 65769f8ffcd6..ceac44a35c02 100644 --- a/src/Controls/src/Core/Platform/Android/BottomNavigationViewUtils.cs +++ b/src/Controls/src/Core/Platform/Android/BottomNavigationViewUtils.cs @@ -68,12 +68,13 @@ internal static Task SetupMenuItem( int currentIndex, BottomNavigationView bottomView, IMauiContext mauiContext, - out IMenuItem menuItem) + out IMenuItem menuItem, + Action onIconLoaded = null) { Task returnValue; using var title = new Java.Lang.String(item.title); menuItem = menu.Add(0, index, 0, title); - returnValue = SetMenuItemIcon(menuItem, item.icon, mauiContext); + returnValue = SetMenuItemIcon(menuItem, item.icon, mauiContext, onIconLoaded); UpdateEnabled(item.tabEnabled, menuItem); if (index == currentIndex) { @@ -90,7 +91,8 @@ internal static async void SetupMenu( List<(string title, ImageSource icon, bool tabEnabled)> items, int currentIndex, BottomNavigationView bottomView, - IMauiContext mauiContext) + IMauiContext mauiContext, + Action onIconLoaded = null) { maxBottomItems = Math.Min(maxBottomItems, MaxBottomNavigationItems); Context context = mauiContext.Context; @@ -112,19 +114,26 @@ internal static async void SetupMenu( IMenuItem menuItem; if (i >= menu.Size()) - loadTasks.Add(SetupMenuItem(item, menu, i, currentIndex, bottomView, mauiContext, out menuItem)); + loadTasks.Add(SetupMenuItem(item, menu, i, currentIndex, bottomView, mauiContext, out menuItem, onIconLoaded)); else { menuItem = menu.GetItem(i); if (menuItem.ItemId != i) { menu.RemoveItem(menuItem.ItemId); - loadTasks.Add(SetupMenuItem(item, menu, i, currentIndex, bottomView, mauiContext, out menuItem)); + loadTasks.Add(SetupMenuItem(item, menu, i, currentIndex, bottomView, mauiContext, out menuItem, onIconLoaded)); } else { SetMenuItemTitle(menuItem, item.title); - loadTasks.Add(SetMenuItemIcon(menuItem, item.icon, mauiContext)); + loadTasks.Add(SetMenuItemIcon(menuItem, item.icon, mauiContext, onIconLoaded)); + // Reapply enabled/selected state since this IMenuItem is being reused, not recreated. + UpdateEnabled(item.tabEnabled, menuItem); + if (i == currentIndex) + { + menuItem.SetChecked(true); + bottomView.SelectedItemId = i; + } } } @@ -132,23 +141,48 @@ internal static async void SetupMenu( } var menuSize = menu.Size(); + IMenuItem moreMenuItem = null; if (showMore && menu.GetItem(menuSize - 1).ItemId != MoreTabId) { var moreString = context.Resources.GetText(Resource.String.overflow_tab_title); if (menuSize == maxBottomItems) menu.RemoveItem(menu.GetItem(menuSize - 1).ItemId); - var menuItem = menu.Add(0, MoreTabId, 0, moreString); - menuItems.Add(menuItem); + moreMenuItem = menu.Add(0, MoreTabId, 0, moreString); + menuItems.Add(moreMenuItem); - menuItem.SetIcon(Resource.Drawable.abc_ic_menu_overflow_material); - if (currentIndex >= maxBottomItems - 1) - menuItem.SetChecked(true); + moreMenuItem.SetIcon(Resource.Drawable.abc_ic_menu_overflow_material); + } + else if (showMore) + { + // The More item already exists (reused, not recreated) — still need to + // reapply its selected state below in case currentIndex has changed. + moreMenuItem = menu.GetItem(menuSize - 1); + } + + if (moreMenuItem is not null && currentIndex >= maxBottomItems - 1) + { + // Use SetChecked only — setting SelectedItemId would trigger ShowMoreBottomSheet(). + moreMenuItem.SetChecked(true); } bottomView.SetShiftMode(false, false); if (loadTasks.Count > 0) - await Task.WhenAll(loadTasks); + { + try + { + await Task.WhenAll(loadTasks); + } + catch (Exception ex) + { + // SetupMenu is async void — an unhandled exception here would crash + // the app. SetMenuItemIcon itself no longer swallows exceptions so + // its other caller (ShellItemRenderer.UpdateShellSectionIcon, via + // FireAndForget) can still observe/log a faulted Task; this catch + // only protects SetupMenu's own async-void boundary. + System.Diagnostics.Debug.WriteLine($"SetupMenu: one or more icon loads failed: {ex}"); + } + } } internal static void SetMenuItemTitle(IMenuItem menuItem, string title) @@ -157,14 +191,31 @@ internal static void SetMenuItemTitle(IMenuItem menuItem, string title) menuItem.SetTitle(jTitle); } - internal static async Task SetMenuItemIcon(IMenuItem menuItem, ImageSource source, IMauiContext context) + // Records which ImageSource each reused IMenuItem is currently supposed to show, so a + // slower, stale load (from before the item was repurposed for a different tab) can + // detect it's been superseded and skip applying its now-outdated result. + static readonly ConditionalWeakTable s_pendingIconSource = new(); + + internal static async Task SetMenuItemIcon(IMenuItem menuItem, ImageSource source, IMauiContext context, Action onIconLoaded = null) { if (!menuItem.IsAlive()) return; + s_pendingIconSource.AddOrUpdate(menuItem, source); + if (source is null) + { + // Clear any stale icon left on this (possibly reused) menu item. + menuItem.SetIcon(null); + onIconLoaded?.Invoke(menuItem); return; + } + // Exceptions are intentionally allowed to propagate here (not swallowed) so + // callers can observe/log failures via the returned Task — e.g. the legacy + // ShellItemRenderer.UpdateShellSectionIcon relies on FireAndForget's error + // handler seeing a faulted Task. SetupMenu (the other caller) guards its own + // async-void boundary separately when awaiting these tasks. var services = context.Services; var provider = services.GetRequiredService(); var imageSourceService = provider.GetRequiredImageSourceService(source); @@ -173,9 +224,14 @@ internal static async Task SetMenuItemIcon(IMenuItem menuItem, ImageSource sourc source, context.Context); - if (menuItem.IsAlive()) + // Skip applying if this menu item has since been repurposed for a different + // source (i.e. a newer SetMenuItemIcon call updated the pending source above). + if (menuItem.IsAlive() && s_pendingIconSource.TryGetValue(menuItem, out var pending) && ReferenceEquals(pending, source)) { menuItem.SetIcon(result?.Value); + // Let the caller reapply per-item icon tint (e.g. to preserve a + // FontImageSource's own Color) now that the drawable is installed. + onIconLoaded?.Invoke(menuItem); } } diff --git a/src/Controls/src/Core/Platform/Android/TabbedViewManager.cs b/src/Controls/src/Core/Platform/Android/TabbedViewManager.cs index c6d3a3d34bcc..8552a21e6ad0 100644 --- a/src/Controls/src/Core/Platform/Android/TabbedViewManager.cs +++ b/src/Controls/src/Core/Platform/Android/TabbedViewManager.cs @@ -744,7 +744,9 @@ internal void UpdateSwipePaging() 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)); + // Fallback for null/whitespace Title to avoid a blank bottom tab label. + var title = !string.IsNullOrWhiteSpace(tab.Title) ? tab.Title : $"Tab {i + 1}"; + items.Add((title, icon, tab.IsEnabled)); } return items; @@ -758,11 +760,13 @@ internal virtual void SetupBottomNavigationView() } var menu = _bottomNavigationView.Menu; - menu.Clear(); var tabs = Element.Tabs; if (tabs is null || tabs.Count == 0) { + menu.Clear(); + _bottomNavigationView.SetOnItemSelectedListener(null); + _bottomNavigationView.SetOnItemReselectedListener(null); return; } @@ -776,43 +780,21 @@ internal virtual void SetupBottomNavigationView() _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); - } + // Use BottomNavigationViewUtils.SetupMenu which does incremental in-place updates + // (reuses existing IMenuItem objects at unchanged positions, preserving identity). + // This matches the renderer's ShellItemRenderer.SetupMenu behavior and avoids + // menu.Clear() which destroys all IMenuItem references on every tab add/remove. + var items = CreateTabList(); - // 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; - } + BottomNavigationViewUtils.SetupMenu( + menu, + _bottomNavigationView.MaxItemCount, + items, + currentIndex, + _bottomNavigationView, + _context, + onIconLoaded: menuItem => SetupBottomNavigationViewIconColor(menuItem.ItemId, menuItem)); _bottomNavigationView.SetShiftMode(false, false); @@ -832,23 +814,20 @@ 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); - + // Route through BottomNavigationViewUtils.SetMenuItemIcon instead of loading + // the icon directly here. SetMenuItemIcon guards against a reused IMenuItem + // being repurposed (by SetupMenu) for a different tab while this load is still + // in flight -- without that guard, this call could win a race against a newer + // SetupMenu-driven icon load and overwrite the wrong tab's icon. + await BottomNavigationViewUtils.SetMenuItemIcon( + menuItem, + tab.Icon as ImageSource, + _context, // 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); - } + onIconLoaded: loadedMenuItem => SetupBottomNavigationViewIconColor(loadedMenuItem.ItemId, loadedMenuItem)); } catch (Exception ex) { diff --git a/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.Android.cs b/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.Android.cs index 86e57d9cb0e5..aff97baaf669 100644 --- a/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.Android.cs +++ b/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.Android.cs @@ -211,7 +211,7 @@ public bool IsNavigationBarVisible(IMauiContext mauiContext) .LayoutParameters?.Height > 0; } - protected bool IsBackButtonVisible(IElementHandler handler) + protected virtual bool IsBackButtonVisible(IElementHandler handler) { if (GetPlatformToolbar(handler)?.NavigationIcon is DrawerArrowDrawable dad) return dad.Progress == 1; diff --git a/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs b/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs index 4557b06d3aef..db4ed30d5954 100644 --- a/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs @@ -29,9 +29,10 @@ namespace Microsoft.Maui.DeviceTests #if ANDROID || IOS || MACCATALYST [Collection(ControlsHandlerTestBase.RunInNewWindowCollection)] #endif + [Trait(RendererHandlerVariant.TraitName, RendererHandlerVariant.AndroidShellRenderer)] // See RendererHandlerVariant.cs public partial class ModalTests : ControlsHandlerTestBase { - void SetupBuilder() + protected virtual void SetupBuilder() { EnsureHandlerCreated(builder => { diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/RendererHandlerVariant.cs b/src/Controls/tests/DeviceTests/Elements/Shell/RendererHandlerVariant.cs new file mode 100644 index 000000000000..86e50d13cb7c --- /dev/null +++ b/src/Controls/tests/DeviceTests/Elements/Shell/RendererHandlerVariant.cs @@ -0,0 +1,14 @@ +namespace Microsoft.Maui.DeviceTests +{ + /// + /// Trait key/values for Android Shell Renderer/Handler test variants so that, on Android only, + /// xUnitCustomizations.cs can prefix their DisplayName as "[Renderer]"/"[Handler]". + /// Also reused by Modal/Window tests, which share the same Android Renderer/Handler reuse logic as Shell. + /// + public static class RendererHandlerVariant + { + public const string TraitName = "Variant"; + public const string AndroidShellRenderer = "Renderer"; + public const string AndroidShellHandler = "Handler"; + } +} diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.Android.cs index 355c471f78a5..c8551685f9e8 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.Android.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using AndroidX.DrawerLayout.Widget; using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Platform.Compatibility; using Xunit; namespace Microsoft.Maui.DeviceTests @@ -15,18 +16,21 @@ await RunShellTest(shell => { shell.FlyoutContent = new VerticalStackLayout() { new Label() { Text = "Flyout Content" } }; }, - async (shell, handler) => + async shell => { +#if ANDROID || IOS || MACCATALYST + var shellContext = (IShellContext)shell.Handler; +#endif // 1. Set FlyoutIsPresented=true to make the Shell Flyout visible. shell.FlyoutIsPresented = true; - var dl = GetDrawerLayout(handler) as DrawerLayout; + var dl = GetDrawerLayout(shellContext) as DrawerLayout; Assert.NotNull(dl); await AssertionExtensions.AssertEventually(() => { // 2. Check that the Flyout has size. - var flyoutFrame = GetFlyoutFrame(handler); + var flyoutFrame = GetFlyoutFrame(shellContext); return flyoutFrame.Width > 0 && flyoutFrame.Height > 0 && dl.IsOpen; }); }); diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.cs index 76180c67934b..8c4feb0e8417 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.cs @@ -15,6 +15,7 @@ #if ANDROID || IOS || MACCATALYST using ShellHandler = Microsoft.Maui.Controls.Handlers.Compatibility.ShellRenderer; using Microsoft.Maui.Graphics; +using Microsoft.Maui.Controls.Platform.Compatibility; using Microsoft.Maui.Platform; using System.Threading; #else @@ -38,7 +39,7 @@ await RunShellTest(shell => { shell.FlyoutBehavior = FlyoutBehavior.Locked; }, - async (shell, handler) => + async shell => { Assert.False(flyoutContent.IsLoaded); @@ -83,7 +84,7 @@ await RunShellTest(shell => shell.Items.Add(shellSection); shell.Items.Add(shellContent); }, - async (shell, handler) => + async shell => { await OnLoadedAsync(flyoutItemGrid); await OnLoadedAsync(shellSectionGrid); @@ -114,10 +115,15 @@ await RunShellTest(shell => shell.FlyoutHeader = layout; shell.FlyoutHeaderBehavior = behavior; }, - async (shell, handler) => + async shell => { - await OpenFlyout(handler); - var flyoutFrame = GetFrameRelativeToFlyout(handler, shell.FlyoutHeader as IView); +#if ANDROID + var shellContext = (IShellContext)shell.Handler; +#elif IOS || MACCATALYST + var shellContext = (ShellHandler)shell.Handler; +#endif + await OpenFlyout(shellContext); + var flyoutFrame = GetFrameRelativeToFlyout(shellContext, shell.FlyoutHeader as IView); if (behavior == FlyoutHeaderBehavior.CollapseOnScroll) @@ -162,22 +168,26 @@ await RunShellTest(shell => shell.FlyoutContent = new VerticalStackLayout() { new Label() { Text = "Flyout Content" } }; shell.FlyoutHeaderBehavior = FlyoutHeaderBehavior.CollapseOnScroll; }, - async (shell, handler) => + async shell => { - await OpenFlyout(handler); - - var headerFrame = GetFrameRelativeToFlyout(handler, (IView)shell.FlyoutHeader); - var contentFrame = GetFrameRelativeToFlyout(handler, (IView)shell.FlyoutContent); - var footerFrame = GetFrameRelativeToFlyout(handler, (IView)shell.FlyoutFooter); +#if ANDROID + var shellContext = (IShellContext)shell.Handler; +#elif IOS || MACCATALYST + var shellContext = (ShellHandler)shell.Handler; +#endif + await OpenFlyout(shellContext); + var headerFrame = GetFrameRelativeToFlyout(shellContext, (IView)shell.FlyoutHeader); + var contentFrame = GetFrameRelativeToFlyout(shellContext, (IView)shell.FlyoutContent); + var footerFrame = GetFrameRelativeToFlyout(shellContext, (IView)shell.FlyoutFooter); // validate footer position - #if IOS - AssertionExtensions.CloseEnough(footerFrame.Y + GetSafeArea(handler.ToPlatform()).Bottom, headerFrame.Height + contentFrame.Height + GetSafeArea(handler.ToPlatform()).Top); - #else +#if IOS + AssertionExtensions.CloseEnough(footerFrame.Y + GetSafeArea(shell.Handler.ToPlatform()).Bottom, headerFrame.Height + contentFrame.Height + GetSafeArea(shell.Handler.ToPlatform()).Top); +#else // On android the we pad the top of the header frame by the safe area because how layout works // so that is already included in the headerFrame Height AssertionExtensions.CloseEnough(footerFrame.Y, headerFrame.Height + contentFrame.Height); - #endif +#endif }); } @@ -210,19 +220,23 @@ await RunShellTest(shell => shell.FlyoutHeaderBehavior = behavior; shell.FlyoutContent = ShellFlyoutHeaderBehaviorAndContentTestCases.GetFlyoutContentAction(contentType, contentMargin); }, - async (shell, handler) => + async shell => { if (!headerMarginTop.HasValue) { - headerMargin.Top = GetSafeArea(handler.ToPlatform()).Top; + headerMargin.Top = GetSafeArea(shell.Handler.ToPlatform()).Top; } - await OpenFlyout(handler); - - var flyoutFrame = GetFlyoutFrame(handler); - var headerFrame = GetFrameRelativeToFlyout(handler, (IView)shell.FlyoutHeader); - var contentFrame = GetFrameRelativeToFlyout(handler, (IView)shell.FlyoutContent); - var footerFrame = GetFrameRelativeToFlyout(handler, (IView)shell.FlyoutFooter); +#if ANDROID + var shellContext = (IShellContext)shell.Handler; +#elif IOS || MACCATALYST + var shellContext = (ShellHandler)shell.Handler; +#endif + await OpenFlyout(shellContext); + var flyoutFrame = GetFlyoutFrame(shellContext); + var headerFrame = GetFrameRelativeToFlyout(shellContext, (IView)shell.FlyoutHeader); + var contentFrame = GetFrameRelativeToFlyout(shellContext, (IView)shell.FlyoutContent); + var footerFrame = GetFrameRelativeToFlyout(shellContext, (IView)shell.FlyoutFooter); // validate header position AssertionExtensions.CloseEnough(0, headerFrame.X, message: "Header X"); @@ -253,7 +267,7 @@ await RunShellTest(shell => // validate footer position var expectedFooterY = expectedContentY + contentMargin.Bottom + contentFrame.Height; AssertionExtensions.CloseEnough(0, footerFrame.X, message: "Footer X"); - AssertionExtensions.CloseEnough(expectedFooterY, footerFrame.Y + GetSafeArea(handler.ToPlatform()).Bottom, epsilon: 0.6, message: "Footer Y"); + AssertionExtensions.CloseEnough(expectedFooterY, footerFrame.Y + GetSafeArea(shell.Handler.ToPlatform()).Bottom, epsilon: 0.6, message: "Footer Y"); AssertionExtensions.CloseEnough(flyoutFrame.Width, footerFrame.Width, message: "Footer Width"); //All three views should measure to the height of the flyout @@ -306,15 +320,19 @@ await RunShellTest(shell => ShellFlyoutHeaderScrollTestCases.SetFlyoutContent(contentType, shell); }, - async (shell, handler) => + async shell => { - await OpenFlyout(handler); - +#if ANDROID + var shellContext = (IShellContext)shell.Handler; +#elif IOS || MACCATALYST + var shellContext = (ShellHandler)shell.Handler; +#endif + await OpenFlyout(shellContext); var initialBox = (shell.FlyoutHeader as IView).GetBoundingBox(); AssertionExtensions.CloseEnough(headerRequestedHeight, initialBox.Height, 0.3); - var bottomOffset = await ScrollFlyoutToBottom(handler); + var bottomOffset = await ScrollFlyoutToBottom(shellContext); var scrolledBox = (shell.FlyoutHeader as IView).GetBoundingBox(); if (flyoutHeaderBehavior == FlyoutHeaderBehavior.CollapseOnScroll) @@ -325,7 +343,7 @@ await RunShellTest(shell => { // After scrolling, the header height may include the safe area margin // depending on the content type and how InvalidateMeasure is triggered. - var safeAreaTop = GetSafeArea(handler.ToPlatform()).Top; + var safeAreaTop = GetSafeArea(shell.Handler.ToPlatform()).Top; Assert.True( scrolledBox.Height >= headerRequestedHeight - 0.3 && scrolledBox.Height <= headerRequestedHeight + safeAreaTop + 0.3, @@ -340,7 +358,7 @@ await RunShellTest(shell => } else { - AssertionExtensions.CloseEnough(GetSafeArea(handler.ToPlatform()).Top, scrolledBox.Y, 0.3, "Header position"); + AssertionExtensions.CloseEnough(GetSafeArea(shell.Handler.ToPlatform()).Top, scrolledBox.Y, 0.3, "Header position"); } } }); @@ -362,10 +380,15 @@ await RunShellTest(shell => { shellPart(shell, baselineContent); }, - async (shell, handler) => + async shell => { - await OpenFlyout(handler); - frameWithoutMargin = GetFrameRelativeToFlyout(handler, baselineContent); +#if ANDROID + var shellContext = (IShellContext)shell.Handler; +#elif IOS || MACCATALYST + var shellContext = (ShellHandler)shell.Handler; +#endif + await OpenFlyout(shellContext); + frameWithoutMargin = GetFrameRelativeToFlyout(shellContext, baselineContent); }); var content = new VerticalStackLayout() { new Label() { Text = "Flyout Layout Part" } }; @@ -375,11 +398,15 @@ await RunShellTest(shell => content.Margin = new Thickness(20, 30, 0, 30); shellPart(shell, content); }, - async (shell, handler) => + async shell => { - await OpenFlyout(handler); - - var frameWithMargin = GetFrameRelativeToFlyout(handler, content); +#if ANDROID + var shellContext = (IShellContext)shell.Handler; +#elif IOS || MACCATALYST + var shellContext = (ShellHandler)shell.Handler; +#endif + await OpenFlyout(shellContext); + var frameWithMargin = GetFrameRelativeToFlyout(shellContext, content); var leftDiff = Math.Abs(Math.Abs(frameWithMargin.Left - (frameWithoutMargin.Left - baselineContent.Margin.Left)) - 20); double verticalDiff; @@ -388,11 +415,11 @@ await RunShellTest(shell => verticalDiff = Math.Abs(Math.Abs(frameWithMargin.Top - (frameWithoutMargin.Top)) - 30); else { - #if ANDROID +#if ANDROID verticalDiff = Math.Abs(Math.Abs(frameWithMargin.Top - (frameWithoutMargin.Top)) - 30); - #else - verticalDiff = Math.Abs(Math.Abs(frameWithMargin.Top - (frameWithoutMargin.Top - GetSafeArea(handler.ToPlatform()).Top)) - 30); - #endif +#else + verticalDiff = Math.Abs(Math.Abs(frameWithMargin.Top - (frameWithoutMargin.Top - GetSafeArea(shell.Handler.ToPlatform()).Top)) - 30); +#endif } Assert.True(leftDiff < 0.2, $"{partTesting} Left Margin Incorrect. Frame w/ margin: {frameWithMargin}. Frame w/o margin : {frameWithoutMargin}"); @@ -436,7 +463,7 @@ Thickness GetSafeArea(object view) } #endif - async Task RunShellTest(Action action, Func testAction) + protected virtual async Task RunShellTest(Action action, Func testAction) { SetupBuilder(); var shell = await CreateShellAsync((shell) => @@ -446,10 +473,10 @@ async Task RunShellTest(Action action, Func te shell.CurrentItem = new FlyoutItem() { Items = { new ContentPage() } }; }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { await OnNavigatedToAsync(shell.CurrentPage); - await testAction(shell, handler); + await testAction(shell); }); } } diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.iOS.cs index 0a2b8111e4fe..09cc6d21c147 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellFlyoutTests.iOS.cs @@ -1,6 +1,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Handlers.Compatibility; using Microsoft.Maui.Controls.Platform.Compatibility; using Microsoft.Maui.Graphics; using Xunit; @@ -32,8 +33,13 @@ await RunShellTest(shell => shell.FlyoutHeader = layout; shell.FlyoutContent = new ScrollView() { Content = new Label() { Text = "FlyoutContent" } }; }, - async (shell, handler) => + // RunShellTest's callback signature was changed from Func + // to Func so Android handler subclasses can inherit the test without + // a platform-specific handler type leaking into the shared callback signature. + // The handler is now accessed directly from shell.Handler inside the lambda. + async shell => { + var handler = (ShellRenderer)shell.Handler; await OpenFlyout(handler); var flyout = GetFlyoutPlatformView(handler); var header = flyout.Subviews.OfType().First(); diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellHandlerSubclasses.Android.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellHandlerSubclasses.Android.cs new file mode 100644 index 000000000000..2c91281c9439 --- /dev/null +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellHandlerSubclasses.Android.cs @@ -0,0 +1,177 @@ +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Handlers; +using Microsoft.Maui.Controls.Handlers.Items; +using Microsoft.Maui.Controls.Platform.Compatibility; +using Microsoft.Maui.DeviceTests.Stubs; +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 NativeShellHandler = Microsoft.Maui.Controls.Handlers.ShellHandler; + +namespace Microsoft.Maui.DeviceTests +{ + [Category(TestCategory.Shell)] + [Collection(ControlsHandlerTestBase.RunInNewWindowCollection)] + [Trait(RendererHandlerVariant.TraitName, RendererHandlerVariant.AndroidShellHandler)] // See RendererHandlerVariant.cs + public partial class ShellHandlerTests_Shell : ShellTests + { + protected override void SetupBuilder() + { + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handlers => + { + // 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(ShellItem), typeof(ShellItemHandler)); + handlers.AddHandler(typeof(ShellSection), typeof(ShellSectionHandler)); + handlers.AddHandler(typeof(NavigationPage), typeof(NavigationViewHandler)); + handlers.AddHandler(typeof(Button), typeof(ButtonHandler)); + handlers.AddHandler(typeof(Entry), typeof(EntryHandler)); + handlers.AddHandler(typeof(Controls.ContentView), typeof(ContentViewHandler)); + handlers.AddHandler(typeof(ScrollView), typeof(ScrollViewHandler)); + handlers.AddHandler(typeof(CollectionView), typeof(CollectionViewHandler)); + handlers.AddHandler(typeof(TabbedPage), typeof(TabbedViewHandler)); + handlers.AddHandler(typeof(FlyoutPage), typeof(FlyoutViewHandler)); + }); + }); + } + + // NativeShellHandler uses MauiDrawerLayout (not ShellFlyoutRenderer), so cast to MauiDrawerLayout. + protected override DrawerLayout GetDrawerLayout(IShellContext shellContext) + { + return (MauiDrawerLayout)shellContext.CurrentDrawerLayout; + } + + // The base IsBackButtonVisible uses GetPlatformToolbar which has no NativeShellHandler branch. + // NativeShellHandler nests the toolbar inside an outer CoordinatorLayout — walk up to find it. + protected override bool IsBackButtonVisible(IElementHandler handler) + { + if (GetShellHandlerToolbar(handler)?.NavigationIcon is DrawerArrowDrawable drawerArrow) + return drawerArrow.Progress == 1; + + return false; + } + + MaterialToolbar GetShellHandlerToolbar(IElementHandler handler) + { + // Direct NativeShellHandler: toolbar lives in nested CoordinatorLayout of shell.CurrentPage. + if (handler is NativeShellHandler nativeShell) + { + var shell = nativeShell.VirtualView as Shell; + var currentPage = shell?.CurrentPage; + + if (currentPage?.Handler?.PlatformView is AView pagePlatformView) + { + // Walk up CoordinatorLayouts — handler has nested coordinators; + // the toolbar lives in the outer one (navigationlayout.axml). + var coordinator = pagePlatformView.GetParentOfType(); + while (coordinator is not null) + { + var toolbar = coordinator.GetFirstChildOfType(); + if (toolbar is not null) + return toolbar; + + coordinator = (coordinator.Parent as AView)?.GetParentOfType(); + } + } + + return null; + } + + // For page/navigation handlers (NavigationViewHandler, PageHandler, etc.): + // use the handler's own MauiContext directly so modal pages find their own toolbar, + // not the Shell's toolbar via the window content handler. + return GetPlatformToolbar(handler.MauiContext); + } + } + + [Category(TestCategory.Modal)] + [Collection(ControlsHandlerTestBase.RunInNewWindowCollection)] + [Trait(RendererHandlerVariant.TraitName, RendererHandlerVariant.AndroidShellHandler)] // See RendererHandlerVariant.cs + public partial class ModalHandlerTests : ModalTests + { + protected override void SetupBuilder() + { + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handlers => + { + // Register all standard handlers first + SetupShellHandlers(handlers); + handlers.AddHandler(typeof(NavigationPage), typeof(NavigationViewHandler)); + handlers.AddHandler(typeof(FlyoutPage), typeof(FlyoutViewHandler)); + handlers.AddHandler(typeof(TabbedPage), typeof(TabbedViewHandler)); + handlers.AddHandler(); + handlers.AddHandler(); + handlers.AddHandler(typeof(Controls.Shell), typeof(NativeShellHandler)); + handlers.AddHandler(typeof(ShellItem), typeof(ShellItemHandler)); + handlers.AddHandler(typeof(ShellSection), typeof(ShellSectionHandler)); + handlers.AddHandler(typeof(ScrollView), typeof(ScrollViewHandler)); + }); + }); + } + + // Modal pages pushed over Shell need platform-view traversal to find the modal's own toolbar. + // GetPlatformToolbar(MauiContext) resolves to the Shell's NavigationRootManager and finds + // the wrong toolbar (Shell nav toolbar with back button from secondPage). + protected override bool IsBackButtonVisible(IElementHandler handler) + { + // For NavigationPage handlers, check its current page's view hierarchy. + VisualElement visualElement = handler.VirtualView as VisualElement; + if (visualElement is NavigationPage navPage) + visualElement = navPage.CurrentPage; + + if (visualElement?.Handler?.PlatformView is AView platformView) + { + // Walk up CoordinatorLayouts starting from the page's platform view. + // This stays within the modal's view hierarchy, not the Shell's. + var coordinator = platformView.GetParentOfType(); + while (coordinator is not null) + { + var toolbar = coordinator.GetFirstChildOfType(); + if (toolbar?.NavigationIcon is DrawerArrowDrawable drawerArrow) + return drawerArrow.Progress == 1; + + coordinator = (coordinator.Parent as AView)?.GetParentOfType(); + } + } + + return false; + } + } + + [Category(TestCategory.Window)] + [Collection(ControlsHandlerTestBase.RunInNewWindowCollection)] + [Trait(RendererHandlerVariant.TraitName, RendererHandlerVariant.AndroidShellHandler)] // See RendererHandlerVariant.cs + public partial class WindowHandlerTests : WindowTests + { + protected override void SetupBuilder() + { + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handlers => + { + // Register all standard handlers first + SetupShellHandlers(handlers); + handlers.AddHandler(typeof(Controls.Shell), typeof(NativeShellHandler)); + handlers.AddHandler(typeof(ShellItem), typeof(ShellItemHandler)); + handlers.AddHandler(typeof(ShellSection), typeof(ShellSectionHandler)); + handlers.AddHandler(typeof(NavigationPage), typeof(NavigationViewHandler)); + handlers.AddHandler(typeof(TabbedPage), typeof(TabbedViewHandler)); + handlers.AddHandler(typeof(FlyoutPage), typeof(FlyoutViewHandler)); + handlers.AddHandler(typeof(Controls.ContentView), typeof(ContentViewHandler)); + handlers.AddHandler(typeof(ScrollView), typeof(ScrollViewHandler)); + }); + }); + } + } +} diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.Android.cs index ec2b6d03d913..66dd51f738d2 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.Android.cs @@ -8,7 +8,7 @@ using Google.Android.Material.TextView; using Microsoft.Maui.Controls; using Microsoft.Maui.Controls.Handlers; -using Microsoft.Maui.Controls.Handlers.Compatibility; +using Microsoft.Maui.Controls.Platform.Compatibility; using Microsoft.Maui.Graphics; using Microsoft.Maui.Platform; using Xunit; @@ -23,8 +23,8 @@ public partial class ShellTests BottomNavigationView GetTab(ShellSection item) { var shell = item.FindParentOfType(); - var renderer = (ShellRenderer)shell.Handler; - var bottomView = GetDrawerLayout(renderer).GetFirstChildOfType(); + var shellContext = (IShellContext)shell.Handler; + var bottomView = GetDrawerLayout(shellContext).GetFirstChildOfType(); var menu = bottomView.Menu; var index = shell.CurrentItem.Items.IndexOf(item); diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.cs index 81fe5109fc59..c0cbbf33469a 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.cs @@ -116,7 +116,7 @@ await RunShellTabBarTests(shell => Shell.SetTabBarUnselectedColor(shell, expecte }); } - async Task RunShellTabBarTests(Action setup, Func runTest) + protected virtual async Task RunShellTabBarTests(Action setup, Func runTest) { SetupBuilder(); @@ -158,7 +158,7 @@ async Task RunShellTabBarTests(Action setup, Func runTest) }); setup.Invoke(shell); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { await runTest(shell); }); diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs index d4051b5619da..86e7a4c4d86d 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Threading.Tasks; using Android.Views; @@ -67,8 +67,9 @@ public async Task GoingBackUsingGoToAsyncMethod() }); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { + var shellContext = (IShellContext)shell.Handler; await OnLoadedAsync(page1); await shell.GoToAsync("//Item2"); await shell.GoToAsync(".."); @@ -103,11 +104,12 @@ public async Task CanHideNavBarShadow(bool navBarHasShadow) Shell.SetNavBarHasShadow(contentPage, navBarHasShadow); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { + var shellContext = (IShellContext)shell.Handler; await Task.Delay(100); - var platformToolbar = GetPlatformToolbar(handler); + var platformToolbar = GetPlatformToolbar((IPlatformViewHandler)shell.Handler); var appBar = platformToolbar.Parent.GetParentOfType(); if (navBarHasShadow) @@ -117,9 +119,9 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => }); } - protected async Task CheckFlyoutState(ShellRenderer handler, bool desiredState) + protected virtual async Task CheckFlyoutState(IShellContext shellContext, bool desiredState) { - var drawerLayout = GetDrawerLayout(handler); + var drawerLayout = GetDrawerLayout(shellContext); var flyout = drawerLayout.GetChildAt(1); if (drawerLayout.IsDrawerOpen(flyout) == desiredState) @@ -165,11 +167,12 @@ public async Task FlyoutItemsRendererWhenFlyoutBehaviorStartsAsLocked() shell.FlyoutBehavior = FlyoutBehavior.Locked; }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { + var shellContext = (IShellContext)shell.Handler; await Task.Delay(100); - var dl = GetDrawerLayout(handler); - var flyoutContainer = GetFlyoutMenuReyclerView(handler); + var dl = GetDrawerLayout(shellContext); + var flyoutContainer = GetFlyoutMenuReyclerView(shellContext); Assert.True(flyoutContainer.MeasuredWidth > 0); Assert.True(flyoutContainer.MeasuredHeight > 0); @@ -188,9 +191,10 @@ public async Task ShellWithFlyoutDisabledDoesntRenderFlyout() shell.FlyoutBehavior = FlyoutBehavior.Disabled; - await CreateHandlerAndAddToWindow(shell, (handler) => + await CreateHandlerAndAddToWindow(shell, () => { - var dl = GetDrawerLayout(handler); + var shellContext = (IShellContext)shell.Handler; + var dl = GetDrawerLayout(shellContext); Assert.Equal(1, dl.ChildCount); shell.FlyoutBehavior = FlyoutBehavior.Flyout; Assert.Equal(2, dl.ChildCount); @@ -215,8 +219,9 @@ public async Task FooterTemplateMeasuresToSetFlyoutWidth() shell.FlyoutFooter = footer; }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { + var shellContext = (IShellContext)shell.Handler; await OnFrameSetToNotEmpty(footer); Assert.True(Math.Abs(20 - footer.Frame.Width) < 1); Assert.True(footer.Frame.Height > 0); @@ -238,13 +243,14 @@ public async Task FlyoutFooterRenderersWithDefaultFlyoutItems() shell.FlyoutFooter = footer; }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { + var shellContext = (IShellContext)shell.Handler; await Task.Delay(100); - var dl = GetDrawerLayout(handler); - await OpenFlyout(handler); + var dl = GetDrawerLayout(shellContext); + await OpenFlyout(shellContext); - var flyoutContainer = GetFlyoutMenuReyclerView(handler); + var flyoutContainer = GetFlyoutMenuReyclerView(shellContext); Assert.True(flyoutContainer.MeasuredWidth > 0); Assert.True(flyoutContainer.MeasuredHeight > 0); @@ -266,13 +272,14 @@ public async Task FlyoutItemsRenderWhenFlyoutHeaderIsSet() shell.FlyoutHeader = header; }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { + var shellContext = (IShellContext)shell.Handler; await Task.Delay(100); - var dl = GetDrawerLayout(handler); - await OpenFlyout(handler); + var dl = GetDrawerLayout(shellContext); + await OpenFlyout(shellContext); - var flyoutContainer = GetFlyoutMenuReyclerView(handler); + var flyoutContainer = GetFlyoutMenuReyclerView(shellContext); Assert.True(flyoutContainer.MeasuredWidth > 0); Assert.True(flyoutContainer.MeasuredHeight > 0); @@ -304,8 +311,9 @@ public async Task FlyoutHeaderRendersCorrectSizeWithFlyoutContentSet() }; }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { + var shellContext = (IShellContext)shell.Handler; await Task.Delay(100); var headerPlatformView = header.ToPlatform(); var appBar = headerPlatformView.GetParentOfType(); @@ -358,9 +366,10 @@ public async Task ChangingBottomTabAttributesDoesntRecreateBottomTabs() shell.Items.Add(new Tab() { Items = { new ContentPage() }, Title = "Tab 2", Icon = "red.png" }); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { - var menu = GetDrawerLayout(handler).GetFirstChildOfType().Menu; + var shellContext = (IShellContext)shell.Handler; + var menu = GetDrawerLayout(shellContext).GetFirstChildOfType().Menu; var menuItem1 = menu.GetItem(0); var menuItem2 = menu.GetItem(1); var icon1 = menuItem1.Icon; @@ -377,7 +386,7 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => // let the icon and title propagate await AssertEventually(() => menuItem1.Icon != icon1); - menu = GetDrawerLayout(handler).GetFirstChildOfType().Menu; + menu = GetDrawerLayout(shellContext).GetFirstChildOfType().Menu; Assert.Equal(menuItem1, menu.GetItem(0)); Assert.Equal(menuItem2, menu.GetItem(1)); @@ -403,9 +412,10 @@ public async Task RemovingBottomTabDoesntRecreateMenu() shell.Items.Add(new Tab() { Items = { new ContentPage() }, Title = "Tab 3", Icon = "red.png" }); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { - var bottomView = GetDrawerLayout(handler).GetFirstChildOfType(); + var shellContext = (IShellContext)shell.Handler; + var bottomView = GetDrawerLayout(shellContext).GetFirstChildOfType(); var menu = bottomView.Menu; var menuItem1 = menu.GetItem(0); var menuItem2 = menu.GetItem(1); @@ -432,9 +442,10 @@ public async Task AddingBottomTabDoesntRecreateMenu() shell.Items.Add(new Tab() { Items = { new ContentPage() }, Title = "Tab 3", Icon = "red.png" }); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { - var bottomView = GetDrawerLayout(handler).GetFirstChildOfType(); + var shellContext = (IShellContext)shell.Handler; + var bottomView = GetDrawerLayout(shellContext).GetFirstChildOfType(); var menu = bottomView.Menu; var menuItem1 = menu.GetItem(0); var menuItem2 = menu.GetItem(1); @@ -454,6 +465,80 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => }); } + [Fact] + public async Task ReusedBottomTabReappliesEnabledState() + { + SetupBuilder(); + + var shell = await CreateShellAsync(shell => + { + shell.Items.Add(new Tab() { Items = { new ContentPage() }, Title = "Tab 1", Icon = "red.png" }); + shell.Items.Add(new Tab() { Items = { new ContentPage() }, Title = "Tab 2", Icon = "red.png", IsEnabled = false }); + shell.Items.Add(new Tab() { Items = { new ContentPage() }, Title = "Tab 3", Icon = "red.png" }); + }); + + await CreateHandlerAndAddToWindow(shell, async () => + { + var shellContext = (IShellContext)shell.Handler; + var bottomView = GetDrawerLayout(shellContext).GetFirstChildOfType(); + var menu = bottomView.Menu; + var menuItem2 = menu.GetItem(1); + + Assert.False(menuItem2.IsEnabled); + + // Remove an unrelated tab so SetupMenu re-runs and this position-stable + // item goes through the reused-item branch — it must reapply the + // disabled state on reuse, not silently reset it to enabled. + shell.CurrentItem.Items.RemoveAt(2); + + // let the change propagate + await AssertEventually(() => bottomView.Menu.Size() == 2); + + menu = bottomView.Menu; + Assert.Equal(menuItem2, menu.GetItem(1)); + Assert.False(menuItem2.IsEnabled); + }); + } + + [Fact] + public async Task MoreOverflowItemIsReusedNotRecreated() + { + SetupBuilder(); + + var shell = await CreateShellAsync(shell => + { + for (int i = 1; i <= 7; i++) + { + shell.Items.Add(new Tab() { Items = { new ContentPage() }, Title = $"Tab {i}", Icon = "red.png" }); + } + }); + + await CreateHandlerAndAddToWindow(shell, async () => + { + var shellContext = (IShellContext)shell.Handler; + var bottomView = GetDrawerLayout(shellContext).GetFirstChildOfType(); + var menu = bottomView.Menu; + + // 4 regular tabs + "More" overflow item covering the remaining 3 + Assert.Equal(5, menu.Size()); + var moreItem = menu.GetItem(4); + Assert.Equal(BottomNavigationViewUtils.MoreTabId, moreItem.ItemId); + + // Remove one of the overflowed tabs while overflow is still active + // (6 tabs remain, still over the 5-item max) — the "More" IMenuItem + // must be reused, not recreated, since it's not structurally changing. + shell.CurrentItem.Items.RemoveAt(6); + + // let the change propagate + await AssertEventually(() => shell.CurrentItem.Items.Count == 6); + + menu = bottomView.Menu; + Assert.Equal(5, menu.Size()); + Assert.Equal(moreItem, menu.GetItem(4)); + Assert.Equal(BottomNavigationViewUtils.MoreTabId, menu.GetItem(4).ItemId); + }); + } + //src/Compatibility/Core/tests/Android/ShellTests.cs [Fact(DisplayName = "Flyout Header Changes When Updated")] public async Task FlyoutHeaderReactsToChanges() @@ -469,8 +554,9 @@ public async Task FlyoutHeaderReactsToChanges() shell.FlyoutHeader = initialHeader; }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { + var shellContext = (IShellContext)shell.Handler; var initialHeaderPlatformView = initialHeader.ToPlatform(); Assert.NotNull(initialHeaderPlatformView); Assert.NotNull(initialHeader.Handler); @@ -483,7 +569,7 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => Assert.Null(initialHeader.Handler); - await OpenFlyout(handler); + await OpenFlyout(shellContext); var appBar = newHeaderPlatformView.GetParentOfType(); Assert.NotNull(appBar); @@ -501,9 +587,10 @@ public async Task ShellTabColorsDefaultToWhite() shell.Items.Add(new Tab() { Items = { new ContentPage() }, Title = "Tab 1" }); }); - await CreateHandlerAndAddToWindow(shell, (handler) => + await CreateHandlerAndAddToWindow(shell, () => { - var bottomNavigationView = GetDrawerLayout(handler).GetFirstChildOfType(); + var shellContext = (IShellContext)shell.Handler; + var bottomNavigationView = GetDrawerLayout(shellContext).GetFirstChildOfType(); Assert.NotNull(bottomNavigationView); var background = bottomNavigationView.Background; @@ -541,8 +628,9 @@ public async Task ShellContentFragmentDestroyHandlesNullShellContext() }); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { + var shellContext = (IShellContext)shell.Handler; await OnLoadedAsync(shell.CurrentPage); await OnNavigatedToAsync(shell.CurrentPage); @@ -571,15 +659,15 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => }); } - protected AView GetFlyoutPlatformView(ShellRenderer shellRenderer) + protected virtual AView GetFlyoutPlatformView(IShellContext shellContext) { - var drawerLayout = GetDrawerLayout(shellRenderer); + var drawerLayout = GetDrawerLayout(shellContext); return drawerLayout.GetChildrenOfType().First(); } - internal Graphics.Rect GetFlyoutFrame(ShellRenderer shellRenderer) + internal virtual Graphics.Rect GetFlyoutFrame(IShellContext shellContext) { - var platformView = GetFlyoutPlatformView(shellRenderer); + var platformView = GetFlyoutPlatformView(shellContext); var context = platformView.Context; return new Graphics.Rect(0, 0, @@ -587,22 +675,22 @@ internal Graphics.Rect GetFlyoutFrame(ShellRenderer shellRenderer) context.FromPixels(platformView.MeasuredHeight - (platformView.PaddingTop + platformView.PaddingBottom))); } - internal Graphics.Rect GetFrameRelativeToFlyout(ShellRenderer shellRenderer, IView view) + internal virtual Graphics.Rect GetFrameRelativeToFlyout(IShellContext shellContext, IView view) { var platformView = (view.Handler as IPlatformViewHandler).PlatformView; - return platformView.GetFrameRelativeTo(GetFlyoutPlatformView(shellRenderer)); + return platformView.GetFrameRelativeTo(GetFlyoutPlatformView(shellContext)); } - protected async Task OpenFlyout(ShellRenderer shellRenderer, TimeSpan? timeOut = null) + protected virtual async Task OpenFlyout(IShellContext shellContext, TimeSpan? timeOut = null) { - var flyoutView = GetFlyoutPlatformView(shellRenderer); - var drawerLayout = GetDrawerLayout(shellRenderer); + var flyoutView = GetFlyoutPlatformView(shellContext); + var drawerLayout = GetDrawerLayout(shellContext); - if (!drawerLayout.FlyoutFirstDrawPassFinished) + if (drawerLayout is ShellFlyoutRenderer sfr && !sfr.FlyoutFirstDrawPassFinished) await Task.Delay(10); var hamburger = - GetPlatformToolbar((IPlatformViewHandler)shellRenderer).GetChildrenOfType().FirstOrDefault() ?? + GetPlatformToolbar((IPlatformViewHandler)shellContext).GetChildrenOfType().FirstOrDefault() ?? throw new InvalidOperationException("Unable to find Drawer Button"); timeOut = timeOut ?? TimeSpan.FromSeconds(2); @@ -620,9 +708,8 @@ void OnDrawerOpened(object sender, DrawerLayout.DrawerOpenedEventArgs e) } } - protected async Task ScrollFlyoutToBottom(ShellRenderer shellRenderer) + protected virtual async Task ScrollFlyoutToBottom(IShellContext shellContext) { - IShellContext shellContext = shellRenderer; DrawerLayout dl = shellContext.CurrentDrawerLayout; var viewGroup = dl.GetChildAt(1) as ViewGroup; var scrollView = viewGroup?.GetChildAt(0); @@ -705,15 +792,13 @@ void OnFlyoutItemsScrollChange(object sender, NestedScrollView.ScrollChangeEvent return verticalOffset; } - ShellFlyoutRenderer GetDrawerLayout(ShellRenderer shellRenderer) + protected virtual DrawerLayout GetDrawerLayout(IShellContext shellContext) { - IShellContext shellContext = shellRenderer; - return (ShellFlyoutRenderer)shellContext.CurrentDrawerLayout; + return shellContext.CurrentDrawerLayout; } - RecyclerView GetFlyoutMenuReyclerView(ShellRenderer shellRenderer) + protected virtual RecyclerView GetFlyoutMenuReyclerView(IShellContext shellContext) { - IShellContext shellContext = shellRenderer; DrawerLayout dl = shellContext.CurrentDrawerLayout; var flyout = dl.GetChildAt(0); diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs index 020969604cc4..67a927d446a4 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs @@ -20,6 +20,7 @@ #if ANDROID || IOS || MACCATALYST using ShellHandler = Microsoft.Maui.Controls.Handlers.Compatibility.ShellRenderer; +using Microsoft.Maui.Controls.Platform.Compatibility; #endif #if IOS || MACCATALYST @@ -30,9 +31,10 @@ namespace Microsoft.Maui.DeviceTests { [Category(TestCategory.Shell)] [Collection(ControlsHandlerTestBase.RunInNewWindowCollection)] + [Trait(RendererHandlerVariant.TraitName, RendererHandlerVariant.AndroidShellRenderer)] // See RendererHandlerVariant.cs public partial class ShellTests : ControlsHandlerTestBase { - void SetupBuilder() + protected virtual void SetupBuilder() { EnsureHandlerCreated(builder => { @@ -98,7 +100,7 @@ public async Task SearchHandlerRendersCorrectly() Shell.SetSearchHandler(shell, new SearchHandler() { SearchBoxVisibility = SearchBoxVisibility.Expanded }); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { await OnLoadedAsync(shell.CurrentPage); await OnNavigatedToAsync(shell.CurrentPage); @@ -357,7 +359,7 @@ public async Task FlyoutContentRenderersWhenFlyoutBehaviorStartsAsLocked() shell.FlyoutBehavior = FlyoutBehavior.Locked; }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { await OnFrameSetToNotEmpty(flyoutContent); @@ -378,11 +380,19 @@ public async Task FlyoutIsPresented() shell.FlyoutIsPresented = true; }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { - await CheckFlyoutState(handler, true); + #if ANDROID || IOS || MACCATALYST + var shellContext = (IShellContext)shell.Handler; + await CheckFlyoutState(shellContext, true); shell.FlyoutIsPresented = false; - await CheckFlyoutState(handler, false); + await CheckFlyoutState(shellContext, false); +#else + var shellContext = (ShellHandler)shell.Handler; + await CheckFlyoutState(shellContext, true); + shell.FlyoutIsPresented = false; + await CheckFlyoutState(shellContext, false); +#endif }); } #endif @@ -396,13 +406,13 @@ public async Task BackButtonVisibilityChangesWithPushPop() shell.CurrentItem = new ContentPage(); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { - Assert.False(IsBackButtonVisible(handler)); + Assert.False(IsBackButtonVisible(shell.Handler)); await shell.Navigation.PushAsync(new ContentPage()); - Assert.True(IsBackButtonVisible(handler)); + Assert.True(IsBackButtonVisible(shell.Handler)); await shell.Navigation.PopAsync(); - Assert.False(IsBackButtonVisible(handler)); + Assert.False(IsBackButtonVisible(shell.Handler)); }); } @@ -429,7 +439,7 @@ public async Task PushingTheSamePageUpdatesToolbar() shell.CurrentItem = new ContentPage(); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { await shell.Navigation.PushAsync(pushedPage); await shell.Navigation.PopAsync(); @@ -449,7 +459,7 @@ public async Task SetHasBackButton() shell.CurrentItem = new ContentPage(); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { Assert.False(IsBackButtonVisible(shell.Handler)); await shell.Navigation.PushAsync(new ContentPage()); @@ -492,7 +502,7 @@ public async Task CorrectlyAdjustToMakingCurrentlyVisibleShellPageInvisible() shell.Items.Add(tabBar); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { await OnNavigatedToAsync(page1); shell.CurrentItem = page2; @@ -521,7 +531,7 @@ public async Task DetailsViewUpdates() }; }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { // TODO MAUI Fix this await Task.Delay(100); @@ -571,14 +581,14 @@ public async Task TitleViewUpdateToCurrentlyVisiblePage() }); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { await OnLoadedAsync(page1); - Assert.Equal(titleView1.ToPlatform(), GetTitleView(handler)); + Assert.Equal(titleView1.ToPlatform(), GetTitleView(shell.Handler)); bool viewIsTitleView(IView view) { - return view.Handler != null && view.ToPlatform() == GetTitleView(handler); + return view.Handler != null && view.ToPlatform() == GetTitleView(shell.Handler); } await shell.GoToAsync("//Item2"); @@ -623,7 +633,7 @@ public async Task HandlersNotRecreatedWhenChangingTabs() }); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { var initialHandler = page1.Handler; await shell.GoToAsync("//Item2"); @@ -646,7 +656,7 @@ public async Task NavigatedFiresAfterSwitchingFlyoutItems() shell.Items.Add(shellContent2); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { IShellController shellController = shell; var currentItem = shell.CurrentItem; @@ -719,7 +729,7 @@ public async Task BasicShellNavigationStructurePermutations(ShellItem[] shellIte return value; }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { // TODO MAUI Fix this await Task.Delay(100); @@ -738,7 +748,7 @@ public async Task NavigateToRootWithBackButtonBehaviorNoCrash() shell.CurrentItem = new ContentPage(); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { Assert.False(IsBackButtonVisible(shell.Handler)); var secondPage = new ContentPage(); @@ -796,7 +806,7 @@ public async Task LifeCycleEventsFireWhenNavigatingTopTabs() }); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { await OnNavigatedToAsync(page1); @@ -944,7 +954,7 @@ public async Task PagesDoNotLeak() WeakReference pageReference = null; - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { await OnLoadedAsync(shell.CurrentPage); @@ -1092,7 +1102,7 @@ public async Task ShellAddRemoveItems() shell.Items.Add(rootItem); }); - await CreateHandlerAndAddToWindow(shell, async (handler) => + await CreateHandlerAndAddToWindow(shell, async () => { rootItem.IsVisible = true; diff --git a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.cs b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.cs index dbe5533fe0e1..95c67863b8fd 100644 --- a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.cs @@ -35,9 +35,10 @@ namespace Microsoft.Maui.DeviceTests #if ANDROID || IOS || MACCATALYST [Collection(ControlsHandlerTestBase.RunInNewWindowCollection)] #endif + [Trait(RendererHandlerVariant.TraitName, RendererHandlerVariant.AndroidShellRenderer)] // See RendererHandlerVariant.cs public partial class WindowTests : ControlsHandlerTestBase { - void SetupBuilder() + protected virtual void SetupBuilder() { EnsureHandlerCreated(builder => { diff --git a/src/TestUtils/src/DeviceTests/xUnitCustomizations.cs b/src/TestUtils/src/DeviceTests/xUnitCustomizations.cs index cffc999d9a30..e06c26b46ee5 100644 --- a/src/TestUtils/src/DeviceTests/xUnitCustomizations.cs +++ b/src/TestUtils/src/DeviceTests/xUnitCustomizations.cs @@ -213,13 +213,61 @@ string GetCategoryPrefix() return _categoryPrefix; } + string? _reuseVariantPrefix; + + // Android-only: Shell/Modal/Window tests reuse the same test bodies across a renderer base + // class and a handler subclass (see ShellHandlerSubclasses.Android.cs), so DisplayName alone + // can't tell them apart. Tag the subclass run as "[Handler]" and the base run as "[Renderer]". + string GetReuseVariantPrefix() + { + if (_reuseVariantPrefix == null) + { +#if ANDROID + try + { + if (Traits.TryGetValue("Variant", out var variants) && variants is not null) + { + // Check Handler first: a Handler subclass also inherits the base class's + // "Renderer" trait, so Traits may contain both values for this key. + // These literals must stay in sync with RendererHandlerVariant.cs (Controls.DeviceTests). + if (variants.Contains("Handler")) + { + _reuseVariantPrefix = "[Handler] "; + } + else if (variants.Contains("Renderer")) + { + _reuseVariantPrefix = "[Renderer] "; + } + else + { + _reuseVariantPrefix = string.Empty; + } + } + else + { + _reuseVariantPrefix = string.Empty; + } + } + catch + { + // Never let display-name resolution crash the test run. + _reuseVariantPrefix = string.Empty; + } +#else + _reuseVariantPrefix = string.Empty; +#endif + } + + return _reuseVariantPrefix; + } + string? _displayName; public string DisplayName { get { - _displayName = _displayName ?? $"{GetCategoryPrefix()}{_inner.DisplayName}"; + _displayName = _displayName ?? $"{GetCategoryPrefix()}{GetReuseVariantPrefix()}{_inner.DisplayName}"; return _displayName; } }