diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs index 202234f0c388..9a2c22d36fca 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs @@ -159,7 +159,7 @@ protected SearchHandler SearchHandler void AView.IOnClickListener.OnClick(AView v) { - var backButtonHandler = Shell.GetBackButtonBehavior(Page); + var backButtonHandler = Shell.GetEffectiveBackButtonBehavior(Page); var isEnabled = backButtonHandler.GetPropertyIfSet(BackButtonBehavior.IsEnabledProperty, true); if (isEnabled) @@ -255,7 +255,7 @@ protected virtual void OnPageChanged(Page oldPage, Page newPage) if (newPage != null) { newPage.PropertyChanged += OnPagePropertyChanged; - _backButtonBehavior = Shell.GetBackButtonBehavior(newPage); + _backButtonBehavior = Shell.GetEffectiveBackButtonBehavior(newPage); if (_backButtonBehavior != null) _backButtonBehavior.PropertyChanged += OnBackButtonBehaviorChanged; @@ -309,7 +309,7 @@ protected virtual void OnPagePropertyChanged(object sender, PropertyChangedEvent UpdateNavBarHasShadow(Page); else if (e.PropertyName == Shell.BackButtonBehaviorProperty.PropertyName) { - var backButtonHandler = Shell.GetBackButtonBehavior(Page); + var backButtonHandler = Shell.GetEffectiveBackButtonBehavior(Page); if (_backButtonBehavior != null) _backButtonBehavior.PropertyChanged -= OnBackButtonBehaviorChanged; @@ -407,7 +407,7 @@ protected virtual async void UpdateLeftBarButtonItem(Context context, AToolbar t drawerLayout.AddDrawerListener(_drawerToggle); } - var backButtonHandler = Shell.GetBackButtonBehavior(page); + var backButtonHandler = Shell.GetEffectiveBackButtonBehavior(page); var text = backButtonHandler.GetPropertyIfSet(BackButtonBehavior.TextOverrideProperty, String.Empty); var command = backButtonHandler.GetPropertyIfSet(BackButtonBehavior.CommandProperty, null); var backButtonVisibleFromBehavior = backButtonHandler.GetPropertyIfSet(BackButtonBehavior.IsVisibleProperty, true); @@ -540,7 +540,7 @@ protected virtual Task UpdateDrawerArrow(Context context, AToolbar toolbar, Draw protected virtual void UpdateToolbarIconAccessibilityText(AToolbar toolbar, Shell shell) { - var backButtonHandler = Shell.GetBackButtonBehavior(Page); + var backButtonHandler = Shell.GetEffectiveBackButtonBehavior(Page); var image = GetFlyoutIcon(backButtonHandler, Page); var text = backButtonHandler.GetPropertyIfSet(BackButtonBehavior.TextOverrideProperty, String.Empty); var automationId = image?.AutomationId ?? text; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs index c18fdc433f46..8826058cee0f 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs @@ -133,7 +133,7 @@ protected virtual void OnPagePropertyChanged(object sender, PropertyChangedEvent #nullable restore if (e.PropertyName == Shell.BackButtonBehaviorProperty.PropertyName) { - SetBackButtonBehavior(Shell.GetBackButtonBehavior(Page)); + SetBackButtonBehavior(Shell.GetEffectiveBackButtonBehavior(Page)); } else if (e.PropertyName == Shell.SearchHandlerProperty.PropertyName) { @@ -217,7 +217,7 @@ void UpdateShellToMyPage() return; } - SetBackButtonBehavior(Shell.GetBackButtonBehavior(Page)); + SetBackButtonBehavior(Shell.GetEffectiveBackButtonBehavior(Page)); SearchHandler = Shell.GetSearchHandler(Page); UpdateTitleView(); UpdateTitle(); diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs index 335b5853a665..aabf4cc14bc7 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs @@ -129,7 +129,7 @@ internal bool SendPop() { if (tracker.Value.ViewController == TopViewController) { - var behavior = Shell.GetBackButtonBehavior(tracker.Value.Page); + var behavior = Shell.GetEffectiveBackButtonBehavior(tracker.Value.Page); var command = behavior.GetPropertyIfSet(BackButtonBehavior.CommandProperty, null); var commandParameter = behavior.GetPropertyIfSet(BackButtonBehavior.CommandParameterProperty, null); diff --git a/src/Controls/src/Core/Internals/PropertyPropagationExtensions.cs b/src/Controls/src/Core/Internals/PropertyPropagationExtensions.cs index 13aa142ec9d3..0b0abec206e8 100644 --- a/src/Controls/src/Core/Internals/PropertyPropagationExtensions.cs +++ b/src/Controls/src/Core/Internals/PropertyPropagationExtensions.cs @@ -40,9 +40,6 @@ internal static void PropagatePropertyChanged(string propertyName, Element eleme if (propertyName == null || propertyName == Shell.NavBarVisibilityAnimationEnabledProperty.PropertyName) BaseShellItem.PropagateFromParent(Shell.NavBarVisibilityAnimationEnabledProperty, element); - - if (propertyName == null || propertyName == Shell.BackButtonBehaviorProperty.PropertyName) - BaseShellItem.PropagateFromParent(Shell.BackButtonBehaviorProperty, element); foreach (var child in children.ToArray()) { diff --git a/src/Controls/src/Core/Shell/Shell.cs b/src/Controls/src/Core/Shell/Shell.cs index 8be8a6fcacd9..36d5e2610d9e 100644 --- a/src/Controls/src/Core/Shell/Shell.cs +++ b/src/Controls/src/Core/Shell/Shell.cs @@ -202,6 +202,34 @@ static void OnFlyoutItemIsVisibleChanged(BindableObject bindable, object oldValu /// The back button behavior for the object. public static BackButtonBehavior GetBackButtonBehavior(BindableObject obj) => (BackButtonBehavior)obj.GetValue(BackButtonBehaviorProperty); + /// + /// Gets the BackButtonBehavior for the given page, with fallback to Shell if not set on the page. + /// + internal static BackButtonBehavior GetEffectiveBackButtonBehavior(BindableObject page) + { + if (page == null) + return null; + + // First check if the page has its own BackButtonBehavior + var behavior = GetBackButtonBehavior(page); + if (behavior != null) + return behavior; + + // Fallback: check if the Shell itself has a BackButtonBehavior + if (page is Element element) + { + var shell = element.FindParentOfType(); + if (shell != null) + { + behavior = GetBackButtonBehavior(shell); + if (behavior != null) + return behavior; + } + } + + return null; + } + /// /// Sets the back button behavior when the given is presented. /// @@ -1556,7 +1584,7 @@ internal void SendStructureChanged() protected override bool OnBackButtonPressed() { #if WINDOWS || !PLATFORM - var backButtonBehavior = GetBackButtonBehavior(GetVisiblePage()); + var backButtonBehavior = GetEffectiveBackButtonBehavior(GetVisiblePage()); if (backButtonBehavior != null) { var command = backButtonBehavior.GetPropertyIfSet(BackButtonBehavior.CommandProperty, null); diff --git a/src/Controls/src/Core/ShellToolbar.cs b/src/Controls/src/Core/ShellToolbar.cs index beb4e6374a62..d74cbf82a16d 100644 --- a/src/Controls/src/Core/ShellToolbar.cs +++ b/src/Controls/src/Core/ShellToolbar.cs @@ -131,7 +131,7 @@ internal void ApplyChanges() void UpdateBackbuttonBehavior() { - var bbb = Shell.GetBackButtonBehavior(_currentPage); + var bbb = Shell.GetEffectiveBackButtonBehavior(_currentPage); if (bbb == _backButtonBehavior) return; diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue33688.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue33688.cs new file mode 100644 index 000000000000..730a5e5d0ddc --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue33688.cs @@ -0,0 +1,174 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows.Input; + +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 33688, "BackButtonBehavior is no longer triggered once a ContentPage contains a CollectionView and the ItemsSource has been changed", PlatformAffected.iOS | PlatformAffected.Android)] +public class Issue33688 : Shell +{ + public Issue33688() + { + var mainContent = new ShellContent + { + ContentTemplate = new DataTemplate(() => new Issue33688MainPage()), + Title = "Main", + Route = "main" + }; + + Items.Add(mainContent); + Routing.RegisterRoute("Issue33688_second", typeof(Issue33688SecondPage)); + } +} + +file class Issue33688MainPage : ContentPage +{ + public Issue33688MainPage() + { + Padding = 24; + + var resultLabel = new Label + { + Text = "Waiting for back button...", + AutomationId = "ResultLabel" + }; + + // Store reference so ViewModel can update it + Issue33688ViewModel.SetResultLabelRef(resultLabel); + + Content = new VerticalStackLayout + { + Spacing = 10, + Children = + { + new Label + { + Text = "Tap the button to navigate to a page with a CollectionView. Then press back - the BackButtonBehavior command should fire and update the label below.", + AutomationId = "InstructionLabel" + }, + new Button + { + Text = "Navigate to other Page", + AutomationId = "NavigateButton", + Command = new Command(() => Shell.Current.GoToAsync("Issue33688_second")) + }, + resultLabel + } + }; + } +} + +file class Issue33688SecondPage : ContentPage +{ + public Issue33688SecondPage() + { + // Use a ViewModel pattern with binding - this is the scenario from the issue + var viewModel = new Issue33688ViewModel(); + BindingContext = viewModel; + + // BackButtonBehavior with BOUND Command (key to reproduction) + var backButtonBehavior = new BackButtonBehavior(); + backButtonBehavior.SetBinding(BackButtonBehavior.CommandProperty, nameof(Issue33688ViewModel.SaveAndNavigateBackCommand)); + Shell.SetBackButtonBehavior(this, backButtonBehavior); + + var collectionView = new CollectionView + { + AutomationId = "TestCollectionView", + ItemTemplate = new DataTemplate(() => + { + var label = new Label(); + label.SetBinding(Label.TextProperty, "Name"); + return label; + }) + }; + collectionView.SetBinding(CollectionView.ItemsSourceProperty, nameof(Issue33688ViewModel.Items)); + + var filterButton = new Button + { + Text = "Load Items (triggers bug)", + AutomationId = "FilterButton" + }; + filterButton.SetBinding(Button.CommandProperty, nameof(Issue33688ViewModel.FilterCommand)); + + var statusLabel = new Label + { + Text = "Tap 'Load Items' then press back button.", + AutomationId = "StatusLabel" + }; + + Content = new VerticalStackLayout + { + Padding = 24, + Spacing = 10, + Children = + { + statusLabel, + filterButton, + collectionView + } + }; + } +} + +file class Issue33688ViewModel : INotifyPropertyChanged +{ + static Label _resultLabelRef = null!; + + public static void SetResultLabelRef(Label label) => _resultLabelRef = label; + + private ObservableCollection _items = new(); + + public ObservableCollection Items + { + get => _items; + set + { + if (_items != value) + { + _items = value; + OnPropertyChanged(); + } + } + } + + public ICommand SaveAndNavigateBackCommand { get; } + public ICommand FilterCommand { get; } + + public Issue33688ViewModel() + { + SaveAndNavigateBackCommand = new Command(() => + { + // Update the result label, then navigate back + if (_resultLabelRef != null) + { + _resultLabelRef.Text = "BackButtonBehavior triggered!"; + } + Shell.Current.GoToAsync(".."); + }); + + FilterCommand = new Command(() => + { + // This is the key action that triggers the bug: + // Setting Items to a new ObservableCollection AFTER the page is displayed + Items = new ObservableCollection + { + new Issue33688Item { Name = "Item 1" }, + new Issue33688Item { Name = "Item 2" }, + new Issue33688Item { Name = "Item 3" } + }; + }); + } + + public event PropertyChangedEventHandler PropertyChanged = null!; + + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null!) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} + +file class Issue33688Item +{ + public string Name { get; set; } = string.Empty; +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33688.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33688.cs new file mode 100644 index 000000000000..77f80c85939a --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33688.cs @@ -0,0 +1,44 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue33688 : _IssuesUITest +{ + public override string Issue => "BackButtonBehavior is no longer triggered once a ContentPage contains a CollectionView and the ItemsSource has been changed"; + + public Issue33688(TestDevice device) : base(device) { } + + [Test] + [Category(UITestCategories.Shell)] + public void BackButtonBehaviorTriggersWithCollectionView() + { + // Wait for main page to load + App.WaitForElement("NavigateButton"); + + // Navigate to the second page with CollectionView + App.Tap("NavigateButton"); + + // Wait for the second page to load - use StatusLabel as primary indicator + App.WaitForElement("StatusLabel"); + + // Find and tap the filter button to load items - this triggers the bug + // (setting ItemsSource to a new ObservableCollection) + App.WaitForElement("FilterButton"); + App.Tap("FilterButton"); + + // Give time for the CollectionView to update + App.WaitForElement("TestCollectionView"); + + // Press the back button + App.TapBackArrow(); + + // Wait for navigation back and verify BackButtonBehavior was triggered + App.WaitForElement("ResultLabel"); + + var resultText = App.FindElement("ResultLabel").GetText(); + Assert.That(resultText, Is.EqualTo("BackButtonBehavior triggered!"), + "BackButtonBehavior command should have been executed when pressing back button"); + } +}