From 0a11a184a29ad143d18f8d95ee538cad96d47fdc Mon Sep 17 00:00:00 2001 From: "E.Z. Hart" Date: Tue, 2 Jul 2019 15:34:45 -0600 Subject: [PATCH] Implement CollectionView grouping on iOS (#6590) * Implement CollectionView grouping for iOS * Add grouping UI tests * Fix invalidcast when source is not INotifyCollectionChanged * Remove old TODO comments * Apply suggestions from code review Co-Authored-By: Stephane Delcroix * IsGroupingEnabled -> IsGrouped * Update empty source workaround * Fix incorrect group counting for empty collections * Iron out all section counts; remove unsavory empty view workaround; * Fix crash when filter is null * Handle sections for add/remove to/from empty sources; check for empty sources when handling sections; * Prevent random test failures for test 5793 * Remove now-unnecessary size check on transition from empty to non-empty source * Update Xamarin.Forms.Controls.Issues/Xamarin.Forms.Controls.Issues.Shared/CollectionViewGrouping.cs Co-Authored-By: Samantha Houts --- .../CollectionViewGrouping.cs | 95 ++++++++ .../Xamarin.Forms.Controls.Issues.Shared.projitems | 1 + .../CollectionViewGallery.cs | 6 +- .../DemoFilteredItemSource.cs | 3 +- .../GroupingGalleries/BasicGrouping.xaml | 35 +++ .../GroupingGalleries/BasicGrouping.xaml.cs | 24 ++ .../GroupingGalleries/GroupingGallery.cs | 40 ++++ .../GroupingGalleries/GroupingNoTemplates.xaml | 8 + .../GroupingGalleries/GroupingNoTemplates.xaml.cs | 21 ++ .../GroupingGalleries/GroupingPlusSelection.xaml | 36 +++ .../GroupingPlusSelection.xaml.cs | 21 ++ .../GroupingGalleries/MeasureFirstStrategy.xaml | 40 ++++ .../GroupingGalleries/MeasureFirstStrategy.xaml.cs | 22 ++ .../GroupingGalleries/ObservableGrouping.cs | 232 +++++++++++++++++++ .../GroupingGalleries/SomeEmptyGroups.xaml | 50 +++++ .../GroupingGalleries/SomeEmptyGroups.xaml.cs | 43 ++++ .../GroupingGalleries/SwitchGrouping.xaml | 42 ++++ .../GroupingGalleries/SwitchGrouping.xaml.cs | 22 ++ .../GroupingGalleries/ViewModel.cs | 213 ++++++++++++++++++ .../MultiTestObservableCollection.cs | 10 + .../Xamarin.Forms.Controls.csproj | 21 ++ Xamarin.Forms.Core/Items/CollectionView.cs | 2 +- Xamarin.Forms.Core/Items/GroupableItemsView.cs | 32 +++ Xamarin.Forms.Core/Items/ListItemsLayout.cs | 2 +- .../CollectionView/CollectionViewRenderer.cs | 2 +- .../CollectionView/EmptySource.cs | 24 +- .../CollectionView/GroupableItemsViewController.cs | 174 +++++++++++++++ .../CollectionView/GroupableItemsViewRenderer.cs | 25 +++ .../HorizontalDefaultSupplementalView.cs | 31 +++ .../HorizontalTemplatedHeaderView.cs | 32 +++ .../CollectionView/IItemsViewSource.cs | 10 +- .../CollectionView/ItemsSourceFactory.cs | 10 + .../CollectionView/ItemsViewController.cs | 183 +++++++-------- .../CollectionView/ItemsViewLayout.cs | 82 +++++-- .../CollectionView/ListSource.cs | 49 +++- .../CollectionView/ObservableGroupedSource.cs | 248 +++++++++++++++++++++ .../CollectionView/ObservableItemsSource.cs | 158 +++++++++---- .../SelectableItemsViewController.cs | 3 +- .../CollectionView/UICollectionViewDelegator.cs | 31 ++- .../VerticalDefaultSupplementalView.cs | 30 +++ .../CollectionView/VerticalTemplatedHeaderView.cs | 32 +++ .../Xamarin.Forms.Platform.iOS.csproj | 7 + 42 files changed, 1964 insertions(+), 188 deletions(-) create mode 100644 Xamarin.Forms.Controls.Issues/Xamarin.Forms.Controls.Issues.Shared/CollectionViewGrouping.cs create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/BasicGrouping.xaml create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/BasicGrouping.xaml.cs create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingGallery.cs create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingNoTemplates.xaml create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingNoTemplates.xaml.cs create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingPlusSelection.xaml create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingPlusSelection.xaml.cs create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/MeasureFirstStrategy.xaml create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/MeasureFirstStrategy.xaml.cs create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/ObservableGrouping.cs create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SomeEmptyGroups.xaml create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SomeEmptyGroups.xaml.cs create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SwitchGrouping.xaml create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SwitchGrouping.xaml.cs create mode 100644 Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/ViewModel.cs create mode 100644 Xamarin.Forms.Core/Items/GroupableItemsView.cs create mode 100644 Xamarin.Forms.Platform.iOS/CollectionView/GroupableItemsViewController.cs create mode 100644 Xamarin.Forms.Platform.iOS/CollectionView/GroupableItemsViewRenderer.cs create mode 100644 Xamarin.Forms.Platform.iOS/CollectionView/HorizontalDefaultSupplementalView.cs create mode 100644 Xamarin.Forms.Platform.iOS/CollectionView/HorizontalTemplatedHeaderView.cs create mode 100644 Xamarin.Forms.Platform.iOS/CollectionView/ObservableGroupedSource.cs create mode 100644 Xamarin.Forms.Platform.iOS/CollectionView/VerticalDefaultSupplementalView.cs create mode 100644 Xamarin.Forms.Platform.iOS/CollectionView/VerticalTemplatedHeaderView.cs diff --git a/Xamarin.Forms.Controls.Issues/Xamarin.Forms.Controls.Issues.Shared/CollectionViewGrouping.cs b/Xamarin.Forms.Controls.Issues/Xamarin.Forms.Controls.Issues.Shared/CollectionViewGrouping.cs new file mode 100644 index 0000000..bf25b99 --- /dev/null +++ b/Xamarin.Forms.Controls.Issues/Xamarin.Forms.Controls.Issues.Shared/CollectionViewGrouping.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Xamarin.Forms.CustomAttributes; +using Xamarin.Forms.Internals; + +#if UITEST +using Xamarin.Forms.Core.UITests; +using Xamarin.UITest; +using NUnit.Framework; +#endif + +namespace Xamarin.Forms.Controls.Issues +{ +#if UITEST + [Category(UITestCategories.CollectionView)] +#endif + + [Preserve(AllMembers = true)] + [Issue(IssueTracker.None, 4539135, "CollectionView: Grouping", PlatformAffected.All)] + public class CollectionViewGrouping : TestNavigationPage + { + protected override void Init() + { +#if APP + Device.SetFlags(new List(Device.Flags ?? new List()) { "CollectionView_Experimental" }); + + PushAsync(new GalleryPages.CollectionViewGalleries.GroupingGalleries.ObservableGrouping()); +#endif + } + +#if UITEST && __IOS__ // Grouping is not implemented on Android yet + [Test] + public void RemoveSelectedItem() + { + RunningApp.WaitForElement("Hawkeye"); + RunningApp.Tap("Hawkeye"); + RunningApp.Tap("RemoveItem"); + RunningApp.WaitForNoElement("Hawkeye"); + } + + [Test] + public void AddItem() + { + RunningApp.WaitForElement("Hawkeye"); + RunningApp.Tap("Hawkeye"); + RunningApp.Tap("AddItem"); + RunningApp.WaitForElement("Spider-Man"); + } + + [Test] + public void ReplaceItem() + { + RunningApp.WaitForElement("Iron Man"); + RunningApp.Tap("Iron Man"); + RunningApp.Tap("ReplaceItem"); + RunningApp.WaitForNoElement("Iron Man"); + RunningApp.WaitForElement("Spider-Man"); + } + + [Test] + public void RemoveGroup() + { + RunningApp.WaitForElement("Avengers"); + RunningApp.Tap("RemoveGroup"); + RunningApp.WaitForNoElement("Avengers"); + } + + [Test] + public void AddGroup() + { + RunningApp.WaitForElement("AddGroup"); + RunningApp.Tap("AddGroup"); + RunningApp.WaitForElement("Excalibur"); + } + + [Test] + public void ReplaceGroup() + { + RunningApp.WaitForElement("Fantastic Four"); + RunningApp.Tap("ReplaceGroup"); + RunningApp.WaitForElement("Alpha Flight"); + } + + [Test] + public void MoveGroup() + { + RunningApp.WaitForElement("MoveGroup"); + RunningApp.Tap("MoveGroup"); + } + + +#endif + } +} diff --git a/Xamarin.Forms.Controls.Issues/Xamarin.Forms.Controls.Issues.Shared/Xamarin.Forms.Controls.Issues.Shared.projitems b/Xamarin.Forms.Controls.Issues/Xamarin.Forms.Controls.Issues.Shared/Xamarin.Forms.Controls.Issues.Shared.projitems index e383872..8148ca5 100644 --- a/Xamarin.Forms.Controls.Issues/Xamarin.Forms.Controls.Issues.Shared/Xamarin.Forms.Controls.Issues.Shared.projitems +++ b/Xamarin.Forms.Controls.Issues/Xamarin.Forms.Controls.Issues.Shared/Xamarin.Forms.Controls.Issues.Shared.projitems @@ -9,6 +9,7 @@ Xamarin.Forms.Controls.Issues + diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/CollectionViewGallery.cs b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/CollectionViewGallery.cs index 79e9237..1e06a83 100644 --- a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/CollectionViewGallery.cs +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/CollectionViewGallery.cs @@ -1,7 +1,6 @@ using Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.EmptyViewGalleries; +using Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.GroupingGalleries; using Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.SelectionGalleries; -using Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.ItemSizeGalleries; -using Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.SpacingGalleries; namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries { @@ -23,8 +22,7 @@ namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries GalleryBuilder.NavButton("EmptyView Galleries", () => new EmptyViewGallery(), Navigation), GalleryBuilder.NavButton("Selection Galleries", () => new SelectionGallery(), Navigation), GalleryBuilder.NavButton("Propagation Galleries", () => new PropagationGallery(), Navigation), - GalleryBuilder.NavButton("Item Size Galleries", () => new ItemsSizeGallery(), Navigation), - GalleryBuilder.NavButton("Spacing Galleries", () => new ItemsSpacingGallery(), Navigation), + GalleryBuilder.NavButton("Grouping Galleries", () => new GroupingGallery(), Navigation), } }; } diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/DemoFilteredItemSource.cs b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/DemoFilteredItemSource.cs index 1ea1e44..6f15e93 100644 --- a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/DemoFilteredItemSource.cs +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/DemoFilteredItemSource.cs @@ -39,7 +39,8 @@ namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries private bool ItemMatches(string filter, CollectionViewGalleryTestItem item) { - return item.Caption.ToLower().Contains(filter.ToLower()); + filter = filter ?? ""; + return item.Caption.ToLower().Contains(filter?.ToLower()); } public void FilterItems(string filter) diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/BasicGrouping.xaml b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/BasicGrouping.xaml new file mode 100644 index 0000000..5332e54 --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/BasicGrouping.xaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/BasicGrouping.xaml.cs b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/BasicGrouping.xaml.cs new file mode 100644 index 0000000..694b66c --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/BasicGrouping.xaml.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Xamarin.Forms; +using Xamarin.Forms.Internals; +using Xamarin.Forms.Xaml; + +namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.GroupingGalleries +{ + [XamlCompilation(XamlCompilationOptions.Compile)] + [Preserve (AllMembers = true)] + public partial class BasicGrouping : ContentPage + { + public BasicGrouping () + { + InitializeComponent (); + + CollectionView.ItemsSource = new SuperTeams(); + } + } +} \ No newline at end of file diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingGallery.cs b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingGallery.cs new file mode 100644 index 0000000..00b9142 --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingGallery.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.GroupingGalleries +{ + class GroupingGallery : ContentPage + { + public GroupingGallery() + { + var descriptionLabel = + new Label { Text = "Grouping Galleries", Margin = new Thickness(2, 2, 2, 2) }; + + Title = "Grouping Galleries"; + + Content = new ScrollView + { + Content = new StackLayout + { + Children = + { + descriptionLabel, + GalleryBuilder.NavButton("Basic Grouping", () => + new BasicGrouping(), Navigation), + GalleryBuilder.NavButton("Grouping, some empty groups", () => + new SomeEmptyGroups(), Navigation), + GalleryBuilder.NavButton("Grouping, no templates", () => + new GroupingNoTemplates(), Navigation), + GalleryBuilder.NavButton("Grouping, with selection", () => + new GroupingPlusSelection(), Navigation), + GalleryBuilder.NavButton("Grouping, switchable", () => + new SwitchGrouping(), Navigation), + GalleryBuilder.NavButton("Grouping, Observable", () => + new ObservableGrouping(), Navigation), + } + } + }; + } + } +} diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingNoTemplates.xaml b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingNoTemplates.xaml new file mode 100644 index 0000000..81991f6 --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingNoTemplates.xaml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingNoTemplates.xaml.cs b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingNoTemplates.xaml.cs new file mode 100644 index 0000000..c152507 --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingNoTemplates.xaml.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Xamarin.Forms; +using Xamarin.Forms.Xaml; + +namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.GroupingGalleries +{ + [XamlCompilation(XamlCompilationOptions.Compile)] + public partial class GroupingNoTemplates : ContentPage + { + public GroupingNoTemplates() + { + InitializeComponent(); + CollectionView.ItemsSource = new SuperTeams(); + } + } +} \ No newline at end of file diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingPlusSelection.xaml b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingPlusSelection.xaml new file mode 100644 index 0000000..0dbd226 --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingPlusSelection.xaml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingPlusSelection.xaml.cs b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingPlusSelection.xaml.cs new file mode 100644 index 0000000..cf1506f --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/GroupingPlusSelection.xaml.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Xamarin.Forms; +using Xamarin.Forms.Xaml; + +namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.GroupingGalleries +{ + [XamlCompilation(XamlCompilationOptions.Compile)] + public partial class GroupingPlusSelection : ContentPage + { + public GroupingPlusSelection () + { + InitializeComponent (); + CollectionView.ItemsSource = new SuperTeams(); + } + } +} \ No newline at end of file diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/MeasureFirstStrategy.xaml b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/MeasureFirstStrategy.xaml new file mode 100644 index 0000000..6ce49ef --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/MeasureFirstStrategy.xaml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/MeasureFirstStrategy.xaml.cs b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/MeasureFirstStrategy.xaml.cs new file mode 100644 index 0000000..e1b6465 --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/MeasureFirstStrategy.xaml.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Xamarin.Forms; +using Xamarin.Forms.Xaml; + +namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.GroupingGalleries +{ + [XamlCompilation(XamlCompilationOptions.Compile)] + public partial class MeasureFirstStrategy : ContentPage + { + public MeasureFirstStrategy() + { + InitializeComponent(); + + CollectionView.ItemsSource = new SuperTeams(); + } + } +} \ No newline at end of file diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/ObservableGrouping.cs b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/ObservableGrouping.cs new file mode 100644 index 0000000..d97d508 --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/ObservableGrouping.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; + +namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.GroupingGalleries +{ + internal class ObservableGrouping : ContentPage + { + public ObservableGrouping() + { + Title = "Observable Grouped List"; + + var buttonStyle = new Style(typeof(Button)) { }; + buttonStyle.Setters.Add(new Setter() { Property = Button.HeightRequestProperty, Value = 20 }); + buttonStyle.Setters.Add(new Setter() { Property = Button.FontSizeProperty, Value = 10 }); + + var layout = new Grid + { + RowDefinitions = new RowDefinitionCollection + { + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Star } + }, + ColumnDefinitions = new ColumnDefinitionCollection + { + new ColumnDefinition(), new ColumnDefinition() + } + + }; + + var collectionView = new CollectionView + { + ItemTemplate = ItemTemplate(), + GroupFooterTemplate = GroupFooterTemplate(), + GroupHeaderTemplate = GroupHeaderTemplate(), + IsGrouped = true, + SelectionMode = SelectionMode.Single + }; + + var itemsSource = new ObservableSuperTeams(); + + collectionView.ItemsSource = itemsSource; + + var remover = new Button { Text = "Remove Selected", AutomationId = "RemoveItem", Style = buttonStyle }; + remover.Clicked += (obj, args) => { + var selectedMember = collectionView.SelectedItem as Member; + var team = FindTeam(itemsSource, selectedMember); + team?.Remove(selectedMember); + }; + + var adder = new Button { Text = "Add After Selected", AutomationId = "AddItem", Style = buttonStyle }; + adder.Clicked += (obj, args) => { + var selectedMember = collectionView.SelectedItem as Member; + var team = FindTeam(itemsSource, selectedMember); + + if (team == null) + { + return; + } + + team.Insert(team.IndexOf(selectedMember) + 1, new Member("Spider-Man")); + }; + + AddStuffToGridRow(layout, 0, remover, adder); + + var replacer = new Button { Text = "Replace Selected", AutomationId = "ReplaceItem", Style = buttonStyle }; + replacer.Clicked += (obj, args) => { + var selectedMember = collectionView.SelectedItem as Member; + var team = FindTeam(itemsSource, selectedMember); + + if (team == null) + { + return; + } + + team.Insert(team.IndexOf(selectedMember) + 1, new Member("Spider-Man")); + team.Remove(selectedMember); + }; + + var mover = new Button { Text = $"Move Selected To {itemsSource[0].Name}", AutomationId = "MoveItem", + Style = buttonStyle }; + mover.Clicked += (obj, args) => { + var selectedMember = collectionView.SelectedItem as Member; + var team = FindTeam(itemsSource, selectedMember); + + if (team == null || team == itemsSource[0]) + { + return; + } + + team.Remove(selectedMember); + itemsSource[0].Add(selectedMember); + }; + + AddStuffToGridRow(layout, 1, replacer, mover); + + var groupRemover = new Button { Text = $"Remove {itemsSource[0].Name}", AutomationId = "RemoveGroup", + Style = buttonStyle }; + groupRemover.Clicked += (obj, args) => { + itemsSource?.Remove(itemsSource[0]); + groupRemover.Text = $"Remove {itemsSource[0].Name}"; + mover.Text = $"Move Selected To {itemsSource[0].Name}"; + }; + + var groupAdder = new Button { Text = $"Insert New Group at position 2", AutomationId = "AddGroup", + Style = buttonStyle }; + groupAdder.Clicked += (obj, args) => { + itemsSource?.Insert(1, new ObservableTeam("Excalibur", new List())); + }; + + AddStuffToGridRow(layout, 2, groupRemover, groupAdder); + + var groupMover = new Button { Text = "Move 3rd Group to 1st", AutomationId = "MoveGroup", Style = buttonStyle }; + groupMover.Clicked += (obj, args) => { + var group = itemsSource[2]; + itemsSource.Remove(group); + itemsSource.Insert(0, group); + groupRemover.Text = $"Remove {itemsSource[0].Name}"; + mover.Text = $"Move Selected To {itemsSource[0].Name}"; + }; + + var groupReplacer = new Button { Text = "Replace 2nd Group", AutomationId = "ReplaceGroup", Style = buttonStyle }; + groupReplacer.Clicked += (obj, args) => { + var group = itemsSource[1]; + itemsSource.Remove(group); + itemsSource?.Insert(1, new ObservableTeam("Alpha Flight", new List { new Member("Guardian"), + new Member("Sasquatch"), new Member("Northstar") })); + }; + + AddStuffToGridRow(layout, 3, groupMover, groupReplacer); + + layout.Children.Add(collectionView); + Grid.SetRow(collectionView, 6); + Grid.SetColumnSpan(collectionView, 2); + + Content = layout; + } + + void AddStuffToGridRow(Grid grid, int row, params View[] views) + { + var col = 0; + + foreach (var view in views) + { + grid.Children.Add(view); + Grid.SetRow(view, row); + Grid.SetColumn(view, col); + col = col + 1; + } + } + + DataTemplate ItemTemplate() + { + return new DataTemplate(() => + { + var layout = new StackLayout(); + var label = new Label() + { + Margin = new Thickness(5, 0, 0, 0), + + }; + + label.SetBinding(Label.TextProperty, new Binding("Name")); + layout.Children.Add(label); + + return layout; + }); + } + + DataTemplate GroupHeaderTemplate() + { + return new DataTemplate(() => + { + var layout = new StackLayout(); + var label = new Label() + { + FontSize = 16, + FontAttributes = FontAttributes.Bold, + BackgroundColor = Color.LightGreen + + }; + + label.SetBinding(Label.TextProperty, new Binding("Name")); + layout.Children.Add(label); + + return layout; + }); + } + + DataTemplate GroupFooterTemplate() + { + return new DataTemplate(() => + { + var layout = new StackLayout(); + var label = new Label() + { + Margin = new Thickness(0, 0, 0, 15), + BackgroundColor = Color.LightBlue + }; + + label.SetBinding(Label.TextProperty, new Binding("Count", stringFormat: "Total members: {0:D}")); + layout.Children.Add(label); + + return layout; + }); + } + + ObservableTeam FindTeam(ObservableSuperTeams teams, Member member) + { + if (member == null) + { + return null; + } + + for (int i = 0; i < teams.Count; i++) + { + var group = teams[i]; + + if (group.Contains(member)) + { + return group; + } + } + + return null; + } + } +} diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SomeEmptyGroups.xaml b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SomeEmptyGroups.xaml new file mode 100644 index 0000000..c55fbd6 --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SomeEmptyGroups.xaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SomeEmptyGroups.xaml.cs b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SomeEmptyGroups.xaml.cs new file mode 100644 index 0000000..4f1437a --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SomeEmptyGroups.xaml.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using Xamarin.Forms.Xaml; + +namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.GroupingGalleries +{ + [XamlCompilation(XamlCompilationOptions.Compile)] + public partial class SomeEmptyGroups : ContentPage + { + public SomeEmptyGroups() + { + InitializeComponent(); + + var teams = new List + { + new Team("Avengers", new List + { + new Member("Thor"), + new Member("Captain America") + }), + + new Team("Thundercats", new List()), + + new Team("Avengers", new List + { + new Member("Thor"), + new Member("Captain America") + }), + + new Team("Bionic Six", new List()), + + new Team("Fantastic Four", new List + { + new Member("The Thing"), + new Member("The Human Torch"), + new Member("The Invisible Woman"), + new Member("Mr. Fantastic"), + }) + }; + + CollectionView.ItemsSource = teams; + } + } +} \ No newline at end of file diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SwitchGrouping.xaml b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SwitchGrouping.xaml new file mode 100644 index 0000000..00260e9 --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SwitchGrouping.xaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SwitchGrouping.xaml.cs b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SwitchGrouping.xaml.cs new file mode 100644 index 0000000..87a6e13 --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/SwitchGrouping.xaml.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Xamarin.Forms; +using Xamarin.Forms.Xaml; + +namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.GroupingGalleries +{ + [XamlCompilation(XamlCompilationOptions.Compile)] + public partial class SwitchGrouping : ContentPage + { + public SwitchGrouping() + { + InitializeComponent(); + + CollectionView.ItemsSource = new SuperTeams(); + } + } +} \ No newline at end of file diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/ViewModel.cs b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/ViewModel.cs new file mode 100644 index 0000000..59b2184 --- /dev/null +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/GroupingGalleries/ViewModel.cs @@ -0,0 +1,213 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using Xamarin.Forms.Internals; + +namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.GroupingGalleries +{ + [Preserve(AllMembers = true)] + class Team : List + { + public Team(string name, List members) : base(members) + { + Name = name; + } + + public string Name { get; set; } + + public override string ToString() + { + return Name; + } + } + + [Preserve(AllMembers = true)] + class Member + { + public Member(string name) => Name = name; + + public string Name { get; set; } + + public override string ToString() + { + return Name; + } + } + + [Preserve(AllMembers = true)] + class SuperTeams : List + { + public SuperTeams() + { + Add(new Team("Avengers", + new List + { + new Member("Thor"), + new Member("Captain America"), + new Member("Iron Man"), + new Member("The Hulk"), + new Member("Ant-Man"), + new Member("Wasp"), + new Member("Hawkeye"), + new Member("Black Panther"), + new Member("Black Widow"), + new Member("Doctor Druid"), + new Member("She-Hulk"), + new Member("Mockingbird"), + } + )); + + Add(new Team("Fantastic Four", + new List + { + new Member("The Thing"), + new Member("The Human Torch"), + new Member("The Invisible Woman"), + new Member("Mr. Fantastic"), + } + )); + + Add(new Team("Defenders", + new List + { + new Member("Doctor Strange"), + new Member("Namor"), + new Member("Hulk"), + new Member("Silver Surfer"), + new Member("Hellcat"), + new Member("Nighthawk"), + new Member("Yellowjacket"), + } + )); + + Add(new Team("Heroes for Hire", + new List + { + new Member("Luke Cage"), + new Member("Iron Fist"), + new Member("Misty Knight"), + new Member("Colleen Wing"), + new Member("Shang-Chi"), + } + )); + + Add(new Team("West Coast Avengers", + new List + { + new Member("Hawkeye"), + new Member("Mockingbird"), + new Member("War Machine"), + new Member("Wonder Man"), + new Member("Tigra"), + } + )); + + Add(new Team("Great Lakes Avengers", + new List + { + new Member("Squirrel Girl"), + new Member("Dinah Soar"), + new Member("Mr. Immortal"), + new Member("Flatman"), + new Member("Doorman"), + } + )); + } + } + + [Preserve(AllMembers = true)] + class ObservableTeam : ObservableCollection + { + public ObservableTeam(string name, List members) : base(members) + { + Name = name; + } + + public string Name { get; set; } + + public override string ToString() + { + return Name; + } + } + + [Preserve(AllMembers = true)] + class ObservableSuperTeams : ObservableCollection + { + public ObservableSuperTeams () + { + Add(new ObservableTeam("Avengers", + new List + { + new Member("Thor"), + new Member("Captain America"), + new Member("Iron Man"), + new Member("The Hulk"), + new Member("Ant-Man"), + new Member("Wasp"), + new Member("Hawkeye"), + new Member("Black Panther"), + new Member("Black Widow"), + new Member("Doctor Druid"), + new Member("She-Hulk"), + new Member("Mockingbird"), + } + )); + + Add(new ObservableTeam("Fantastic Four", + new List + { + new Member("The Thing"), + new Member("The Human Torch"), + new Member("The Invisible Woman"), + new Member("Mr. Fantastic"), + } + )); + + Add(new ObservableTeam("Defenders", + new List + { + new Member("Doctor Strange"), + new Member("Namor"), + new Member("Hulk"), + new Member("Silver Surfer"), + new Member("Hellcat"), + new Member("Nighthawk"), + new Member("Yellowjacket"), + } + )); + + Add(new ObservableTeam("Heroes for Hire", + new List + { + new Member("Luke Cage"), + new Member("Iron Fist"), + new Member("Misty Knight"), + new Member("Colleen Wing"), + new Member("Shang-Chi"), + } + )); + + Add(new ObservableTeam("West Coast Avengers", + new List + { + new Member("Hawkeye"), + new Member("Mockingbird"), + new Member("War Machine"), + new Member("Wonder Man"), + new Member("Tigra"), + } + )); + + Add(new ObservableTeam("Great Lakes Avengers", + new List + { + new Member("Squirrel Girl"), + new Member("Dinah Soar"), + new Member("Mr. Immortal"), + new Member("Flatman"), + new Member("Doorman"), + } + )); + } + } +} diff --git a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/MultiTestObservableCollection.cs b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/MultiTestObservableCollection.cs index 511a257..66e54c7 100644 --- a/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/MultiTestObservableCollection.cs +++ b/Xamarin.Forms.Controls/GalleryPages/CollectionViewGalleries/MultiTestObservableCollection.cs @@ -2,11 +2,16 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; +using Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.GroupingGalleries; namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries { internal class MultiTestObservableCollection : List, INotifyCollectionChanged { + public MultiTestObservableCollection(List members) : base(members) { } + + public MultiTestObservableCollection() { } + // This is a testing class which implements INotifyCollectionChanged and, unlike the regular // ObservableCollection, will actually fire Add and Remove with multiple items at once @@ -77,7 +82,12 @@ namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries public void TestReset() { + var random = new Random(); + var randomized = GetRange(1, Count - 1).Select(item => new { Item = item, Index = random.Next(100000) }) + .OrderBy(x => x.Index).Select(x => x.Item).ToList(); + RemoveRange(0, Count); + InsertRange(0, randomized); var args = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset); OnNotifyCollectionChanged(this, args); diff --git a/Xamarin.Forms.Controls/Xamarin.Forms.Controls.csproj b/Xamarin.Forms.Controls/Xamarin.Forms.Controls.csproj index 5ef591f..a9a73ba 100644 --- a/Xamarin.Forms.Controls/Xamarin.Forms.Controls.csproj +++ b/Xamarin.Forms.Controls/Xamarin.Forms.Controls.csproj @@ -126,6 +126,27 @@ MSBuild:UpdateDesignTimeXaml + + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + diff --git a/Xamarin.Forms.Core/Items/CollectionView.cs b/Xamarin.Forms.Core/Items/CollectionView.cs index e49e84d..0021141 100644 --- a/Xamarin.Forms.Core/Items/CollectionView.cs +++ b/Xamarin.Forms.Core/Items/CollectionView.cs @@ -10,7 +10,7 @@ using Xamarin.Forms.Platform; namespace Xamarin.Forms { [RenderWith(typeof(_CollectionViewRenderer))] - public class CollectionView : SelectableItemsView + public class CollectionView : GroupableItemsView { internal const string CollectionViewExperimental = "CollectionView_Experimental"; diff --git a/Xamarin.Forms.Core/Items/GroupableItemsView.cs b/Xamarin.Forms.Core/Items/GroupableItemsView.cs new file mode 100644 index 0000000..27664e0 --- /dev/null +++ b/Xamarin.Forms.Core/Items/GroupableItemsView.cs @@ -0,0 +1,32 @@ +namespace Xamarin.Forms +{ + public class GroupableItemsView : SelectableItemsView + { + public static readonly BindableProperty IsGroupedProperty = + BindableProperty.Create(nameof(IsGrouped), typeof(bool), typeof(GroupableItemsView), false); + + public bool IsGrouped + { + get => (bool)GetValue(IsGroupedProperty); + set => SetValue(IsGroupedProperty, value); + } + + public static readonly BindableProperty GroupHeaderTemplateProperty = + BindableProperty.Create(nameof(GroupHeaderTemplate), typeof(DataTemplate), typeof(GroupableItemsView), default(DataTemplate)); + + public DataTemplate GroupHeaderTemplate + { + get => (DataTemplate)GetValue(GroupHeaderTemplateProperty); + set => SetValue(GroupHeaderTemplateProperty, value); + } + + public static readonly BindableProperty GroupFooterTemplateProperty = + BindableProperty.Create(nameof(GroupFooterTemplate), typeof(DataTemplate), typeof(GroupableItemsView), default(DataTemplate)); + + public DataTemplate GroupFooterTemplate + { + get => (DataTemplate)GetValue(GroupFooterTemplateProperty); + set => SetValue(GroupFooterTemplateProperty, value); + } + } +} diff --git a/Xamarin.Forms.Core/Items/ListItemsLayout.cs b/Xamarin.Forms.Core/Items/ListItemsLayout.cs index 6400f48..b64678d 100644 --- a/Xamarin.Forms.Core/Items/ListItemsLayout.cs +++ b/Xamarin.Forms.Core/Items/ListItemsLayout.cs @@ -4,7 +4,7 @@ namespace Xamarin.Forms { public class ListItemsLayout : ItemsLayout { - public ListItemsLayout(ItemsLayoutOrientation orientation) : base(orientation) + public ListItemsLayout([Parameter("Orientation")] ItemsLayoutOrientation orientation) : base(orientation) { } diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/CollectionViewRenderer.cs b/Xamarin.Forms.Platform.iOS/CollectionView/CollectionViewRenderer.cs index 5bd2a96..23a6a66 100644 --- a/Xamarin.Forms.Platform.iOS/CollectionView/CollectionViewRenderer.cs +++ b/Xamarin.Forms.Platform.iOS/CollectionView/CollectionViewRenderer.cs @@ -1,4 +1,4 @@ namespace Xamarin.Forms.Platform.iOS { - public class CollectionViewRenderer : SelectableItemsViewRenderer { } + public class CollectionViewRenderer : GroupableItemsViewRenderer { } } \ No newline at end of file diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/EmptySource.cs b/Xamarin.Forms.Platform.iOS/CollectionView/EmptySource.cs index 123709b..3aa0447 100644 --- a/Xamarin.Forms.Platform.iOS/CollectionView/EmptySource.cs +++ b/Xamarin.Forms.Platform.iOS/CollectionView/EmptySource.cs @@ -1,12 +1,30 @@ using System; +using Foundation; namespace Xamarin.Forms.Platform.iOS { - sealed class EmptySource : IItemsViewSource + internal class EmptySource : IItemsViewSource { - public int Count => 0; + public int GroupCount => 0; - public object this[int index] => throw new IndexOutOfRangeException("IItemsViewSource is empty"); + public int ItemCount => 0; + + public object this[NSIndexPath indexPath] => throw new IndexOutOfRangeException("IItemsViewSource is empty"); + + public int ItemCountInGroup(nint group) + { + return 0; + } + + public object Group(NSIndexPath indexPath) + { + throw new IndexOutOfRangeException("IItemsViewSource is empty"); + } + + public NSIndexPath GetIndexForItem(object item) + { + throw new IndexOutOfRangeException("IItemsViewSource is empty"); + } public void Dispose() { diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/GroupableItemsViewController.cs b/Xamarin.Forms.Platform.iOS/CollectionView/GroupableItemsViewController.cs new file mode 100644 index 0000000..1c76203 --- /dev/null +++ b/Xamarin.Forms.Platform.iOS/CollectionView/GroupableItemsViewController.cs @@ -0,0 +1,174 @@ +using System; +using CoreGraphics; +using Foundation; +using UIKit; + +namespace Xamarin.Forms.Platform.iOS +{ + public class GroupableItemsViewController : SelectableItemsViewController + { + GroupableItemsView GroupableItemsView => (GroupableItemsView)ItemsView; + + // Keep a cached value for the current state of grouping around so we can avoid hitting the + // BindableProperty all the time + bool _isGrouped; + + public GroupableItemsViewController(GroupableItemsView groupableItemsView, ItemsViewLayout layout) + : base(groupableItemsView, layout) + { + _isGrouped = GroupableItemsView.IsGrouped; + } + + protected override IItemsViewSource CreateItemsViewSource() + { + // Use the BindableProperty here (instead of _isGroupingEnabled) because the cached value might not be set yet + if (GroupableItemsView.IsGrouped) + { + return ItemsSourceFactory.CreateGrouped(GroupableItemsView.ItemsSource, CollectionView); + } + + return base.CreateItemsViewSource(); + } + + public override void UpdateItemsSource() + { + _isGrouped = GroupableItemsView.IsGrouped; + base.UpdateItemsSource(); + } + + protected override void RegisterViewTypes() + { + base.RegisterViewTypes(); + + RegisterSupplementaryViews(UICollectionElementKindSection.Header); + RegisterSupplementaryViews(UICollectionElementKindSection.Footer); + } + + private void RegisterSupplementaryViews(UICollectionElementKindSection kind) + { + CollectionView.RegisterClassForSupplementaryView(typeof(HorizontalTemplatedSupplementalView), + kind, HorizontalTemplatedSupplementalView.ReuseId); + CollectionView.RegisterClassForSupplementaryView(typeof(VerticalTemplatedSupplementalView), + kind, VerticalTemplatedSupplementalView.ReuseId); + CollectionView.RegisterClassForSupplementaryView(typeof(HorizontalDefaultSupplementalView), + kind, HorizontalDefaultSupplementalView.ReuseId); + CollectionView.RegisterClassForSupplementaryView(typeof(VerticalDefaultSupplementalView), + kind, VerticalDefaultSupplementalView.ReuseId); + } + + public override UICollectionReusableView GetViewForSupplementaryElement(UICollectionView collectionView, + NSString elementKind, NSIndexPath indexPath) + { + var reuseId = DetermineViewReuseId(elementKind); + + var view = collectionView.DequeueReusableSupplementaryView(elementKind, reuseId, indexPath) as UICollectionReusableView; + + switch (view) + { + case DefaultCell defaultCell: + UpdateDefaultSupplementaryView(defaultCell, elementKind, indexPath); + break; + case TemplatedCell templatedCell: + UpdateTemplatedSupplementaryView(templatedCell, elementKind, indexPath); + break; + } + + return view; + } + + void UpdateDefaultSupplementaryView(DefaultCell cell, NSString elementKind, NSIndexPath indexPath) + { + cell.Label.Text = ItemsSource.Group(indexPath).ToString(); + + if (cell is ItemsViewCell constrainedCell) + { + cell.ConstrainTo(ItemsViewLayout.ConstrainedDimension); + } + } + + void UpdateTemplatedSupplementaryView(TemplatedCell cell, NSString elementKind, NSIndexPath indexPath) + { + ApplyTemplateAndDataContext(cell, elementKind, indexPath); + + if (cell is ItemsViewCell constrainedCell) + { + cell.ConstrainTo(ItemsViewLayout.ConstrainedDimension); + } + } + + void ApplyTemplateAndDataContext(TemplatedCell cell, NSString elementKind, NSIndexPath indexPath) + { + DataTemplate template; + + if (elementKind == UICollectionElementKindSectionKey.Header) + { + template = GroupableItemsView.GroupHeaderTemplate; + } + else + { + template = GroupableItemsView.GroupFooterTemplate; + } + + var templateElement = template.CreateContent() as View; + var renderer = CreateRenderer(templateElement); + + BindableObject.SetInheritedBindingContext(renderer.Element, ItemsSource.Group(indexPath)); + cell.SetRenderer(renderer); + } + + string DetermineViewReuseId(NSString elementKind) + { + if (elementKind == UICollectionElementKindSectionKey.Header) + { + return DetermineViewReuseId(GroupableItemsView.GroupHeaderTemplate); + } + + return DetermineViewReuseId(GroupableItemsView.GroupFooterTemplate); + } + + string DetermineViewReuseId(DataTemplate template) + { + if (template == null) + { + // No template, fall back the the default supplemental views + return ItemsViewLayout.ScrollDirection == UICollectionViewScrollDirection.Horizontal + ? HorizontalDefaultSupplementalView.ReuseId + : VerticalDefaultSupplementalView.ReuseId; + } + + return ItemsViewLayout.ScrollDirection == UICollectionViewScrollDirection.Horizontal + ? HorizontalTemplatedSupplementalView.ReuseId + : VerticalTemplatedSupplementalView.ReuseId; + } + + internal CGSize GetReferenceSizeForHeader(UICollectionView collectionView, UICollectionViewLayout layout, nint section) + { + if (!_isGrouped) + { + return CGSize.Empty; + } + + // Currently we explicitly measure all of the headers/footers + // Long-term, we might want to look at performance hints (similar to ItemSizingStrategy) for + // headers/footers (if the dev knows for sure they'll all the be the same size) + + var cell = GetViewForSupplementaryElement(collectionView, UICollectionElementKindSectionKey.Header, + NSIndexPath.FromItemSection(0, section)) as ItemsViewCell; + + return cell.Measure(); + } + + internal CGSize GetReferenceSizeForFooter(UICollectionView collectionView, UICollectionViewLayout layout, nint section) + { + if (!_isGrouped) + { + return CGSize.Empty; + } + + var cell = GetViewForSupplementaryElement(collectionView, UICollectionElementKindSectionKey.Footer, + NSIndexPath.FromItemSection(0, section)) as ItemsViewCell; + + return cell.Measure(); + } + } +} \ No newline at end of file diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/GroupableItemsViewRenderer.cs b/Xamarin.Forms.Platform.iOS/CollectionView/GroupableItemsViewRenderer.cs new file mode 100644 index 0000000..c6ad925 --- /dev/null +++ b/Xamarin.Forms.Platform.iOS/CollectionView/GroupableItemsViewRenderer.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; + +namespace Xamarin.Forms.Platform.iOS +{ + public class GroupableItemsViewRenderer : SelectableItemsViewRenderer + { + GroupableItemsView GroupableItemsView => (GroupableItemsView)Element; + GroupableItemsViewController GroupableItemsViewController => (GroupableItemsViewController)ItemsViewController; + + protected override ItemsViewController CreateController(ItemsView itemsView, ItemsViewLayout layout) + { + return new GroupableItemsViewController(itemsView as GroupableItemsView, layout); + } + + protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs changedProperty) + { + base.OnElementPropertyChanged(sender, changedProperty); + + if (changedProperty.Is(GroupableItemsView.IsGroupedProperty)) + { + GroupableItemsViewController?.UpdateItemsSource(); + } + } + } +} \ No newline at end of file diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/HorizontalDefaultSupplementalView.cs b/Xamarin.Forms.Platform.iOS/CollectionView/HorizontalDefaultSupplementalView.cs new file mode 100644 index 0000000..a77ab1a --- /dev/null +++ b/Xamarin.Forms.Platform.iOS/CollectionView/HorizontalDefaultSupplementalView.cs @@ -0,0 +1,31 @@ +using CoreGraphics; +using Foundation; +using UIKit; + +namespace Xamarin.Forms.Platform.iOS +{ + internal sealed class HorizontalDefaultSupplementalView : DefaultCell + { + public static NSString ReuseId = new NSString("Xamarin.Forms.Platform.iOS.HorizontalDefaultSupplementalView"); + + [Export("initWithFrame:")] + public HorizontalDefaultSupplementalView(CGRect frame) : base(frame) + { + Label.Font = UIFont.PreferredHeadline; + + Constraint = Label.HeightAnchor.ConstraintEqualTo(Frame.Height); + Constraint.Active = true; + } + + public override void ConstrainTo(CGSize constraint) + { + Constraint.Constant = constraint.Height; + } + + public override CGSize Measure() + { + return new CGSize(Label.IntrinsicContentSize.Width, Constraint.Constant); + } + } + +} \ No newline at end of file diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/HorizontalTemplatedHeaderView.cs b/Xamarin.Forms.Platform.iOS/CollectionView/HorizontalTemplatedHeaderView.cs new file mode 100644 index 0000000..3069ae4 --- /dev/null +++ b/Xamarin.Forms.Platform.iOS/CollectionView/HorizontalTemplatedHeaderView.cs @@ -0,0 +1,32 @@ +using CoreGraphics; +using Foundation; + +namespace Xamarin.Forms.Platform.iOS +{ + public class HorizontalTemplatedSupplementalView : TemplatedCell + { + public static NSString ReuseId = new NSString("Xamarin.Forms.Platform.iOS.HorizontalTemplatedSupplementalView"); + + [Export("initWithFrame:")] + public HorizontalTemplatedSupplementalView(CGRect frame) : base(frame) + { + } + + public override CGSize Measure() + { + var measure = VisualElementRenderer.Element.Measure(double.PositiveInfinity, + ConstrainedDimension, MeasureFlags.IncludeMargins); + + var width = VisualElementRenderer.Element.Width > 0 + ? VisualElementRenderer.Element.Width : measure.Request.Width; + + return new CGSize(width, ConstrainedDimension); + } + + public override void ConstrainTo(CGSize constraint) + { + ConstrainedDimension = constraint.Height; + Layout(constraint); + } + } +} \ No newline at end of file diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/IItemsViewSource.cs b/Xamarin.Forms.Platform.iOS/CollectionView/IItemsViewSource.cs index 6ebbd52..0adaa32 100644 --- a/Xamarin.Forms.Platform.iOS/CollectionView/IItemsViewSource.cs +++ b/Xamarin.Forms.Platform.iOS/CollectionView/IItemsViewSource.cs @@ -2,9 +2,13 @@ namespace Xamarin.Forms.Platform.iOS { - internal interface IItemsViewSource : IDisposable + public interface IItemsViewSource : IDisposable { - int Count { get; } - object this[int index] { get; } + int ItemCount { get; } + int ItemCountInGroup(nint group); + int GroupCount { get; } + object this[Foundation.NSIndexPath indexPath] { get; } + object Group(Foundation.NSIndexPath indexPath); + Foundation.NSIndexPath GetIndexForItem(object item); } } \ No newline at end of file diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/ItemsSourceFactory.cs b/Xamarin.Forms.Platform.iOS/CollectionView/ItemsSourceFactory.cs index 7fc4d7e..e637cf3 100644 --- a/Xamarin.Forms.Platform.iOS/CollectionView/ItemsSourceFactory.cs +++ b/Xamarin.Forms.Platform.iOS/CollectionView/ItemsSourceFactory.cs @@ -24,5 +24,15 @@ namespace Xamarin.Forms.Platform.iOS return new ListSource(itemsSource); } + + public static IItemsViewSource CreateGrouped(IEnumerable itemsSource, UICollectionView collectionView) + { + if (itemsSource == null) + { + return new EmptySource(); + } + + return new ObservableGroupedSource(itemsSource, collectionView); + } } } \ No newline at end of file diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/ItemsViewController.cs b/Xamarin.Forms.Platform.iOS/CollectionView/ItemsViewController.cs index ebe06c5..2a1724a 100644 --- a/Xamarin.Forms.Platform.iOS/CollectionView/ItemsViewController.cs +++ b/Xamarin.Forms.Platform.iOS/CollectionView/ItemsViewController.cs @@ -9,12 +9,11 @@ namespace Xamarin.Forms.Platform.iOS // TODO hartez 2018/06/01 14:21:24 Add a method for updating the layout public class ItemsViewController : UICollectionViewController { - IItemsViewSource _itemsSource; - readonly ItemsView _itemsView; - ItemsViewLayout _layout; + protected IItemsViewSource ItemsSource { get; set; } + public ItemsView ItemsView { get; } + protected ItemsViewLayout ItemsViewLayout { get; set; } bool _initialConstraintsSet; - bool _safeForReload; - bool _wasEmpty; + bool _isEmpty; bool _currentBackgroundIsEmptyView; bool _disposed; @@ -26,35 +25,31 @@ namespace Xamarin.Forms.Platform.iOS public ItemsViewController(ItemsView itemsView, ItemsViewLayout layout) : base(layout) { - _itemsView = itemsView; - _itemsSource = ItemsSourceFactory.Create(_itemsView.ItemsSource, CollectionView); - - // If we already have data, the UICollectionView will have items and we'll be safe to call - // ReloadData if the ItemsSource changes in the future (see UpdateItemsSource for more). - _safeForReload = _itemsSource?.Count > 0; + ItemsView = itemsView; + ItemsSource = CreateItemsViewSource(); UpdateLayout(layout); } public void UpdateLayout(ItemsViewLayout layout) { - _layout = layout; - _layout.GetPrototype = GetPrototype; + ItemsViewLayout = layout; + ItemsViewLayout.GetPrototype = GetPrototype; // If we're updating from a previous layout, we should keep any settings for the SelectableItemsViewController around var selectableItemsViewController = Delegator?.SelectableItemsViewController; - Delegator = new UICollectionViewDelegator(_layout, this); + Delegator = new UICollectionViewDelegator(ItemsViewLayout, this); CollectionView.Delegate = Delegator; - if (CollectionView.CollectionViewLayout != _layout) + if (CollectionView.CollectionViewLayout != ItemsViewLayout) { // We're updating from a previous layout // Make sure the new layout is sized properly - _layout.ConstrainTo(CollectionView.Bounds.Size); + ItemsViewLayout.ConstrainTo(CollectionView.Bounds.Size); - CollectionView.SetCollectionViewLayout(_layout, false); + CollectionView.SetCollectionViewLayout(ItemsViewLayout, false); // Reload the data so the currently visible cells get laid out according to the new layout CollectionView.ReloadData(); @@ -67,7 +62,7 @@ namespace Xamarin.Forms.Platform.iOS { if (disposing) { - _itemsSource?.Dispose(); + ItemsSource?.Dispose(); } _disposed = true; @@ -78,7 +73,7 @@ namespace Xamarin.Forms.Platform.iOS public override UICollectionViewCell GetCell(UICollectionView collectionView, NSIndexPath indexPath) { - var cell = collectionView.DequeueReusableCell(DetermineCellReusedId(), indexPath) as UICollectionViewCell; + var cell = collectionView.DequeueReusableCell(DetermineCellReuseId(), indexPath) as UICollectionViewCell; switch (cell) { @@ -95,27 +90,30 @@ namespace Xamarin.Forms.Platform.iOS public override nint GetItemsCount(UICollectionView collectionView, nint section) { - var count = _itemsSource.Count; + var count = ItemsSource.ItemCountInGroup(section); - if (_wasEmpty && count > 0) - { - // We've moved from no items to having at least one item; it's likely that the layout needs to update - // its cell size/estimate - _layout?.SetNeedCellSizeUpdate(); - } + CheckForEmptySource(); + + return count; + } - _wasEmpty = count == 0; + void CheckForEmptySource() + { + var wasEmpty = _isEmpty; - UpdateEmptyViewVisibility(_wasEmpty); + _isEmpty = ItemsSource.ItemCount == 0; - return count; + if (wasEmpty != _isEmpty) + { + UpdateEmptyViewVisibility(_isEmpty); + } } public override void ViewDidLoad() { base.ViewDidLoad(); AutomaticallyAdjustsScrollViewInsets = false; - RegisterCells(); + RegisterViewTypes(); } public override void ViewWillLayoutSubviews() @@ -128,73 +126,36 @@ namespace Xamarin.Forms.Platform.iOS // are set up the first time this method is called. if (!_initialConstraintsSet) { - _layout.ConstrainTo(CollectionView.Bounds.Size); + ItemsViewLayout.ConstrainTo(CollectionView.Bounds.Size); _initialConstraintsSet = true; } } - public virtual void UpdateItemsSource() + protected virtual IItemsViewSource CreateItemsViewSource() { - if (_safeForReload) - { - UpdateItemsSourceAndReload(); - } - else - { - // Okay, thus far this UICollectionView has never had any items in it. At this point, if - // we set the ItemsSource and try to call ReloadData(), it'll crash. AFAICT this is a bug, but - // until it's fixed (or we can figure out another way to go from empty -> having items), we'll - // have to use this crazy workaround - EmptyCollectionViewReloadWorkaround(); - } + return ItemsSourceFactory.Create(ItemsView.ItemsSource, CollectionView); } - void UpdateItemsSourceAndReload() + public virtual void UpdateItemsSource() { - _itemsSource = ItemsSourceFactory.Create(_itemsView.ItemsSource, CollectionView); + ItemsSource = CreateItemsViewSource(); CollectionView.ReloadData(); CollectionView.CollectionViewLayout.InvalidateLayout(); } - void EmptyCollectionViewReloadWorkaround() + public override nint NumberOfSections(UICollectionView collectionView) { - var enumerator = _itemsView.ItemsSource.GetEnumerator(); - - if (!enumerator.MoveNext()) - { - // The source we're updating to is empty, so we can just update as normal; it won't crash - UpdateItemsSourceAndReload(); - } - else - { - // Grab the first item from the new ItemsSource and create a usable source for the UICollectionView - // from that - var firstItem = new List { enumerator.Current }; - _itemsSource = ItemsSourceFactory.Create(firstItem, CollectionView); - - // Insert that item into the UICollectionView - // TODO ezhart When we implement grouping, this will need to be the index of the first actual item - // Which might not be zero,zero if we have empty groups - var indexesToInsert = new NSIndexPath[1] { NSIndexPath.Create(0, 0) }; - - UIView.PerformWithoutAnimation(() => - { - CollectionView.InsertItems(indexesToInsert); - }); - - // Okay, from now on we can just call ReloadData and things will work fine - _safeForReload = true; - UpdateItemsSource(); - } + CheckForEmptySource(); + return ItemsSource.GroupCount; } protected virtual void UpdateDefaultCell(DefaultCell cell, NSIndexPath indexPath) { - cell.Label.Text = _itemsSource[indexPath.Row].ToString(); + cell.Label.Text = ItemsSource[indexPath].ToString(); if (cell is ItemsViewCell constrainedCell) { - _layout.PrepareCellForLayout(constrainedCell); + ItemsViewLayout.PrepareCellForLayout(constrainedCell); } } @@ -204,35 +165,27 @@ namespace Xamarin.Forms.Platform.iOS if (cell is ItemsViewCell constrainedCell) { - _layout.PrepareCellForLayout(constrainedCell); + ItemsViewLayout.PrepareCellForLayout(constrainedCell); } } public virtual NSIndexPath GetIndexForItem(object item) { - for (int n = 0; n < _itemsSource.Count; n++) - { - if (_itemsSource[n] == item) - { - return NSIndexPath.Create(0, n); - } - } - - return NSIndexPath.Create(-1, -1); + return ItemsSource.GetIndexForItem(item); } protected object GetItemAtIndex(NSIndexPath index) { - return _itemsSource[index.Row]; + return ItemsSource[index]; } void ApplyTemplateAndDataContext(TemplatedCell cell, NSIndexPath indexPath) { - var template = _itemsView.ItemTemplate; - var item = _itemsSource[indexPath.Row]; + var template = ItemsView.ItemTemplate; + var item = ItemsSource[indexPath]; // Run this through the extension method in case it's really a DataTemplateSelector - template = template.SelectDataTemplate(item, _itemsView); + template = template.SelectDataTemplate(item, ItemsView); // Create the content and renderer for the view and var view = template.CreateContent() as View; @@ -240,10 +193,10 @@ namespace Xamarin.Forms.Platform.iOS cell.SetRenderer(renderer); // Bind the view to the data item - view.BindingContext = _itemsSource[indexPath.Row]; + view.BindingContext = ItemsSource[indexPath]; // And make sure it's a "child" of the ItemsView - _itemsView.AddLogicalChild(view); + ItemsView.AddLogicalChild(view); cell.ContentSizeChanged += CellContentSizeChanged; } @@ -263,14 +216,14 @@ namespace Xamarin.Forms.Platform.iOS if (oldView != null) { oldView.BindingContext = null; - _itemsView.RemoveLogicalChild(oldView); + ItemsView.RemoveLogicalChild(oldView); } templatedCell.PrepareForRemoval(); } } - IVisualElementRenderer CreateRenderer(View view) + protected IVisualElementRenderer CreateRenderer(View view) { if (view == null) { @@ -283,33 +236,49 @@ namespace Xamarin.Forms.Platform.iOS return renderer; } - string DetermineCellReusedId() + string DetermineCellReuseId() { - if (_itemsView.ItemTemplate != null) + if (ItemsView.ItemTemplate != null) { - return _layout.ScrollDirection == UICollectionViewScrollDirection.Horizontal + return ItemsViewLayout.ScrollDirection == UICollectionViewScrollDirection.Horizontal ? HorizontalTemplatedCell.ReuseId : VerticalTemplatedCell.ReuseId; } - return _layout.ScrollDirection == UICollectionViewScrollDirection.Horizontal + return ItemsViewLayout.ScrollDirection == UICollectionViewScrollDirection.Horizontal ? HorizontalDefaultCell.ReuseId : VerticalDefaultCell.ReuseId; } UICollectionViewCell GetPrototype() { - if (_itemsSource.Count == 0) + if (ItemsSource.ItemCount == 0) { return null; } - // TODO hartez assuming this works, we'll need to evaluate using this nsindexpath (what about groups?) - var indexPath = NSIndexPath.Create(0, 0); + var group = 0; + + if (ItemsSource.GroupCount > 1) + { + // If we're in a grouping situation, then we need to make sure we find an actual data item + // to use for our prototype cell. It's possible that we have empty groups. + for (int n = 0; n < ItemsSource.GroupCount; n++) + { + if (ItemsSource.ItemCountInGroup(n) > 0) + { + group = n; + break; + } + } + } + + var indexPath = NSIndexPath.Create(group, 0); + return GetCell(CollectionView, indexPath); } - void RegisterCells() + protected virtual void RegisterViewTypes() { CollectionView.RegisterClassForCell(typeof(HorizontalDefaultCell), HorizontalDefaultCell.ReuseId); CollectionView.RegisterClassForCell(typeof(VerticalDefaultCell), VerticalDefaultCell.ReuseId); @@ -321,7 +290,7 @@ namespace Xamarin.Forms.Platform.iOS internal void UpdateEmptyView() { // Is EmptyView set on the ItemsView? - var emptyView = _itemsView?.EmptyView; + var emptyView = ItemsView?.EmptyView; if (emptyView == null) { @@ -333,13 +302,13 @@ namespace Xamarin.Forms.Platform.iOS { // Create the native renderer for the EmptyView, and keep the actual Forms element (if any) // around for updating the layout later - var (NativeView, FormsElement) = RealizeEmptyView(emptyView, _itemsView.EmptyViewTemplate); + var (NativeView, FormsElement) = RealizeEmptyView(emptyView, ItemsView.EmptyViewTemplate); _emptyUIView = NativeView; _emptyViewFormsElement = FormsElement; } // If the empty view is being displayed, we might need to update it - UpdateEmptyViewVisibility(_itemsSource?.Count == 0); + UpdateEmptyViewVisibility(ItemsSource?.ItemCount == 0); } void UpdateEmptyViewVisibility(bool isEmpty) @@ -381,7 +350,7 @@ namespace Xamarin.Forms.Platform.iOS if (emptyViewTemplate != null) { // Run this through the extension method in case it's really a DataTemplateSelector - emptyViewTemplate = emptyViewTemplate.SelectDataTemplate(emptyView, _itemsView); + emptyViewTemplate = emptyViewTemplate.SelectDataTemplate(emptyView, ItemsView); // We have a template; turn it into a Forms view var templateElement = emptyViewTemplate.CreateContent() as View; diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/ItemsViewLayout.cs b/Xamarin.Forms.Platform.iOS/CollectionView/ItemsViewLayout.cs index 21f398a..57e8768 100644 --- a/Xamarin.Forms.Platform.iOS/CollectionView/ItemsViewLayout.cs +++ b/Xamarin.Forms.Platform.iOS/CollectionView/ItemsViewLayout.cs @@ -13,7 +13,6 @@ namespace Xamarin.Forms.Platform.iOS readonly ItemsLayout _itemsLayout; bool _determiningCellSize; bool _disposed; - bool _needCellSizeUpdate; protected ItemsViewLayout(ItemsLayout itemsLayout) { @@ -80,16 +79,6 @@ namespace Xamarin.Forms.Platform.iOS public abstract void ConstrainTo(CGSize size); - public virtual void WillDisplayCell(UICollectionView collectionView, UICollectionViewCell cell, NSIndexPath path) - { - if (_needCellSizeUpdate) - { - // Our cell size/estimate is out of date, probably because we moved from zero to one item; update it - _needCellSizeUpdate = false; - DetermineCellSize(); - } - } - public virtual UIEdgeInsets GetInsetForSection(UICollectionView collectionView, UICollectionViewLayout layout, nint section) { @@ -172,6 +161,19 @@ namespace Xamarin.Forms.Platform.iOS } } + if (Forms.IsiOS11OrNewer) + { + return base.ShouldInvalidateLayout(preferredAttributes, originalAttributes); + } + + // For iOS 10 and lower, we have to invalidate on header/footer changes here; otherwise, all of the + // headers and footers will draw on top of one another + if (preferredAttributes.RepresentedElementKind == UICollectionElementKindSectionKey.Header + || preferredAttributes.RepresentedElementKind == UICollectionElementKindSectionKey.Footer) + { + return true; + } + return base.ShouldInvalidateLayout(preferredAttributes, originalAttributes); } @@ -259,11 +261,6 @@ namespace Xamarin.Forms.Platform.iOS UpdateCellConstraints(); } - public void SetNeedCellSizeUpdate() - { - _needCellSizeUpdate = true; - } - public override CGPoint TargetContentOffset(CGPoint proposedContentOffset, CGPoint scrollingVelocity) { var snapPointsType = _itemsLayout.SnapPointsType; @@ -370,5 +367,58 @@ namespace Xamarin.Forms.Platform.iOS InvalidateLayout(); } + + public override UICollectionViewLayoutInvalidationContext GetInvalidationContext(UICollectionViewLayoutAttributes preferredAttributes, UICollectionViewLayoutAttributes originalAttributes) + { + if (Forms.IsiOS11OrNewer) + { + return base.GetInvalidationContext(preferredAttributes, originalAttributes); + } + + var indexPath = preferredAttributes.IndexPath; + + try + { + UICollectionViewLayoutInvalidationContext invalidationContext = + base.GetInvalidationContext(preferredAttributes, originalAttributes); + + // Ensure that if this invalidation was triggered by header/footer changes, the header/footer + // are being invalidated + if (preferredAttributes.RepresentedElementKind == UICollectionElementKindSectionKey.Header) + { + invalidationContext.InvalidateSupplementaryElements(UICollectionElementKindSectionKey.Header, + new[] { indexPath }); + } + else if (preferredAttributes.RepresentedElementKind == UICollectionElementKindSectionKey.Footer) + { + invalidationContext.InvalidateSupplementaryElements(UICollectionElementKindSectionKey.Footer, + new[] { indexPath }); + } + + return invalidationContext; + } + catch (MonoTouchException) + { + // This happens on iOS 10 if we have any empty groups in our ItemsSource. Catching here and + // returning a UICollectionViewFlowLayoutInvalidationContext means that the application does not + // crash, though any group headers/footers will initially draw in the wrong location. It's possible to + // work around this problem by forcing a full layout update after the headers/footers have been + // drawn in the wrong places + } + + return new UICollectionViewFlowLayoutInvalidationContext(); + } + + public override UICollectionViewLayoutAttributes LayoutAttributesForSupplementaryView(NSString kind, NSIndexPath indexPath) + { + if (Forms.IsiOS11OrNewer) + { + return base.LayoutAttributesForSupplementaryView(kind, indexPath); + } + + // iOS 10 and lower doesn't create these and will throw an exception in GetViewForSupplementaryElement + // without them, so we need to do it manually here + return UICollectionViewLayoutAttributes.CreateForSupplementaryView(kind, indexPath); + } } } diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/ListSource.cs b/Xamarin.Forms.Platform.iOS/CollectionView/ListSource.cs index 2d79d1a..901e486 100644 --- a/Xamarin.Forms.Platform.iOS/CollectionView/ListSource.cs +++ b/Xamarin.Forms.Platform.iOS/CollectionView/ListSource.cs @@ -1,5 +1,7 @@ -using System.Collections; +using System; +using System.Collections; using System.Collections.Generic; +using Foundation; namespace Xamarin.Forms.Platform.iOS { @@ -26,5 +28,50 @@ namespace Xamarin.Forms.Platform.iOS { } + + public object this[NSIndexPath indexPath] + { + get + { + if (indexPath.Section > 0) + { + throw new ArgumentOutOfRangeException(nameof(indexPath)); + } + + return this[indexPath.Row]; + } + } + + public int GroupCount => Count == 0 ? 0 : 1; + + public int ItemCount => Count; + + public NSIndexPath GetIndexForItem(object item) + { + for (int n = 0; n < Count; n++) + { + if (this[n] == item) + { + return NSIndexPath.Create(0, n); + } + } + + return NSIndexPath.Create(-1, -1); + } + + public object Group(NSIndexPath indexPath) + { + return null; + } + + public int ItemCountInGroup(nint group) + { + if (group > 0) + { + throw new ArgumentOutOfRangeException(nameof(group)); + } + + return Count; + } } } \ No newline at end of file diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/ObservableGroupedSource.cs b/Xamarin.Forms.Platform.iOS/CollectionView/ObservableGroupedSource.cs new file mode 100644 index 0000000..a4d7704 --- /dev/null +++ b/Xamarin.Forms.Platform.iOS/CollectionView/ObservableGroupedSource.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using Foundation; +using UIKit; + +namespace Xamarin.Forms.Platform.iOS +{ + internal class ObservableGroupedSource : IItemsViewSource + { + readonly UICollectionView _collectionView; + readonly IList _groupSource; + bool _disposed; + List _groups = new List(); + + public ObservableGroupedSource(IEnumerable groupSource, UICollectionView collectionView) + { + _collectionView = collectionView; + _groupSource = groupSource as IList ?? new ListSource(groupSource); + + if (_groupSource is INotifyCollectionChanged incc) + { + incc.CollectionChanged += CollectionChanged; + } + + ResetGroupTracking(); + } + + public object this[NSIndexPath indexPath] + { + get + { + var group = (IList)_groupSource[indexPath.Section]; + + if (group.Count == 0) + { + return null; + } + + return group[indexPath.Row]; + } + } + + public int GroupCount => _groupSource.Count; + + int IItemsViewSource.ItemCount + { + get + { + // TODO hartez We should probably cache this value + var total = 0; + + for (int n = 0; n < _groupSource.Count; n++) + { + var group = (IList)_groupSource[n]; + total += group.Count; + } + + return total; + } + } + + public NSIndexPath GetIndexForItem(object item) + { + for (int i = 0; i < _groupSource.Count; i++) + { + var group = (IList)_groupSource[i]; + + for (int j = 0; j < group.Count; j++) + { + if (group[j] == item) + { + return NSIndexPath.Create(i, j); + } + } + } + + return NSIndexPath.Create(-1, -1); + } + + public object Group(NSIndexPath indexPath) + { + return _groupSource[indexPath.Section]; + } + + public int ItemCountInGroup(nint group) + { + return ((IList)_groupSource[(int)group]).Count; + } + + public void Dispose() + { + Dispose(true); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (disposing) + { + ClearGroupTracking(); + if (_groupSource is INotifyCollectionChanged incc) + { + incc.CollectionChanged -= CollectionChanged; + } + } + } + + void ClearGroupTracking() + { + for (int n = _groups.Count - 1; n >= 0; n--) + { + _groups[n].Dispose(); + _groups.RemoveAt(n); + } + } + + void ResetGroupTracking() + { + ClearGroupTracking(); + + for (int n = 0; n < _groupSource.Count; n++) + { + if (_groupSource[n] is INotifyCollectionChanged incc && _groupSource[n] is IList list) + { + _groups.Add(new ObservableItemsSource(list, _collectionView, n)); + } + } + } + + void CollectionChanged(object sender, NotifyCollectionChangedEventArgs args) + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + Add(args); + break; + case NotifyCollectionChangedAction.Remove: + Remove(args); + break; + case NotifyCollectionChangedAction.Replace: + Replace(args); + break; + case NotifyCollectionChangedAction.Move: + Move(args); + break; + case NotifyCollectionChangedAction.Reset: + Reload(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + void Reload() + { + ResetGroupTracking(); + _collectionView.ReloadData(); + _collectionView.CollectionViewLayout.InvalidateLayout(); + } + + NSIndexSet CreateIndexSetFrom(int startIndex, int count) + { + return NSIndexSet.FromNSRange(new NSRange(startIndex, count)); + } + + void Add(NotifyCollectionChangedEventArgs args) + { + var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : _groupSource.IndexOf(args.NewItems[0]); + var count = args.NewItems.Count; + + // Adding a group will change the section index for all subsequent groups, so the easiest thing to do + // is to reset all the group tracking to get it up-to-date + ResetGroupTracking(); + + _collectionView.InsertSections(CreateIndexSetFrom(startIndex, count)); + } + + void Remove(NotifyCollectionChangedEventArgs args) + { + var startIndex = args.OldStartingIndex; + + if (startIndex < 0) + { + // INCC implementation isn't giving us enough information to know where the removed items were in the + // collection. So the best we can do is a ReloadData() + Reload(); + return; + } + + // If we have a start index, we can be more clever about removing the item(s) (and get the nifty animations) + var count = args.OldItems.Count; + + // Removing a group will change the section index for all subsequent groups, so the easiest thing to do + // is to reset all the group tracking to get it up-to-date + ResetGroupTracking(); + + _collectionView.DeleteSections(CreateIndexSetFrom(startIndex, count)); + } + + void Replace(NotifyCollectionChangedEventArgs args) + { + var newCount = args.NewItems.Count; + + if (newCount == args.OldItems.Count) + { + ResetGroupTracking(); + + var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : _groupSource.IndexOf(args.NewItems[0]); + + // We are replacing one set of items with a set of equal size; we can do a simple item range update + _collectionView.ReloadSections(CreateIndexSetFrom(startIndex, newCount)); + return; + } + + // The original and replacement sets are of unequal size; this means that everything currently in view will + // have to be updated. So we just have to use ReloadData and let the UICollectionView update everything + Reload(); + } + + void Move(NotifyCollectionChangedEventArgs args) + { + var count = args.NewItems.Count; + + ResetGroupTracking(); + + if (count == 1) + { + // For a single item, we can use MoveSection and get the animation + _collectionView.MoveSection(args.OldStartingIndex, args.NewStartingIndex); + return; + } + + var start = Math.Min(args.OldStartingIndex, args.NewStartingIndex); + var end = Math.Max(args.OldStartingIndex, args.NewStartingIndex) + count; + + _collectionView.ReloadSections(CreateIndexSetFrom(start, end)); + } + } + +} diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/ObservableItemsSource.cs b/Xamarin.Forms.Platform.iOS/CollectionView/ObservableItemsSource.cs index fb14d08..4f4979b 100644 --- a/Xamarin.Forms.Platform.iOS/CollectionView/ObservableItemsSource.cs +++ b/Xamarin.Forms.Platform.iOS/CollectionView/ObservableItemsSource.cs @@ -9,12 +9,18 @@ namespace Xamarin.Forms.Platform.iOS internal class ObservableItemsSource : IItemsViewSource { readonly UICollectionView _collectionView; + readonly bool _grouped; + readonly int _section; readonly IList _itemsSource; bool _disposed; - public ObservableItemsSource(IList itemSource, UICollectionView collectionView) + public ObservableItemsSource(IList itemSource, UICollectionView collectionView, int group = -1) { _collectionView = collectionView; + + _section = group < 0 ? 0 : group; + _grouped = group >= 0; + _itemsSource = itemSource; ((INotifyCollectionChanged)itemSource).CollectionChanged += CollectionChanged; @@ -42,6 +48,46 @@ namespace Xamarin.Forms.Platform.iOS } } + public int ItemCountInGroup(nint group) + { + return _itemsSource.Count; + } + + public object Group(NSIndexPath indexPath) + { + return null; + } + + public NSIndexPath GetIndexForItem(object item) + { + for (int n = 0; n < _itemsSource.Count; n++) + { + if (this[n] == item) + { + return NSIndexPath.Create(_section, n); + } + } + + return NSIndexPath.Create(-1, -1); + } + + public int GroupCount => _itemsSource.Count == 0 ? 0 : 1; + + public int ItemCount => _itemsSource.Count; + + public object this[NSIndexPath indexPath] + { + get + { + if (indexPath.Section != _section) + { + throw new ArgumentOutOfRangeException(nameof(indexPath)); + } + + return this[indexPath.Row]; + } + } + void CollectionChanged(object sender, NotifyCollectionChangedEventArgs args) { switch (args.Action) @@ -59,58 +105,26 @@ namespace Xamarin.Forms.Platform.iOS Move(args); break; case NotifyCollectionChangedAction.Reset: - _collectionView.ReloadData(); - _collectionView.CollectionViewLayout.InvalidateLayout(); + Reload(); break; default: throw new ArgumentOutOfRangeException(); } } - void Move(NotifyCollectionChangedEventArgs args) - { - var count = args.NewItems.Count; - - if (count == 1) - { - // For a single item, we can use MoveItem and get the animation - var oldPath = NSIndexPath.Create(0, args.OldStartingIndex); - var newPath = NSIndexPath.Create(0, args.NewStartingIndex); - - _collectionView.MoveItem(oldPath, newPath); - return; - } - - var start = Math.Min(args.OldStartingIndex, args.NewStartingIndex); - var end = Math.Max(args.OldStartingIndex, args.NewStartingIndex) + count; - _collectionView.ReloadItems(CreateIndexesFrom(start, end)); - } - - private void Replace(NotifyCollectionChangedEventArgs args) + void Reload() { - var newCount = args.NewItems.Count; - - if (newCount == args.OldItems.Count) - { - var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : _itemsSource.IndexOf(args.NewItems[0]); - - // We are replacing one set of items with a set of equal size; we can do a simple item range update - _collectionView.ReloadItems(CreateIndexesFrom(startIndex, newCount)); - return; - } - - // The original and replacement sets are of unequal size; this means that everything currently in view will - // have to be updated. So we just have to use ReloadData and let the UICollectionView update everything _collectionView.ReloadData(); + _collectionView.CollectionViewLayout.InvalidateLayout(); } - static NSIndexPath[] CreateIndexesFrom(int startIndex, int count) + NSIndexPath[] CreateIndexesFrom(int startIndex, int count) { var result = new NSIndexPath[count]; for (int n = 0; n < count; n++) { - result[n] = NSIndexPath.Create(0, startIndex + n); + result[n] = NSIndexPath.Create(_section, startIndex + n); } return result; @@ -121,7 +135,17 @@ namespace Xamarin.Forms.Platform.iOS var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : _itemsSource.IndexOf(args.NewItems[0]); var count = args.NewItems.Count; - _collectionView.InsertItems(CreateIndexesFrom(startIndex, count)); + _collectionView.PerformBatchUpdates(() => + { + if (!_grouped && _collectionView.NumberOfSections() != GroupCount) + { + // We had an empty non-grouped list, and now we're trying to add an item; + // we need to give it a section as well + _collectionView.InsertSections(new NSIndexSet(0)); + } + + _collectionView.InsertItems(CreateIndexesFrom(startIndex, count)); + }, null); } void Remove(NotifyCollectionChangedEventArgs args) @@ -132,13 +156,61 @@ namespace Xamarin.Forms.Platform.iOS { // INCC implementation isn't giving us enough information to know where the removed items were in the // collection. So the best we can do is a ReloadData() - _collectionView.ReloadData(); + Reload(); return; } - + // If we have a start index, we can be more clever about removing the item(s) (and get the nifty animations) var count = args.OldItems.Count; - _collectionView.DeleteItems(CreateIndexesFrom(startIndex, count)); + + _collectionView.PerformBatchUpdates(() => + { + _collectionView.DeleteItems(CreateIndexesFrom(startIndex, count)); + + if (!_grouped && _collectionView.NumberOfSections() != GroupCount) + { + // We had a non-grouped list with items, and we're removing the last one; + // we also need to remove the group it was in + _collectionView.DeleteSections(new NSIndexSet(0)); + } + }, null); + } + + void Replace(NotifyCollectionChangedEventArgs args) + { + var newCount = args.NewItems.Count; + + if (newCount == args.OldItems.Count) + { + var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : _itemsSource.IndexOf(args.NewItems[0]); + + // We are replacing one set of items with a set of equal size; we can do a simple item range update + _collectionView.ReloadItems(CreateIndexesFrom(startIndex, newCount)); + return; + } + + // The original and replacement sets are of unequal size; this means that everything currently in view will + // have to be updated. So we just have to use ReloadData and let the UICollectionView update everything + Reload(); + } + + void Move(NotifyCollectionChangedEventArgs args) + { + var count = args.NewItems.Count; + + if (count == 1) + { + // For a single item, we can use MoveItem and get the animation + var oldPath = NSIndexPath.Create(_section, args.OldStartingIndex); + var newPath = NSIndexPath.Create(_section, args.NewStartingIndex); + + _collectionView.MoveItem(oldPath, newPath); + return; + } + + var start = Math.Min(args.OldStartingIndex, args.NewStartingIndex); + var end = Math.Max(args.OldStartingIndex, args.NewStartingIndex) + count; + _collectionView.ReloadItems(CreateIndexesFrom(start, end)); } } -} \ No newline at end of file +} diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/SelectableItemsViewController.cs b/Xamarin.Forms.Platform.iOS/CollectionView/SelectableItemsViewController.cs index ae4e6ee..7d366c0 100644 --- a/Xamarin.Forms.Platform.iOS/CollectionView/SelectableItemsViewController.cs +++ b/Xamarin.Forms.Platform.iOS/CollectionView/SelectableItemsViewController.cs @@ -7,12 +7,11 @@ namespace Xamarin.Forms.Platform.iOS { public class SelectableItemsViewController : ItemsViewController { - protected readonly SelectableItemsView SelectableItemsView; + SelectableItemsView SelectableItemsView => (SelectableItemsView)ItemsView; public SelectableItemsViewController(SelectableItemsView selectableItemsView, ItemsViewLayout layout) : base(selectableItemsView, layout) { - SelectableItemsView = selectableItemsView; } // _Only_ called if the user initiates the selection change; will not be called for programmatic selection diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/UICollectionViewDelegator.cs b/Xamarin.Forms.Platform.iOS/CollectionView/UICollectionViewDelegator.cs index d654792..bfa45c8 100644 --- a/Xamarin.Forms.Platform.iOS/CollectionView/UICollectionViewDelegator.cs +++ b/Xamarin.Forms.Platform.iOS/CollectionView/UICollectionViewDelegator.cs @@ -1,4 +1,5 @@ using System; +using CoreGraphics; using Foundation; using UIKit; @@ -13,15 +14,15 @@ namespace Xamarin.Forms.Platform.iOS get => ItemsViewController as SelectableItemsViewController; } - public UICollectionViewDelegator(ItemsViewLayout itemsViewLayout, ItemsViewController itemsViewController) + public GroupableItemsViewController GroupableItemsViewController { - ItemsViewLayout = itemsViewLayout; - ItemsViewController = itemsViewController; + get => ItemsViewController as GroupableItemsViewController; } - public override void WillDisplayCell(UICollectionView collectionView, UICollectionViewCell cell, NSIndexPath path) + public UICollectionViewDelegator(ItemsViewLayout itemsViewLayout, ItemsViewController itemsViewController) { - ItemsViewLayout?.WillDisplayCell(collectionView, cell, path); + ItemsViewLayout = itemsViewLayout; + ItemsViewController = itemsViewController; } public override UIEdgeInsets GetInsetForSection(UICollectionView collectionView, UICollectionViewLayout layout, @@ -71,5 +72,25 @@ namespace Xamarin.Forms.Platform.iOS { ItemsViewController.PrepareCellForRemoval(cell); } + + public override CGSize GetReferenceSizeForHeader(UICollectionView collectionView, UICollectionViewLayout layout, nint section) + { + if (GroupableItemsViewController == null) + { + return CGSize.Empty; + } + + return GroupableItemsViewController.GetReferenceSizeForHeader(collectionView, layout, section); + } + + public override CGSize GetReferenceSizeForFooter(UICollectionView collectionView, UICollectionViewLayout layout, nint section) + { + if (GroupableItemsViewController == null) + { + return CGSize.Empty; + } + + return GroupableItemsViewController.GetReferenceSizeForFooter(collectionView, layout, section); + } } } \ No newline at end of file diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/VerticalDefaultSupplementalView.cs b/Xamarin.Forms.Platform.iOS/CollectionView/VerticalDefaultSupplementalView.cs new file mode 100644 index 0000000..5bcd4c0 --- /dev/null +++ b/Xamarin.Forms.Platform.iOS/CollectionView/VerticalDefaultSupplementalView.cs @@ -0,0 +1,30 @@ +using CoreGraphics; +using Foundation; +using UIKit; + +namespace Xamarin.Forms.Platform.iOS +{ + internal sealed class VerticalDefaultSupplementalView : DefaultCell + { + public static NSString ReuseId = new NSString("Xamarin.Forms.Platform.iOS.VerticalDefaultSupplementalView"); + + [Export("initWithFrame:")] + public VerticalDefaultSupplementalView(CGRect frame) : base(frame) + { + Label.Font = UIFont.PreferredHeadline; + + Constraint = Label.WidthAnchor.ConstraintEqualTo(Frame.Width); + Constraint.Active = true; + } + + public override void ConstrainTo(CGSize constraint) + { + Constraint.Constant = constraint.Width; + } + + public override CGSize Measure() + { + return new CGSize(Constraint.Constant, Label.IntrinsicContentSize.Height); + } + } +} \ No newline at end of file diff --git a/Xamarin.Forms.Platform.iOS/CollectionView/VerticalTemplatedHeaderView.cs b/Xamarin.Forms.Platform.iOS/CollectionView/VerticalTemplatedHeaderView.cs new file mode 100644 index 0000000..99fe13b --- /dev/null +++ b/Xamarin.Forms.Platform.iOS/CollectionView/VerticalTemplatedHeaderView.cs @@ -0,0 +1,32 @@ +using CoreGraphics; +using Foundation; + +namespace Xamarin.Forms.Platform.iOS +{ + public class VerticalTemplatedSupplementalView : TemplatedCell + { + public static NSString ReuseId = new NSString("Xamarin.Forms.Platform.iOS.VerticalTemplatedSupplementalView"); + + [Export("initWithFrame:")] + public VerticalTemplatedSupplementalView(CGRect frame) : base(frame) + { + } + + public override CGSize Measure() + { + var measure = VisualElementRenderer.Element.Measure(ConstrainedDimension, + double.PositiveInfinity, MeasureFlags.IncludeMargins); + + var height = VisualElementRenderer.Element.Height > 0 + ? VisualElementRenderer.Element.Height : measure.Request.Height; + + return new CGSize(ConstrainedDimension, height); + } + + public override void ConstrainTo(CGSize constraint) + { + ConstrainedDimension = constraint.Width; + Layout(constraint); + } + } +} \ No newline at end of file diff --git a/Xamarin.Forms.Platform.iOS/Xamarin.Forms.Platform.iOS.csproj b/Xamarin.Forms.Platform.iOS/Xamarin.Forms.Platform.iOS.csproj index 6ca56bf..512cc6b 100644 --- a/Xamarin.Forms.Platform.iOS/Xamarin.Forms.Platform.iOS.csproj +++ b/Xamarin.Forms.Platform.iOS/Xamarin.Forms.Platform.iOS.csproj @@ -111,12 +111,17 @@ + + + + + @@ -131,7 +136,9 @@ + + -- 2.7.4