diff --git a/src/Controls/src/Core/Handlers/Items/iOS/ObservableGroupedSource.cs b/src/Controls/src/Core/Handlers/Items/iOS/ObservableGroupedSource.cs index c42f5f3a4aad..4111016b918a 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/ObservableGroupedSource.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/ObservableGroupedSource.cs @@ -164,14 +164,7 @@ void CollectionChanged(NotifyCollectionChangedEventArgs args) // Force UICollectionView to get the internal accounting straight var collectionView = controller.CollectionView; - if (!collectionView.Hidden) - { - var numberOfSections = collectionView.NumberOfSections(); - for (int section = 0; section < numberOfSections; section++) - { - collectionView.NumberOfItemsInSection(section); - } - } + UpdateSection(collectionView); switch (args.Action) { @@ -193,8 +186,23 @@ void CollectionChanged(NotifyCollectionChangedEventArgs args) default: throw new ArgumentOutOfRangeException(); } + + // Calculate section and item counts after processing changes + // to ensure UICollectionView reflects the updated state + UpdateSection(collectionView); } + void UpdateSection(UICollectionView collectionView) + { + if (!collectionView.Hidden) + { + var numberOfSections = collectionView.NumberOfSections(); + for (int section = 0; section < numberOfSections; section++) + { + collectionView.NumberOfItemsInSection(section); + } + } + } void Reload(bool collectionWasReset = false) { ResetGroupTracking(); diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue27797.xaml b/src/Controls/tests/TestCases.HostApp/Issues/Issue27797.xaml new file mode 100644 index 000000000000..865fb801a1e9 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue27797.xaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue27797.xaml.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue27797.xaml.cs new file mode 100644 index 000000000000..578336048898 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue27797.xaml.cs @@ -0,0 +1,259 @@ +using System.Collections.ObjectModel; + +namespace Maui.Controls.Sample.Issues; +[Issue(IssueTracker.Github, 27797, "CollectionView with grouped data crashes on iOS when the groups change", PlatformAffected.iOS)] +public class Issue27797NavigationPage : NavigationPage +{ + public Issue27797NavigationPage() : base(new Issue27797()) { } +} + +public partial class Issue27797 : ContentPage +{ + public Issue27797() + { + InitializeComponent(); + BindingContext = new MainViewModel(); + } + + private void TapGestureRecognizer_Tapped(object sender, TappedEventArgs e) + { + DetailPage detailPage = new(); + var workItemViewModel = (sender as VisualElement).BindingContext; + detailPage.BindingContext = workItemViewModel; + Navigation.PushAsync(detailPage); + } + + public class DetailPage : ContentPage + { + public DetailPage() + { + Title = "DetailPage"; + BindingContext = new WorkItemViewModel(); + + var statusLabel = new Label + { + FontAttributes = FontAttributes.Bold, + FontSize = 20, + HorizontalOptions = LayoutOptions.Center + }; + statusLabel.SetBinding(Label.TextProperty, "Status"); + + var descriptionLabel = new Label + { + FontSize = 16, + HorizontalOptions = LayoutOptions.Center + }; + descriptionLabel.SetBinding(Label.TextProperty, "Description"); + + var todoButton = new Button + { + Text = "TODO", + AutomationId = "TODO" + }; + todoButton.SetBinding(Button.CommandProperty, "ChangeStatusCommand"); + todoButton.SetBinding(Button.CommandParameterProperty, new Binding { Source = "TODO" }); + + var activeButton = new Button + { + Text = "ACTIVE", + AutomationId = "ACTIVE" + }; + activeButton.SetBinding(Button.CommandProperty, "ChangeStatusCommand"); + activeButton.SetBinding(Button.CommandParameterProperty, new Binding { Source = "ACTIVE" }); + + var doneButton = new Button + { + Text = "DONE", + AutomationId = "DONE", + }; + doneButton.SetBinding(Button.CommandProperty, "ChangeStatusCommand"); + doneButton.SetBinding(Button.CommandParameterProperty, new Binding { Source = "DONE" }); + + var buttonLayout = new HorizontalStackLayout + { + HorizontalOptions = LayoutOptions.Center, + Spacing = 5, + Children = { todoButton, activeButton, doneButton } + }; + + var mainLayout = new VerticalStackLayout + { + Spacing = 10, + Children = { statusLabel, descriptionLabel, buttonLayout } + }; + + Content = mainLayout; + } + } + + public class MainViewModel : BindableObject + { + private List _workItems = new(); + + private ObservableCollection _groupedWorkItems = new(); + public ObservableCollection GroupedWorkItems + { + get => _groupedWorkItems; + set { _groupedWorkItems = value; OnPropertyChanged(); } + } + + static readonly string StatusTODO = "TODO"; + static readonly string StatusACTIVE = "ACTIVE"; + static readonly string StatusDONE = "DONE"; + + WorkItemGroupViewModel _todoGroup = new(StatusTODO); + WorkItemGroupViewModel _activeGroup = new(StatusACTIVE); + WorkItemGroupViewModel _doneGroup = new(StatusDONE); + + public MainViewModel() + { + CreateWorkItemData(); + UpdateGroupedWorkItems(); + } + + private void UpdateGroupedWorkItems() + { + foreach (var item in _workItems) + { + if (item.Status == StatusTODO && !_todoGroup.Contains(item)) + { + _todoGroup.Add(item); + } + else if (item.Status == StatusACTIVE && !_activeGroup.Contains(item)) + { + // move any existing active item back to TODO + if (_activeGroup.Any()) + { + var currentActiveItem = _activeGroup[0]; + _activeGroup.Remove(currentActiveItem); + currentActiveItem.Status = StatusTODO; + _todoGroup.Add(currentActiveItem); + } + _activeGroup.Add(item); + } + else if (item.Status == StatusDONE && !_doneGroup.Contains(item)) + { + _doneGroup.Add(item); + } + } + + if (_todoGroup.Any()) + { + if (!GroupedWorkItems.Contains(_todoGroup)) + GroupedWorkItems.Add(_todoGroup); + } + else if (GroupedWorkItems.Contains(_todoGroup)) + { + GroupedWorkItems.Remove(_todoGroup); + } + + if (_activeGroup.Any()) + { + // ACTIVE always at top + if (!GroupedWorkItems.Contains(_activeGroup)) + GroupedWorkItems.Insert(0, _activeGroup); + } + else if (GroupedWorkItems.Contains(_activeGroup)) + { + GroupedWorkItems.Remove(_activeGroup); + } + + if (_doneGroup.Any()) + { + if (!GroupedWorkItems.Contains(_doneGroup)) + GroupedWorkItems.Add(_doneGroup); + } + else if (GroupedWorkItems.Contains(_doneGroup)) + { + GroupedWorkItems.Remove(_doneGroup); + } + } + + private bool _statusUpdatesInProgress = false; + private void WorkItem_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (_statusUpdatesInProgress) + return; + + if (sender is not WorkItemViewModel workItem) + return; + + if (e.PropertyName == nameof(WorkItemViewModel.Status)) + { + _statusUpdatesInProgress = true; + RemoveItemFromOldGroup(workItem); + + UpdateGroupedWorkItems(); + _statusUpdatesInProgress = false; + } + } + + private void RemoveItemFromOldGroup(WorkItemViewModel workItem) + { + if (_todoGroup.Contains(workItem)) + _todoGroup.Remove(workItem); + + if (_activeGroup.Contains(workItem)) + _activeGroup.Remove(workItem); + + if (_doneGroup.Contains(workItem)) + _doneGroup.Remove(workItem); + } + + private void CreateWorkItemData() + { + var cleanHouse = new WorkItemViewModel + { + Description = "CleanHouse", + Status = StatusDONE + }; + cleanHouse.PropertyChanged += WorkItem_PropertyChanged; + _workItems.Add(cleanHouse); + + var doLaundry = new WorkItemViewModel + { + Description = "DoLaundry", + Status = StatusDONE + }; + doLaundry.PropertyChanged += WorkItem_PropertyChanged; + _workItems.Add(doLaundry); + + var mowLawn = new WorkItemViewModel + { + Description = "MowLawn", + Status = StatusTODO + }; + mowLawn.PropertyChanged += WorkItem_PropertyChanged; + _workItems.Add(mowLawn); + + } + } + + public class WorkItemGroupViewModel : ObservableCollection + { + public WorkItemGroupViewModel(string groupDescription) + { + GroupDescription = groupDescription; + } + + public string GroupDescription { get; } + } + + public class WorkItemViewModel : BindableObject + { + private string _description; + public string Description + { + get => _description; + set { _description = value; OnPropertyChanged(); } + } + + private string _status; + public string Status + { + get => _status; + set { _status = value; OnPropertyChanged(); } + } + public Command ChangeStatusCommand => new((newStatus) => Status = newStatus); + } +} \ No newline at end of file diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue27797.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue27797.cs new file mode 100644 index 000000000000..3fce6e798aea --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue27797.cs @@ -0,0 +1,25 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; +public class Issue27797 : _IssuesUITest +{ + public Issue27797(TestDevice testDevice) : base(testDevice) + { + } + + public override string Issue => "CollectionView with grouped data crashes on iOS when the groups change"; + + [Test] + [Category(UITestCategories.CollectionView)] + public void AppShouldNotCrashWhenModifyingCollectionView() + { + App.WaitForElement("CleanHouse"); + App.Click("MowLawn"); + App.WaitForElement("ACTIVE"); + App.Click("ACTIVE"); + App.Click("TODO"); + App.Back(); + } +} \ No newline at end of file