From 449408263777a140d35123e0f417120a6f27c908 Mon Sep 17 00:00:00 2001 From: devanathan-vaithiyanathan <114395405+devanathan-vaithiyanathan@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:56:18 +0530 Subject: [PATCH 1/5] fix added --- .../Android/ShellFragmentStateAdapter.cs | 20 +++++ .../Shell/Android/ShellSectionRenderer.cs | 10 +++ .../Shell/iOS/ShellSectionRootRenderer.cs | 79 +++++++++++++++++++ .../Shell/ShellSectionHandler.Windows.cs | 32 +++++++- 4 files changed, 140 insertions(+), 1 deletion(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs index a11fd787ee55..9445ef6df23e 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs @@ -44,6 +44,26 @@ public void OnItemsCollectionChanged(object sender, NotifyCollectionChangedEvent _createdShellContent.Remove(remove); } + /// + /// Invalidates the cached item ID for the given so that + /// existing fragment and recreate it with the updated page content. + /// + public void InvalidateShellContent(ShellContent shellContent) + { + long removeKey = -1; + foreach (var item in _createdShellContent) + { + if (item.Value == shellContent) + { + removeKey = item.Key; + break; + } + } + + if (removeKey >= 0) + _createdShellContent.Remove(removeKey); + } + public int CountOverride { get; set; } public override int ItemCount => _items.Count; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs index a3f4513ccaf2..5cef963b241b 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs @@ -161,6 +161,16 @@ void OnShellContentPropertyChanged(object sender, System.ComponentModel.Property { UpdateTabTitle(shellContent); } + else if (e.PropertyName == ShellContent.ContentProperty.PropertyName && sender is ShellContent changedContent) + { + // The page inside this ShellContent changed — force ViewPager2 to recreate the + // fragment so it picks up the new content. + if (_viewPager?.Adapter is ShellFragmentStateAdapter adapter) + { + adapter.InvalidateShellContent(changedContent); + SafeNotifyDataSetChanged(); + } + } } void UpdateTabTitle(ShellContent shellContent) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs index 6d880ec642a6..234ddc69c4d6 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs @@ -101,6 +101,9 @@ public override void ViewDidLoad() ShellSection.PropertyChanged += OnShellSectionPropertyChanged; ShellSectionController.ItemsCollectionChanged += OnShellSectionItemsChanged; + foreach (var item in ShellSectionController.GetItems()) + item.PropertyChanged += OnShellContentPropertyChanged; + _blurView = new UIView(); UIVisualEffect blurEffect = UIBlurEffect.FromStyle(UIBlurEffectStyle.ExtraLight); _blurView = new UIVisualEffectView(blurEffect); @@ -151,6 +154,11 @@ void IDisconnectable.Disconnect() if (ShellSectionController != null) ShellSectionController.ItemsCollectionChanged -= OnShellSectionItemsChanged; + var shellContents = ShellSectionController?.GetItems(); + if (shellContents != null) + foreach (var item in shellContents) + item.PropertyChanged -= OnShellContentPropertyChanged; + if (_shellContext?.Shell != null) _shellContext.Shell.PropertyChanged -= HandleShellPropertyChanged; @@ -500,6 +508,8 @@ void OnShellSectionItemsChanged(object sender, NotifyCollectionChangedEventArgs { foreach (ShellContent oldItem in e.OldItems) { + oldItem.PropertyChanged -= OnShellContentPropertyChanged; + // if current item is removed will be handled by the currentitem property changed event // That way the render is swapped out cleanly once the new current item is set if (_currentContent == oldItem) @@ -524,6 +534,8 @@ void OnShellSectionItemsChanged(object sender, NotifyCollectionChangedEventArgs { foreach (ShellContent newItem in e.NewItems) { + newItem.PropertyChanged += OnShellContentPropertyChanged; + if (_renderers.ContainsKey(newItem)) continue; @@ -535,6 +547,73 @@ void OnShellSectionItemsChanged(object sender, NotifyCollectionChangedEventArgs } } + void OnShellContentPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (_isDisposed) + return; + + if (e.PropertyName == ShellContent.ContentProperty.PropertyName && sender is ShellContent shellContent) + { + // PropertyChanged fires BEFORE the BindableProperty's propertyChanged callback + // (OnContentChanged), so ContentCache may not be updated yet. + if (shellContent.Content is Page newPage) + { + // Content is a Page — available immediately, no deferral needed. + UpdateRendererForShellContent(shellContent, newPage); + } + else if (shellContent.Content is DataTemplate) + { + // Content is a DataTemplate — ContentCache is populated by OnContentChanged, + // which runs after PropertyChanged completes. Defer to the next run-loop + // iteration so GetOrCreateContent() sees the updated ContentCache. + BeginInvokeOnMainThread(() => + { + if (_isDisposed) + return; + var page = ((IShellContentController)shellContent).GetOrCreateContent(); + UpdateRendererForShellContent(shellContent, page); + }); + } + } + } + + void UpdateRendererForShellContent(ShellContent shellContent, Page newPage) + { + if (newPage == null) + return; + + if (!_renderers.TryGetValue(shellContent, out var oldRenderer)) + return; + + // If the existing renderer is already showing the new page, nothing to do. + if (oldRenderer.VirtualView == newPage) + return; + + bool isCurrentContent = shellContent == _currentContent; + + // Remove the old renderer + if (isCurrentContent) + oldRenderer.ViewController?.ViewIfLoaded?.RemoveFromSuperview(); + + oldRenderer.ViewController?.RemoveFromParentViewController(); + oldRenderer.DisconnectHandler(); + _renderers.Remove(shellContent); + + // Create a new renderer for the updated page + var renderer = SetPageRenderer(newPage, shellContent); + AddChildViewController(renderer.ViewController); + + if (isCurrentContent) + { + _containerArea.AddSubview(renderer.ViewController.View); + renderer.ViewController.View.Frame = _containerArea.Bounds; + UpdateAdditionalSafeAreaInsets(renderer); + + if (_tracker != null) + _tracker.Page = newPage; + } + } + IPlatformViewHandler SetPageRenderer(Page page, ShellContent shellContent) { page.Handler?.DisconnectHandler(); diff --git a/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs b/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs index 5c930f718b76..906a7ee0c691 100644 --- a/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs +++ b/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs @@ -53,6 +53,7 @@ public override void SetVirtualView(Maui.IElement view) { if (_shellSection != null) { + ((IShellSectionController)_shellSection).RemoveDisplayedPageObserver(this); ((IShellSectionController)_shellSection).NavigationRequested -= OnNavigationRequested; ((IShellSectionController)_shellSection).ItemsCollectionChanged -= OnItemsCollectionChanged; @@ -84,7 +85,6 @@ public override void SetVirtualView(Maui.IElement view) if (_shellSection != null) { ((IShellSectionController)_shellSection).NavigationRequested += OnNavigationRequested; - ((IShellSectionController)_shellSection).ItemsCollectionChanged += OnItemsCollectionChanged; var shell = _shellSection.FindParentOfType() as IShellController; @@ -93,9 +93,36 @@ public override void SetVirtualView(Maui.IElement view) _lastShell = new WeakReference(shell); shell.AddAppearanceObserver(this, _shellSection); } + + // AddDisplayedPageObserver immediately invokes the callback with the current page, + // but at that point PendingNavigationTask is already set from MapCurrentItem + // (which runs via base.SetVirtualView above), so it is safely skipped. + ((IShellSectionController)_shellSection).AddDisplayedPageObserver(this, OnDisplayedPageChanged); } } + // Called when ShellSection.DisplayedPage changes — covers both content changes and + // navigation pushes. We only act on content changes (stack depth == 1, no pending nav). + void OnDisplayedPageChanged(Page? page) + { + if (page is null || VirtualView is null) + return; + + // Push/pop navigation is handled by OnNavigationRequested; skip those cases. + if (VirtualView.Stack.Count > 1) + return; + + // Tab switches are handled by MapCurrentItem which sets PendingNavigationTask first + // (because the property mapper fires before the propertyChanged callback that calls + // UpdateDisplayedPage). Skip when another navigation is already in flight. + if (VirtualView.PendingNavigationTask != null) + return; + + // ContentCache has been updated by OnContentChanged at this point, so + // SyncNavigationStack will correctly pick up the new page via GetOrCreateContent(). + SyncNavigationStack(false, null); + } + void OnNavigationRequested(object? sender, NavigationRequestedEventArgs e) { SyncNavigationStack(e.Animated, e); @@ -159,6 +186,9 @@ protected override void ConnectHandler(WFrame platformView) protected override void DisconnectHandler(WFrame platformView) { + if (_shellSection != null) + ((IShellSectionController)_shellSection).RemoveDisplayedPageObserver(this); + _navigationManager?.Disconnect(VirtualView, platformView); base.DisconnectHandler(platformView); } From fa52f4bf20e21bf086c2bea406e4baee38178cce Mon Sep 17 00:00:00 2001 From: devanathan-vaithiyanathan <114395405+devanathan-vaithiyanathan@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:21:24 +0530 Subject: [PATCH 2/5] test case added --- .../Android/ShellFragmentStateAdapter.cs | 2 +- .../TestCases.HostApp/Issues/Issue12669.cs | 61 +++++++++++++++++++ .../Tests/Issues/Issue12669.cs | 27 ++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/Controls/tests/TestCases.HostApp/Issues/Issue12669.cs create mode 100644 src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue12669.cs diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs index 9445ef6df23e..44af4390c987 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs @@ -48,7 +48,7 @@ public void OnItemsCollectionChanged(object sender, NotifyCollectionChangedEvent /// Invalidates the cached item ID for the given so that /// existing fragment and recreate it with the updated page content. /// - public void InvalidateShellContent(ShellContent shellContent) + internal void InvalidateShellContent(ShellContent shellContent) { long removeKey = -1; foreach (var item in _createdShellContent) diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue12669.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue12669.cs new file mode 100644 index 000000000000..fd46de83e64f --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue12669.cs @@ -0,0 +1,61 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 12669, "Changing Content property of ShellContent doesn't change visual content", PlatformAffected.All)] +public class Issue12669 : TestShell +{ + protected override void Init() + { + FlyoutBehavior = FlyoutBehavior.Disabled; + + var shellContent = new ShellContent { Title = "Test" }; + + var changeButton = new Button + { + Text = "Change Content", + AutomationId = "ChangeContentButton" + }; + + shellContent.Content = new ContentPage + { + Content = new VerticalStackLayout + { + VerticalOptions = LayoutOptions.Center, + HorizontalOptions = LayoutOptions.Center, + Children = + { + new Label + { + Text = "Original Content", + AutomationId = "OriginalContent" + }, + changeButton + } + } + }; + + changeButton.Clicked += (s, e) => + { + shellContent.ContentTemplate = null; + shellContent.Content = new ContentPage + { + Content = new VerticalStackLayout + { + VerticalOptions = LayoutOptions.Center, + HorizontalOptions = LayoutOptions.Center, + Children = + { + new Label + { + Text = "Content Changed", + AutomationId = "NewContent" + } + } + } + }; + }; + + var tab = new Tab { Title = "Main" }; + tab.Items.Add(shellContent); + Items.Add(tab); + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue12669.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue12669.cs new file mode 100644 index 000000000000..24a13f0e7b2a --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue12669.cs @@ -0,0 +1,27 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; +public class Issue12669 : _IssuesUITest +{ + public Issue12669(TestDevice device) : base(device) { } + + public override string Issue => "Changing Content property of ShellContent doesn't change visual content"; + + [Test] + [Category(UITestCategories.Shell)] + public void ShellContentShouldUpdateWhenContentPropertyChanges() + { + App.WaitForElement("OriginalContent"); + App.WaitForElement("ChangeContentButton"); + + App.Tap("ChangeContentButton"); + + // After clicking, the ShellContent.Content is changed to a new page. + // The visual should update to show the new content. + App.WaitForElement("NewContent"); + var labelText = App.FindElement("NewContent").GetText(); + Assert.That(labelText, Is.EqualTo("Content Changed")); + } +} From 41267adc08e908d7e0de8f307e28721ce854a170 Mon Sep 17 00:00:00 2001 From: devanathan-vaithiyanathan <114395405+devanathan-vaithiyanathan@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:36:34 +0530 Subject: [PATCH 3/5] comment updated --- .../Handlers/Shell/iOS/ShellSectionRootRenderer.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs index 234ddc69c4d6..ae0d5bd0cec9 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs @@ -554,8 +554,9 @@ void OnShellContentPropertyChanged(object sender, System.ComponentModel.Property if (e.PropertyName == ShellContent.ContentProperty.PropertyName && sender is ShellContent shellContent) { - // PropertyChanged fires BEFORE the BindableProperty's propertyChanged callback - // (OnContentChanged), so ContentCache may not be updated yet. + // INotifyPropertyChanged.PropertyChanged fires AFTER the BindableProperty's + // propertyChanged callback (OnContentChanged) in BindableObject.SetValueCore. + // For a Page, ContentCache is already updated by the time we get here. if (shellContent.Content is Page newPage) { // Content is a Page — available immediately, no deferral needed. @@ -564,8 +565,9 @@ void OnShellContentPropertyChanged(object sender, System.ComponentModel.Property else if (shellContent.Content is DataTemplate) { // Content is a DataTemplate — ContentCache is populated by OnContentChanged, - // which runs after PropertyChanged completes. Defer to the next run-loop - // iteration so GetOrCreateContent() sees the updated ContentCache. + // which has already run before this PropertyChanged notification. However, + // defer to the next run-loop iteration to ensure any async post-processing + // in GetOrCreateContent() sees a fully settled ContentCache. BeginInvokeOnMainThread(() => { if (_isDisposed) From 8b7624ec83a04978b1e65042a716e695f9f4fbb5 Mon Sep 17 00:00:00 2001 From: devanathan-vaithiyanathan <114395405+devanathan-vaithiyanathan@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:46:25 +0530 Subject: [PATCH 4/5] AI suggestion addressed --- .../Handlers/Shell/Android/ShellFragmentStateAdapter.cs | 5 +++-- .../src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs index 44af4390c987..f371e69d45be 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs @@ -45,8 +45,9 @@ public void OnItemsCollectionChanged(object sender, NotifyCollectionChangedEvent } /// - /// Invalidates the cached item ID for the given so that - /// existing fragment and recreate it with the updated page content. + /// Invalidates the cached item ID for the given . + /// This causes the existing fragment for that item to be destroyed and a new fragment + /// to be created with the updated page content. /// internal void InvalidateShellContent(ShellContent shellContent) { diff --git a/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs b/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs index 906a7ee0c691..c215ea12c12a 100644 --- a/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs +++ b/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs @@ -103,9 +103,9 @@ public override void SetVirtualView(Maui.IElement view) // Called when ShellSection.DisplayedPage changes — covers both content changes and // navigation pushes. We only act on content changes (stack depth == 1, no pending nav). - void OnDisplayedPageChanged(Page? page) + void OnDisplayedPageChanged(Page page) { - if (page is null || VirtualView is null) + if (VirtualView is null) return; // Push/pop navigation is handled by OnNavigationRequested; skip those cases. From 66f06f4fe15e61c746685bbf009c2d4f853e9314 Mon Sep 17 00:00:00 2001 From: devanathan-vaithiyanathan <114395405+devanathan-vaithiyanathan@users.noreply.github.com> Date: Mon, 25 May 2026 15:42:28 +0530 Subject: [PATCH 5/5] AI summary added --- .../Handlers/Shell/Android/ShellSectionRenderer.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs index 5cef963b241b..c2baa50807c0 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs @@ -169,6 +169,16 @@ void OnShellContentPropertyChanged(object sender, System.ComponentModel.Property { adapter.InvalidateShellContent(changedContent); SafeNotifyDataSetChanged(); + + // Keep toolbar state in sync when the active tab's content page is replaced. + if (ShellSection?.CurrentItem == changedContent && _toolbarTracker is not null) + { + var page = ((IShellContentController)changedContent).GetOrCreateContent(); + if (page is not null) + { + _toolbarTracker.Page = page; + } + } } } }