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..f371e69d45be 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,27 @@ public void OnItemsCollectionChanged(object sender, NotifyCollectionChangedEvent _createdShellContent.Remove(remove); } + /// + /// 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) + { + 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..c2baa50807c0 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,26 @@ 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(); + + // 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; + } + } + } + } } 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..ae0d5bd0cec9 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,75 @@ 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) + { + // 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. + UpdateRendererForShellContent(shellContent, newPage); + } + else if (shellContent.Content is DataTemplate) + { + // Content is a DataTemplate — ContentCache is populated by OnContentChanged, + // 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) + 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..c215ea12c12a 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 (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); } 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")); + } +}