diff --git a/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs b/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs index 9512dea98e39..73a2b4065ffe 100644 --- a/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs +++ b/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs @@ -1,24 +1,14 @@ -namespace Maui.Controls.Sample; +namespace Maui.Controls.Sample; public partial class App : Application { - public App() - { - InitializeComponent(); - } - - protected override Window CreateWindow(IActivationState? activationState) - { - // To test shell scenarios, change this to true - bool useShell = false; +public App() +{ +InitializeComponent(); +} - if (!useShell) - { - return new Window(new NavigationPage(new MainPage())); - } - else - { - return new Window(new SandboxShell()); - } - } +protected override Window CreateWindow(IActivationState? activationState) +{ +return new Window(new SandboxShell()); +} } diff --git a/src/Controls/samples/Controls.Sample.Sandbox/MainPage2.xaml b/src/Controls/samples/Controls.Sample.Sandbox/MainPage2.xaml new file mode 100644 index 000000000000..1d8367a1d780 --- /dev/null +++ b/src/Controls/samples/Controls.Sample.Sandbox/MainPage2.xaml @@ -0,0 +1,7 @@ + + + diff --git a/src/Controls/samples/Controls.Sample.Sandbox/MainPage2.xaml.cs b/src/Controls/samples/Controls.Sample.Sandbox/MainPage2.xaml.cs new file mode 100644 index 000000000000..5300db6a8bff --- /dev/null +++ b/src/Controls/samples/Controls.Sample.Sandbox/MainPage2.xaml.cs @@ -0,0 +1,9 @@ +namespace Maui.Controls.Sample; + +public partial class MainPage2 : ContentPage +{ + public MainPage2() + { + InitializeComponent(); + } +} diff --git a/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml b/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml index 358b0b04ef63..3d4d59d20de1 100644 --- a/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml +++ b/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml @@ -1,22 +1,12 @@ - - - - - - - - + + + + + - \ No newline at end of file + diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/SafeShellTabBarAppearanceTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/SafeShellTabBarAppearanceTracker.cs index 168d119d8e2c..ec3c3b80cc37 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/SafeShellTabBarAppearanceTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/SafeShellTabBarAppearanceTracker.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using Microsoft.Maui.Platform; using ObjCRuntime; using UIKit; @@ -13,6 +14,11 @@ public class SafeShellTabBarAppearanceTracker : IShellTabBarAppearanceTracker UIColor _defaultTint; UIColor _defaultUnselectedTint; UITabBarAppearance _tabBarAppearance; + + // iOS 26+ stores pending colors to re-apply during layout + UIColor _pendingUnselectedTintColor; + UIColor _pendingSelectedTintColor; + public virtual void ResetAppearance(UITabBarController controller) { if (_defaultTint == null) @@ -22,6 +28,8 @@ public virtual void ResetAppearance(UITabBarController controller) tabBar.BarTintColor = _defaultBarTint; tabBar.TintColor = _defaultTint; tabBar.UnselectedItemTintColor = _defaultUnselectedTint; + _pendingUnselectedTintColor = null; + _pendingSelectedTintColor = null; } public virtual void SetAppearance(UITabBarController controller, ShellAppearance appearance) @@ -50,6 +58,19 @@ public virtual void SetAppearance(UITabBarController controller, ShellAppearance public virtual void UpdateLayout(UITabBarController controller) { + // iOS 26+: Re-apply colors on every layout pass. + // The liquid glass tab bar resets subview properties during layout. + if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) + { + var tabBar = controller.TabBar; + if (_pendingSelectedTintColor is not null) + tabBar.TintColor = _pendingSelectedTintColor; + if (_pendingUnselectedTintColor is not null) + { + tabBar.UnselectedItemTintColor = _pendingUnselectedTintColor; + tabBar.ApplyPreColoredImagesForIOS26(_pendingUnselectedTintColor, _pendingSelectedTintColor); + } + } } #region IDisposable Support @@ -76,6 +97,55 @@ void UpdateiOS15TabBarAppearance(UITabBarController controller, ShellAppearance var unselectedColor = appearanceElement.EffectiveTabBarUnselectedColor; var titleColor = appearanceElement.EffectiveTabBarTitleColor; + // iOS 26+: The UITabBarAppearance Normal state (TitleTextAttributes, IconColor) is + // ignored by the liquid glass tab bar. Skip the full appearance pipeline and use + // direct UITabBar properties plus subview coloring instead. + // See: https://github.com/dotnet/maui/issues/32125, https://github.com/dotnet/maui/issues/34605 + if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) + { + var tabBar = controller.TabBar; + + // Background color via appearance (this still works on iOS 26) + if (_tabBarAppearance == null) + { + _tabBarAppearance = new UITabBarAppearance(); + _tabBarAppearance.ConfigureWithDefaultBackground(); + } + if (backgroundColor is not null) + _tabBarAppearance.BackgroundColor = backgroundColor.ToPlatform(); + + tabBar.StandardAppearance = _tabBarAppearance; + tabBar.ScrollEdgeAppearance = _tabBarAppearance; + + // Selected color via TintColor (works on iOS 26) + var selectedColor = foregroundColor ?? titleColor; + if (selectedColor is not null) + { + _pendingSelectedTintColor = selectedColor.ToPlatform(); + tabBar.TintColor = _pendingSelectedTintColor; + } + else + { + _pendingSelectedTintColor = null; + tabBar.TintColor = _defaultTint; + } + + // Unselected color: set property + pre-colored images for visual rendering + if (unselectedColor is not null) + { + _pendingUnselectedTintColor = unselectedColor.ToPlatform(); + tabBar.UnselectedItemTintColor = _pendingUnselectedTintColor; + tabBar.ApplyPreColoredImagesForIOS26(_pendingUnselectedTintColor, _pendingSelectedTintColor); + } + else + { + _pendingUnselectedTintColor = null; + tabBar.UnselectedItemTintColor = _defaultUnselectedTint; + } + + return; + } + controller.TabBar .UpdateiOS15TabBarAppearance( ref _tabBarAppearance, diff --git a/src/Controls/src/Core/Compatibility/Handlers/TabbedPage/iOS/TabbedRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/TabbedPage/iOS/TabbedRenderer.cs index 78a0b58b383a..53d7f850be8e 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/TabbedPage/iOS/TabbedRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/TabbedPage/iOS/TabbedRenderer.cs @@ -8,6 +8,7 @@ using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Controls.Platform; using Microsoft.Maui.Graphics; +using Microsoft.Maui.Platform; using UIKit; using static Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific.Page; using PageUIStatusBarAnimation = Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific.UIStatusBarAnimation; @@ -29,6 +30,10 @@ public class TabbedRenderer : UITabBarController, IPlatformViewHandler UITabBarAppearance _tabBarAppearance; WeakReference _element; + // iOS 26+: cached unselected tint color for re-application during layout + UIColor _pendingUnselectedTintColor; + UIColor _pendingSelectedTintColor; + Brush _currentBarBackground; IMauiContext MauiContext => _mauiContext; @@ -136,6 +141,15 @@ public override void ViewDidLayoutSubviews() // in narrow viewports (< 667 points) before Element is set. Guard against this. if (Element is IView view) view.Arrange(View.Bounds.ToRectangle()); + + // iOS 26+: Re-apply unselected item colors on every layout pass. + // The liquid glass tab bar resets subview tint colors during layout. + if ((OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) + && _pendingUnselectedTintColor is not null && TabBar is not null) + { + TabBar.UnselectedItemTintColor = _pendingUnselectedTintColor; + TabBar.ApplyPreColoredImagesForIOS26(_pendingUnselectedTintColor, _pendingSelectedTintColor); + } } protected override void Dispose(bool disposing) @@ -190,7 +204,7 @@ void OnPagePropertyChanged(object sender, PropertyChangedEventArgs e) UpdateTabBarItem(page); } } - + public override void TraitCollectionDidChange(UITraitCollection previousTraitCollection) { if (previousTraitCollection.VerticalSizeClass == TraitCollection.VerticalSizeClass) @@ -610,15 +624,32 @@ void UpdateiOS15TabBarAppearance() { if (Tabbed is not TabbedPage tabbed) return; + + var unselectedTabColor = tabbed.IsSet(TabbedPage.UnselectedTabColorProperty) ? tabbed.UnselectedTabColor : null; + var barTextColor = tabbed.IsSet(TabbedPage.BarTextColorProperty) ? tabbed.BarTextColor : null; + TabBar.UpdateiOS15TabBarAppearance( ref _tabBarAppearance, _defaultBarColor, _defaultBarTextColor, tabbed.IsSet(TabbedPage.SelectedTabColorProperty) ? tabbed.SelectedTabColor : null, - tabbed.IsSet(TabbedPage.UnselectedTabColorProperty) ? tabbed.UnselectedTabColor : null, + unselectedTabColor, tabbed.IsSet(TabbedPage.BarBackgroundColorProperty) ? tabbed.BarBackgroundColor : null, - tabbed.IsSet(TabbedPage.BarTextColorProperty) ? tabbed.BarTextColor : null, - tabbed.IsSet(TabbedPage.BarTextColorProperty) ? tabbed.BarTextColor : null); + barTextColor, + barTextColor); + + // Cache the effective colors for iOS 26 layout re-application + if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) + { + _pendingUnselectedTintColor = unselectedTabColor?.ToPlatform() + ?? barTextColor?.ToPlatform() + ?? _defaultBarTextColor; + + var selectedTabColor2 = tabbed.IsSet(TabbedPage.SelectedTabColorProperty) ? tabbed.SelectedTabColor : null; + _pendingSelectedTintColor = selectedTabColor2?.ToPlatform() + ?? barTextColor?.ToPlatform() + ?? _defaultBarTextColor; + } } #region IPlatformViewHandler diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.cs index 81fe5109fc59..94979d06046e 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.cs @@ -23,8 +23,7 @@ public partial class ShellTests public async Task ForegroundColorSetsIconAndTitleColorSetsTitle() { #if IOS || MACCATALYST - // Skip on iOS 26+ due to UITabBar internal API changes - // See: https://github.com/dotnet/maui/issues/33004 + // Pixel-based tab bar color verification doesn't work on iOS 26+ due to UITabBar rendering changes if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) return; #endif @@ -54,8 +53,7 @@ await RunShellTabBarTests(shell => public async Task ShellTabBarTitleColorInitializesCorrectly(string colorHex) { #if IOS || MACCATALYST - // Skip on iOS 26+ due to UITabBar internal API changes - // See: https://github.com/dotnet/maui/issues/33004 + // Pixel-based tab bar color verification doesn't work on iOS 26+ due to UITabBar rendering changes if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) return; #endif @@ -78,8 +76,7 @@ await RunShellTabBarTests(shell => Shell.SetTabBarTitleColor(shell, expectedColo public async Task ShellTabBarForegroundInitializesCorrectly(string colorHex) { #if IOS || MACCATALYST - // Skip on iOS 26+ due to UITabBar internal API changes - // See: https://github.com/dotnet/maui/issues/33004 + // Pixel-based tab bar color verification doesn't work on iOS 26+ due to UITabBar rendering changes if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) return; #endif @@ -100,8 +97,7 @@ await RunShellTabBarTests(shell => Shell.SetTabBarForegroundColor(shell, expecte public async Task ShellTabBarUnselectedColorInitializesCorrectly(string colorHex) { #if IOS || MACCATALYST - // Skip on iOS 26+ due to UITabBar internal API changes - // See: https://github.com/dotnet/maui/issues/33004 + // Pixel-based tab bar color verification doesn't work on iOS 26+ due to UITabBar rendering changes if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) return; #endif @@ -116,6 +112,39 @@ await RunShellTabBarTests(shell => Shell.SetTabBarUnselectedColor(shell, expecte }); } + // Regression test for https://github.com/dotnet/maui/issues/32125 + // On iOS 26+, Shell.TabBarUnselectedColor was not applied to unselected tabs + [Fact(DisplayName = "Shell TabBar UnselectedColor and TitleColor work together")] + public async Task ShellTabBarUnselectedAndTitleColorWorkTogether() + { + var titleColor = Color.FromArgb("#FFFF0000"); + var unselectedColor = Color.FromArgb("#FF808080"); + await RunShellTabBarTests(shell => + { + Shell.SetTabBarTitleColor(shell, titleColor); + Shell.SetTabBarUnselectedColor(shell, unselectedColor); + }, + async (shell) => + { +#if IOS || MACCATALYST + if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) + { + // On iOS 26+, verify the UnselectedItemTintColor property directly + // because pixel-based verification doesn't work with the new tab bar rendering + await ValidateTabBarUnselectedTintColorProperty(shell.CurrentSection, unselectedColor); + } + else +#endif + { + // Selected tab should use title color + await ValidateTabBarTextColor(shell.CurrentSection, titleColor, true); + // Unselected tab should use unselected color + await ValidateTabBarTextColor(shell.Items[0].Items[1], unselectedColor, true); + await ValidateTabBarIconColor(shell.Items[0].Items[1], unselectedColor, true); + } + }); + } + async Task RunShellTabBarTests(Action setup, Func runTest) { SetupBuilder(); diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.iOS.cs index 109d30a17d73..680fe5a63685 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTabBarTests.iOS.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Platform.Compatibility; using Microsoft.Maui.Graphics; using Microsoft.Maui.Platform; using UIKit; @@ -15,12 +16,35 @@ public partial class ShellTests UITabBar GetTabBar(ShellSection item) { var shellItem = item.Parent as ShellItem; - var shell = shellItem.Parent as Shell; + var shell = shellItem?.Parent as Shell; - var pagerParent = (shell.CurrentPage.Handler as IPlatformViewHandler) - .PlatformView.FindParent(x => x.NextResponder is UITabBarController); + if (shell?.Handler is IShellContext shellContext) + { + if (shellContext.CurrentShellItemRenderer is UITabBarController tabBarController) + return tabBarController.TabBar; + } + + // Fallback: walk the view hierarchy from the current page + var platformView = (shell?.CurrentPage?.Handler as IPlatformViewHandler)?.PlatformView; + if (platformView is null) + return null; + + var pagerParent = platformView.FindParent(x => x.NextResponder is UITabBarController); + + if (pagerParent is null) + { + // iOS 26+: walk the responder chain to find the UITabBarController directly + UIResponder responder = platformView; + while (responder != null) + { + if (responder is UITabBarController tbc) + return tbc.TabBar; + responder = responder.NextResponder; + } + return null; + } - // In macOS 15 Sequoia, the UITabBar is nested within the second subview (index 1) of the pagerParent. + // In macOS 15 Sequoia, the UITabBar is nested within the second subview (index 1) of the pagerParent. if (OperatingSystem.IsMacCatalystVersionAtLeast(15, 0) || OperatingSystem.IsMacOSVersionAtLeast(15, 0)) { var subview = pagerParent.Subviews.ElementAtOrDefault(1); @@ -67,5 +91,16 @@ await AssertionExtensions.AssertTabItemTextDoesNotContainColor(GetTabBar(item), item.Title, textColor, MauiContext); } } + + Task ValidateTabBarUnselectedTintColorProperty(ShellSection item, Color expectedColor) + { + var tabBar = GetTabBar(item); + Assert.NotNull(tabBar); + Assert.NotNull(tabBar.UnselectedItemTintColor); + Assert.True( + ColorComparison.ARGBEquivalent(tabBar.UnselectedItemTintColor, expectedColor.ToPlatform(), 0.1), + $"Expected UnselectedItemTintColor to be {expectedColor} but got {tabBar.UnselectedItemTintColor}"); + return Task.CompletedTask; + } } } \ No newline at end of file diff --git a/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.cs b/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.cs index f8ddc4ac8cb5..065494182538 100644 --- a/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.cs @@ -81,8 +81,8 @@ await CreateHandlerAndAddToWindow(new Window(tabbedPage), (ha public async Task BarTextColor() { #if IOS || MACCATALYST - // Skip on iOS 26+ due to UITabBar internal API changes - // See: https://github.com/dotnet/maui/issues/33004 + // Pixel-based text color verification doesn't work on iOS 26+ due to UITabBar rendering changes. + // Property-based tests (UnselectedItemTintColorSetFromBarTextColor) verify the fix instead. if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) return; #endif @@ -96,20 +96,11 @@ public async Task BarTextColor() tabbedPage.BarTextColor = Colors.Red; await CreateHandlerAndAddToWindow(tabbedPage, async handler => { - // Pre iOS15 you couldn't set the text color of the unselected tab - // so only android/windows currently set the color of both - -#if IOS - bool unselectedMatchesSelected = false; -#else - bool unselectedMatchesSelected = true; -#endif - await ValidateTabBarTextColor(tabbedPage, tabbedPage.Children[0].Title, Colors.Red, true); - await ValidateTabBarTextColor(tabbedPage, tabbedPage.Children[1].Title, Colors.Red, unselectedMatchesSelected); + await ValidateTabBarTextColor(tabbedPage, tabbedPage.Children[1].Title, Colors.Red, true); tabbedPage.BarTextColor = Colors.Blue; await ValidateTabBarTextColor(tabbedPage, tabbedPage.Children[0].Title, Colors.Blue, true); - await ValidateTabBarTextColor(tabbedPage, tabbedPage.Children[1].Title, Colors.Blue, unselectedMatchesSelected); + await ValidateTabBarTextColor(tabbedPage, tabbedPage.Children[1].Title, Colors.Blue, true); }); } @@ -121,8 +112,8 @@ await CreateHandlerAndAddToWindow(tabbedPage, async handler = public async Task SelectedAndUnselectedTabColor() { #if IOS || MACCATALYST - // Skip on iOS 26+ due to UITabBar internal API changes - // See: https://github.com/dotnet/maui/issues/33004 + // Pixel-based text color verification doesn't work on iOS 26+ due to UITabBar rendering changes. + // Property-based tests (UnselectedItemTintColorSetFromUnselectedTabColor) verify the fix instead. if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) return; #endif @@ -135,15 +126,8 @@ public async Task SelectedAndUnselectedTabColor() await CreateHandlerAndAddToWindow(tabbedPage, async handler => { - // Pre iOS15 you couldn't set the text color of the unselected tab - // so only android/windows currently set the color of both -#if IOS - bool unselectedMatchesTabColor = false; -#else - bool unselectedMatchesTabColor = true; -#endif await ValidateTabBarTextColor(tabbedPage, tabbedPage.Children[0].Title, Colors.Red, true); - await ValidateTabBarTextColor(tabbedPage, tabbedPage.Children[1].Title, Colors.Purple, unselectedMatchesTabColor); + await ValidateTabBarTextColor(tabbedPage, tabbedPage.Children[1].Title, Colors.Purple, true); await ValidateTabBarIconColor(tabbedPage, tabbedPage.Children[0].Title, Colors.Red, true); await ValidateTabBarIconColor(tabbedPage, tabbedPage.Children[1].Title, Colors.Purple, true); @@ -151,12 +135,53 @@ await CreateHandlerAndAddToWindow(tabbedPage, async handler = await OnNavigatedToAsync(tabbedPage.CurrentPage); await ValidateTabBarTextColor(tabbedPage, tabbedPage.Children[0].Title, Colors.Purple, true); - await ValidateTabBarTextColor(tabbedPage, tabbedPage.Children[1].Title, Colors.Red, unselectedMatchesTabColor); + await ValidateTabBarTextColor(tabbedPage, tabbedPage.Children[1].Title, Colors.Red, true); await ValidateTabBarIconColor(tabbedPage, tabbedPage.Children[0].Title, Colors.Purple, true); await ValidateTabBarIconColor(tabbedPage, tabbedPage.Children[1].Title, Colors.Red, true); }); } + // Regression test for https://github.com/dotnet/maui/issues/34605 + // On iOS 26.2+, BarTextColor was only applied to the selected tab, not unselected tabs + [Fact(DisplayName = "BarTextColor applies to unselected tabs without UnselectedTabColor set" +#if MACCATALYST + , Skip = "Fails on Mac Catalyst, fixme" +#endif + )] + public async Task BarTextColorAppliesToUnselectedTabsWithoutExplicitUnselectedColor() + { +#if IOS || MACCATALYST + // Pixel-based text color verification doesn't work on iOS 26+ due to UITabBar rendering changes. + // Property-based tests (UnselectedItemTintColorSetFromBarTextColor) verify the fix instead. + if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) + return; +#endif + SetupBuilder(); + var tabbedPage = CreateBasicTabbedPage(true, pages: new[] + { + new ContentPage() { Title = "Tab A", IconImageSource = "white.png" }, + new ContentPage() { Title = "Tab B", IconImageSource = "white.png" }, + new ContentPage() { Title = "Tab C", IconImageSource = "white.png" } + }); + + // Set ONLY BarTextColor (no SelectedTabColor or UnselectedTabColor) + tabbedPage.BarTextColor = Colors.Orange; + + await CreateHandlerAndAddToWindow(tabbedPage, async handler => + { + // All tabs (selected and unselected) should use the BarTextColor + await ValidateTabBarTextColor(tabbedPage, "Tab A", Colors.Orange, true); + await ValidateTabBarTextColor(tabbedPage, "Tab B", Colors.Orange, true); + await ValidateTabBarTextColor(tabbedPage, "Tab C", Colors.Orange, true); + + // Switch selected tab and verify colors still correct + tabbedPage.CurrentPage = tabbedPage.Children[2]; + await OnNavigatedToAsync(tabbedPage.CurrentPage); + await ValidateTabBarTextColor(tabbedPage, "Tab A", Colors.Orange, true); + await ValidateTabBarTextColor(tabbedPage, "Tab C", Colors.Orange, true); + }); + } + #if !IOS && !MACCATALYST // iOS currently can't handle recreating a handler if it's disconnecting // This is left over behavior from Forms and will be fixed by a different PR @@ -378,7 +403,7 @@ await CreateHandlerAndAddToWindow(new Window(tabbedPage), asy } #endif -#if IOS +#if IOS [Theory(Skip = "Test doesn't work on iOS yet; probably because of https://github.com/dotnet/maui/issues/10591")] #elif WINDOWS [Theory(Skip = "Test doesn't work on Windows")] diff --git a/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.iOS.cs index a2e8a519c710..68b93eafa171 100644 --- a/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.iOS.cs @@ -7,6 +7,8 @@ using Microsoft.Maui.Graphics; using Microsoft.Maui.Platform; using UIKit; +using Xunit; +using TabbedViewHandler = Microsoft.Maui.Controls.Handlers.Compatibility.TabbedRenderer; namespace Microsoft.Maui.DeviceTests { @@ -15,10 +17,27 @@ public partial class TabbedPageTests { UITabBar GetTabBar(TabbedPage tabbedPage) { - var pagerParent = (tabbedPage.CurrentPage.Handler as IPlatformViewHandler) - .PlatformView.FindParent(x => x.NextResponder is UITabBarController); + // TabbedRenderer IS a UITabBarController — get TabBar from ViewController + var platformHandler = tabbedPage.Handler as IPlatformViewHandler; + if (platformHandler?.ViewController is UITabBarController tbc) + return tbc.TabBar; - return pagerParent.Subviews.FirstOrDefault(v => v.GetType() == typeof(UITabBar)) as UITabBar; + // Fallback: walk the responder chain + var handler = tabbedPage.CurrentPage.Handler as IPlatformViewHandler; + var platformView = handler?.PlatformView; + + if (platformView is null) + throw new Exception("Unable to get platform view from handler"); + + UIResponder responder = platformView; + while (responder != null) + { + if (responder is UITabBarController controller) + return controller.TabBar; + responder = responder.NextResponder; + } + + throw new Exception("Unable to find UITabBarController in view hierarchy"); } async Task ValidateTabBarIconColor( @@ -60,5 +79,128 @@ await AssertionExtensions.AssertTabItemTextDoesNotContainColor( tabText, iconColor, MauiContext); } } + + // Regression test for https://github.com/dotnet/maui/issues/34605 + // Verifies UnselectedItemTintColor is correctly set on iOS 26+ when BarTextColor is set + [Fact(DisplayName = "iOS 26: UnselectedItemTintColor set when BarTextColor specified")] + public async Task UnselectedItemTintColorSetFromBarTextColor() + { + if (!OperatingSystem.IsIOSVersionAtLeast(26) && !OperatingSystem.IsMacCatalystVersionAtLeast(26)) + return; + + SetupBuilder(); + var tabbedPage = CreateBasicTabbedPage(true, pages: new[] + { + new ContentPage() { Title = "Tab 1" }, + new ContentPage() { Title = "Tab 2" } + }); + + tabbedPage.BarTextColor = Colors.Red; + + await CreateHandlerAndAddToWindow(tabbedPage, handler => + { + var tabBar = GetTabBar(tabbedPage); + + // On iOS 26+, our fix sets UnselectedItemTintColor as a workaround + Assert.NotNull(tabBar.UnselectedItemTintColor); + Assert.True( + ColorComparison.ARGBEquivalent(tabBar.UnselectedItemTintColor, Colors.Red.ToPlatform(), 0.1), + $"Expected UnselectedItemTintColor to be Red but got {tabBar.UnselectedItemTintColor}"); + + // Change the color and verify it updates + tabbedPage.BarTextColor = Colors.Blue; + + Assert.NotNull(tabBar.UnselectedItemTintColor); + Assert.True( + ColorComparison.ARGBEquivalent(tabBar.UnselectedItemTintColor, Colors.Blue.ToPlatform(), 0.1), + $"Expected UnselectedItemTintColor to be Blue but got {tabBar.UnselectedItemTintColor}"); + + return Task.CompletedTask; + }); + } + + // Regression test for https://github.com/dotnet/maui/issues/32125 + // Verifies UnselectedItemTintColor is set from UnselectedTabColor on iOS 26+ + [Fact(DisplayName = "iOS 26: UnselectedItemTintColor set from UnselectedTabColor")] + public async Task UnselectedItemTintColorSetFromUnselectedTabColor() + { + if (!OperatingSystem.IsIOSVersionAtLeast(26) && !OperatingSystem.IsMacCatalystVersionAtLeast(26)) + return; + + SetupBuilder(); + var tabbedPage = CreateBasicTabbedPage(true, pages: new[] + { + new ContentPage() { Title = "Tab 1" }, + new ContentPage() { Title = "Tab 2" } + }); + + tabbedPage.SelectedTabColor = Colors.Green; + tabbedPage.UnselectedTabColor = Colors.Purple; + + await CreateHandlerAndAddToWindow(tabbedPage, handler => + { + var tabBar = GetTabBar(tabbedPage); + + // UnselectedTabColor should take priority for UnselectedItemTintColor + Assert.NotNull(tabBar.UnselectedItemTintColor); + Assert.True( + ColorComparison.ARGBEquivalent(tabBar.UnselectedItemTintColor, Colors.Purple.ToPlatform(), 0.1), + $"Expected UnselectedItemTintColor to be Purple but got {tabBar.UnselectedItemTintColor}"); + + return Task.CompletedTask; + }); + } + + // Verifies changing BarTextColor updates UnselectedItemTintColor on iOS 26+ + [Fact(DisplayName = "iOS 26: Changing BarTextColor updates UnselectedItemTintColor")] + public async Task ChangingBarTextColorUpdatesUnselectedItemTintColor() + { + if (!OperatingSystem.IsIOSVersionAtLeast(26) && !OperatingSystem.IsMacCatalystVersionAtLeast(26)) + return; + + SetupBuilder(); + var tabbedPage = CreateBasicTabbedPage(true, pages: new[] + { + new ContentPage() { Title = "Tab 1" }, + new ContentPage() { Title = "Tab 2" } + }); + + tabbedPage.BarTextColor = Colors.Red; + + await CreateHandlerAndAddToWindow(tabbedPage, handler => + { + var tabBar = GetTabBar(tabbedPage); + + // Should be Red after BarTextColor is applied + Assert.NotNull(tabBar.UnselectedItemTintColor); + Assert.True( + ColorComparison.ARGBEquivalent(tabBar.UnselectedItemTintColor, Colors.Red.ToPlatform(), 0.1), + $"Expected Red but got {tabBar.UnselectedItemTintColor}"); + + // Change to Green + tabbedPage.BarTextColor = Colors.Green; + + Assert.NotNull(tabBar.UnselectedItemTintColor); + Assert.True( + ColorComparison.ARGBEquivalent(tabBar.UnselectedItemTintColor, Colors.Green.ToPlatform(), 0.1), + $"Expected Green but got {tabBar.UnselectedItemTintColor}"); + + // Clear the color — should no longer be Red or Green + tabbedPage.BarTextColor = null; + + // After clearing, the tint should not be Red or Green anymore + if (tabBar.UnselectedItemTintColor is not null) + { + Assert.False( + ColorComparison.ARGBEquivalent(tabBar.UnselectedItemTintColor, Colors.Red.ToPlatform(), 0.1), + "UnselectedItemTintColor should not still be Red after clearing"); + Assert.False( + ColorComparison.ARGBEquivalent(tabBar.UnselectedItemTintColor, Colors.Green.ToPlatform(), 0.1), + "UnselectedItemTintColor should not still be Green after clearing"); + } + + return Task.CompletedTask; + }); + } } } diff --git a/src/Core/src/Platform/iOS/TabbedViewExtensions.cs b/src/Core/src/Platform/iOS/TabbedViewExtensions.cs index 6c5ae4cf06b6..6b62594deef1 100644 --- a/src/Core/src/Platform/iOS/TabbedViewExtensions.cs +++ b/src/Core/src/Platform/iOS/TabbedViewExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Text; using CoreGraphics; using Foundation; @@ -90,37 +91,136 @@ internal static void UpdateiOS15TabBarAppearance( } // Set UnselectedTabColor - if (unselectedTabColor is not null) + // On iOS 26+, UITabBarAppearance.Normal state (TitleTextAttributes, IconColor) is ignored + // by the liquid glass tab bar for unselected items. Skip setting Normal state and use + // UnselectedItemTintColor directly instead. + // See: https://github.com/dotnet/maui/issues/32125, https://github.com/dotnet/maui/issues/34605 + bool isiOS26OrNewer = OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26); + + if (!isiOS26OrNewer) { - var foregroundColor = unselectedTabColor.ToPlatform(); - var titleColor = effectiveUnselectedBarTextColor ?? foregroundColor; - _tabBarAppearance.StackedLayoutAppearance.Normal.TitleTextAttributes = new UIStringAttributes { ForegroundColor = titleColor, ParagraphStyle = NSParagraphStyle.Default }; - _tabBarAppearance.StackedLayoutAppearance.Normal.IconColor = foregroundColor; + if (unselectedTabColor is not null) + { + var foregroundColor = unselectedTabColor.ToPlatform(); + var titleColor = effectiveUnselectedBarTextColor ?? foregroundColor; + _tabBarAppearance.StackedLayoutAppearance.Normal.TitleTextAttributes = new UIStringAttributes { ForegroundColor = titleColor, ParagraphStyle = NSParagraphStyle.Default }; + _tabBarAppearance.StackedLayoutAppearance.Normal.IconColor = foregroundColor; - _tabBarAppearance.InlineLayoutAppearance.Normal.TitleTextAttributes = new UIStringAttributes { ForegroundColor = titleColor, ParagraphStyle = NSParagraphStyle.Default }; - _tabBarAppearance.InlineLayoutAppearance.Normal.IconColor = foregroundColor; + _tabBarAppearance.InlineLayoutAppearance.Normal.TitleTextAttributes = new UIStringAttributes { ForegroundColor = titleColor, ParagraphStyle = NSParagraphStyle.Default }; + _tabBarAppearance.InlineLayoutAppearance.Normal.IconColor = foregroundColor; - _tabBarAppearance.CompactInlineLayoutAppearance.Normal.TitleTextAttributes = new UIStringAttributes { ForegroundColor = titleColor, ParagraphStyle = NSParagraphStyle.Default }; - _tabBarAppearance.CompactInlineLayoutAppearance.Normal.IconColor = foregroundColor; - } - else - { - var foreground = UITabBar.Appearance.TintColor; - var titleColor = effectiveUnselectedBarTextColor ?? foreground; - _tabBarAppearance.StackedLayoutAppearance.Normal.TitleTextAttributes = new UIStringAttributes { ForegroundColor = titleColor, ParagraphStyle = NSParagraphStyle.Default }; - _tabBarAppearance.StackedLayoutAppearance.Normal.IconColor = foreground; + _tabBarAppearance.CompactInlineLayoutAppearance.Normal.TitleTextAttributes = new UIStringAttributes { ForegroundColor = titleColor, ParagraphStyle = NSParagraphStyle.Default }; + _tabBarAppearance.CompactInlineLayoutAppearance.Normal.IconColor = foregroundColor; + } + else + { + var foreground = UITabBar.Appearance.TintColor; + var titleColor = effectiveUnselectedBarTextColor ?? foreground; + _tabBarAppearance.StackedLayoutAppearance.Normal.TitleTextAttributes = new UIStringAttributes { ForegroundColor = titleColor, ParagraphStyle = NSParagraphStyle.Default }; + _tabBarAppearance.StackedLayoutAppearance.Normal.IconColor = foreground; - _tabBarAppearance.InlineLayoutAppearance.Normal.TitleTextAttributes = new UIStringAttributes { ForegroundColor = titleColor, ParagraphStyle = NSParagraphStyle.Default }; - _tabBarAppearance.InlineLayoutAppearance.Normal.IconColor = foreground; + _tabBarAppearance.InlineLayoutAppearance.Normal.TitleTextAttributes = new UIStringAttributes { ForegroundColor = titleColor, ParagraphStyle = NSParagraphStyle.Default }; + _tabBarAppearance.InlineLayoutAppearance.Normal.IconColor = foreground; - _tabBarAppearance.CompactInlineLayoutAppearance.Normal.TitleTextAttributes = new UIStringAttributes { ForegroundColor = titleColor, ParagraphStyle = NSParagraphStyle.Default }; - _tabBarAppearance.CompactInlineLayoutAppearance.Normal.IconColor = foreground; + _tabBarAppearance.CompactInlineLayoutAppearance.Normal.TitleTextAttributes = new UIStringAttributes { ForegroundColor = titleColor, ParagraphStyle = NSParagraphStyle.Default }; + _tabBarAppearance.CompactInlineLayoutAppearance.Normal.IconColor = foreground; + } } // Set the TabBarAppearance + // On iOS 26+, setting StandardAppearance may cause UIKit to ignore + // UnselectedItemTintColor. Only set appearance for background color and + // selected state; use direct properties for unselected state. tabBar.StandardAppearance = tabBar.ScrollEdgeAppearance = _tabBarAppearance; + + if (isiOS26OrNewer) + { + UIColor? effectiveUnselectedTint = null; + + if (unselectedTabColor is not null) + effectiveUnselectedTint = unselectedTabColor.ToPlatform(); + else if (effectiveUnselectedBarTextColor is not null) + effectiveUnselectedTint = effectiveUnselectedBarTextColor; + + tabBar.UnselectedItemTintColor = effectiveUnselectedTint; + + // Also ensure TintColor is set for selected items + var effectiveSelectedTint = selectedTabColor?.ToPlatform() ?? effectiveSelectedBarTextColor; + if (effectiveSelectedTint is not null) + tabBar.TintColor = effectiveSelectedTint; + + // iOS 26 liquid glass strips tint colors during compositing. + // Use pre-colored images with AlwaysOriginal to bypass the tint pipeline. + tabBar.ApplyPreColoredImagesForIOS26(effectiveUnselectedTint, effectiveSelectedTint); + } } + /// + /// On iOS 26+, the liquid glass tab bar's compositing pipeline strips TintColor from + /// unselected tab icons and labels. This method bypasses the tint system by creating + /// pre-colored copies of tab icons using AlwaysOriginal rendering mode, which bakes the + /// color into the image pixel data. Also sets per-item title text attributes. + /// Must be called on every layout pass because UIKit may reset these during layout. + /// See: https://github.com/dotnet/maui/issues/32125, https://github.com/dotnet/maui/issues/34605 + /// + [System.Runtime.Versioning.SupportedOSPlatform("ios26.0")] + [System.Runtime.Versioning.SupportedOSPlatform("maccatalyst26.0")] + internal static void ApplyPreColoredImagesForIOS26(this UITabBar tabBar, UIColor? unselectedColor, UIColor? selectedColor) + { + if (tabBar.Items is null) + return; + + foreach (var item in tabBar.Items) + { + if (item.Image is UIImage img && unselectedColor is not null) + { + // Retrieve or store the original template image so we always tint + // from a clean alpha mask, avoiding quality degradation from + // repeated AlwaysOriginal→Template round-trips. + if (!s_originalTemplateImages.TryGetValue(item, out var template)) + { + template = img.RenderingMode == UIImageRenderingMode.AlwaysOriginal + ? img.ImageWithRenderingMode(UIImageRenderingMode.AlwaysTemplate) + : img; + s_originalTemplateImages.AddOrUpdate(item, template); + } + + // Only re-tint if UIKit has reset the image (rendering mode won't + // be AlwaysOriginal) or if this is the first application. This avoids + // creating new UIImage instances on every layout pass. + if (img.RenderingMode != UIImageRenderingMode.AlwaysOriginal) + { + item.Image = template.ApplyTintColor(unselectedColor) + ?.ImageWithRenderingMode(UIImageRenderingMode.AlwaysOriginal); + + if (selectedColor is not null) + { + item.SelectedImage = template.ApplyTintColor(selectedColor) + ?.ImageWithRenderingMode(UIImageRenderingMode.AlwaysOriginal); + } + } + } + + // Note: SetTitleTextAttributes for Normal state is intentionally not called on iOS 26+. + // Apple's Liquid Glass design system ignores the Normal state appearance to ensure visual + // consistency with the glass material. Icon coloring via AlwaysOriginal rendering (above) + // is the supported workaround. Text color customization for unselected tabs is not available + // on iOS 26+. See: https://github.com/dotnet/maui/issues/32125 + + if (selectedColor is not null) + { + item.SetTitleTextAttributes( + new UIStringAttributes { ForegroundColor = selectedColor, ParagraphStyle = NSParagraphStyle.Default }, + UIControlState.Selected); + } + } + } + + // Stores original template images per tab bar item so we always tint from + // a clean alpha mask. Uses ConditionalWeakTable so entries are collected + // when the UITabBarItem is garbage-collected. + static readonly ConditionalWeakTable s_originalTemplateImages = new(); + internal static UIImage? AutoResizeTabBarImage(UITraitCollection traitCollection, UIImage image) { if (image == null || image.Size.Width <= 0 || image.Size.Height <= 0) @@ -152,8 +252,15 @@ internal static void UpdateiOS15TabBarAppearance( newSize.Width = isRegularTabBar ? regularSquareSize : compactSquareSize; newSize.Height = newSize.Width; } - - return image.ResizeImageSource(newSize.Width, newSize.Height, new CGSize(image.Size.Width, image.Size.Height)); + + var resizedImage = image.ResizeImageSource(newSize.Width, newSize.Height, new CGSize(image.Size.Width, image.Size.Height)); + + if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) + { + return resizedImage?.ImageWithRenderingMode(UIImageRenderingMode.AlwaysTemplate); + } + + return resizedImage; } } }