diff --git a/src/Controls/src/Core/Shell/Shell.cs b/src/Controls/src/Core/Shell/Shell.cs index 4037cc967402..31e3b16f6b0d 100644 --- a/src/Controls/src/Core/Shell/Shell.cs +++ b/src/Controls/src/Core/Shell/Shell.cs @@ -397,6 +397,27 @@ internal static BackButtonBehavior GetEffectiveBackButtonBehavior(BindableObject /// The View to be displayed in the navigation bar. public static void SetTitleView(BindableObject obj, View value) => obj.SetValue(TitleViewProperty, value); + // Determines whether the Shell's Title was set by the user (explicit code, style, or a binding) + // as opposed to being mirrored from the current page by the renderer (FromHandler) or never set + // at all (DefaultValue). This lets ShellToolbar mirror the page title into Shell.Title for + // TitleView bindings without clobbering a title the user set intentionally. + internal bool IsTitleSetByUser() + { + if (GetIsBound(TitleProperty)) + return true; + + var context = GetContext(TitleProperty); + if (context is null) + return false; + + var specificity = context.Values.GetSpecificity(); + return specificity != SetterSpecificity.DefaultValue && specificity != SetterSpecificity.FromHandler; + } + + // Returns the Title only when it was set by the user. Used by the native window title + // fallback so that a renderer-mirrored page title never leaks into the platform chrome. + internal string GetUserSetTitle() => IsTitleSetByUser() ? Title : null; + static void OnFlyoutBehaviorChanged(BindableObject bindable, object oldValue, object newValue) { var element = (Element)bindable; diff --git a/src/Controls/src/Core/ShellToolbar.cs b/src/Controls/src/Core/ShellToolbar.cs index d6c324268168..3c76c6f856eb 100644 --- a/src/Controls/src/Core/ShellToolbar.cs +++ b/src/Controls/src/Core/ShellToolbar.cs @@ -170,16 +170,18 @@ internal void UpdateTitle() Shell.GetTitleView(_shell)); var title = GetCurrentTitle(); - if (!IsShellTitleSetByUser()) - _shell.SetValueFromRenderer(Shell.TitleProperty, title); - if (TitleView != null) - { - Title = String.Empty; - return; - } - - Title = title; + // Mirror the current page's title into Shell.Title so that bindings inside a custom + // TitleView (e.g. {Binding Title, Source={x:Reference shell}}) can resolve to it. + // Use SetValueFromRenderer (FromHandler specificity) so the value is recognized as + // renderer-generated and does not leak into the native window title (see Window.cs), + // and only when the user hasn't explicitly set Shell.Title themselves. + if (!_shell.IsTitleSetByUser()) + _shell.SetValueFromRenderer(Page.TitleProperty, title); + + // The native nav-bar title must be empty when a custom TitleView is present, + // otherwise it should reflect the current page title. + Title = TitleView != null ? String.Empty : title; } string GetCurrentTitle() @@ -198,18 +200,5 @@ string GetCurrentTitle() return String.Empty; } - - bool IsShellTitleSetByUser() - { - var titleContext = _shell.GetContext(Shell.TitleProperty); - if (titleContext == null) - return false; - - if (titleContext.Bindings.Count > 0) - return true; - - var specificity = titleContext.Values.GetSpecificity(); - return specificity != SetterSpecificity.DefaultValue && specificity != SetterSpecificity.FromHandler; - } } } diff --git a/src/Controls/src/Core/Window/Window.cs b/src/Controls/src/Core/Window/Window.cs index e961caffd4a2..55d90e5025a4 100644 --- a/src/Controls/src/Core/Window/Window.cs +++ b/src/Controls/src/Core/Window/Window.cs @@ -131,7 +131,7 @@ public bool IsActivated private set => SetValue(IsActivatedPropertyKey, value); } - string? ITitledElement.Title => Title ?? (Page as Shell)?.Title; + string? ITitledElement.Title => Title ?? (Page as Shell)?.GetUserSetTitle(); public Page? Page { diff --git a/src/Controls/tests/Core.UnitTests/ShellToolbarTests.cs b/src/Controls/tests/Core.UnitTests/ShellToolbarTests.cs index ef4fa4829bcf..7c5913aa0154 100644 --- a/src/Controls/tests/Core.UnitTests/ShellToolbarTests.cs +++ b/src/Controls/tests/Core.UnitTests/ShellToolbarTests.cs @@ -302,6 +302,37 @@ public void ShellTitleReflectsCurrentPageTitleForTitleViewBindings() Assert.Equal("Updated Test Title", label.Text); } + // Regression test for https://github.com/dotnet/maui/issues/36562 + // PR #35800 made ShellToolbar mirror the current page's title into Shell.Title (via + // SetValueFromRenderer) so TitleView bindings like {Binding Title, Source={x:Reference Shell}} + // resolve correctly. That mirrored value leaked into the native window title + // (ITitledElement.Title), which previously stayed empty in this scenario, breaking Shell UI + // tests around title/flyout layout on macOS/Windows. + [Fact] + public void WindowNativeTitleDoesNotLeakToolbarMirroredPageTitle() + { + var contentPage = new ContentPage() { Title = "Test Title" }; + var titleView = new VerticalStackLayout(); + + TestShell testShell = new TestShell(contentPage); + var window = new Window() + { + Page = testShell + }; + + Shell.SetTitleView(contentPage, titleView); + + // Shell.Title is mirrored from the page for TitleView binding purposes... + Assert.Equal("Test Title", testShell.Title); + + // ...but that mirrored value must NOT leak into the native window title fallback. + Assert.Null(((ITitledElement)window).Title); + + contentPage.Title = "Updated Test Title"; + Assert.Equal("Updated Test Title", testShell.Title); + Assert.Null(((ITitledElement)window).Title); + } + [Fact] public void ShellTitleBindingIsNotOverwrittenByCurrentPageTitle() {