Handle "groups but no actual cells" cases for reloading UICollectionView (#9911) Fixes #9580 Fixes #9686

* Handle "groups but no actual cells" case for reloading UICollectionView
Fixes #9580

* Add check for reload when adding/removing groups
Fixes #9686

* Prevent prevent reloads during batch updates
Fixes #9686

* Removed obsolete TODO comment

* Fix test (missing success condition)

* Fix possible tap speed issue in UI test

* Switch await loop to SemaphoreSlim

* Fix semaphore deadlocks

* Fix incorrect check for semaphore release

* If this fixes it, I'm gonna be soooooo mad...

* When in doubt, just use a button
This commit is contained in:
E.Z. Hart 2020-04-13 04:43:27 -06:00 коммит произвёл GitHub
Родитель c06932d499
Коммит 7cfe6155e7
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
5 изменённых файлов: 440 добавлений и 94 удалений

Просмотреть файл

@ -0,0 +1,88 @@
using System.Collections.ObjectModel;
using Xamarin.Forms.CustomAttributes;
#if UITEST
using Xamarin.UITest;
using NUnit.Framework;
using Xamarin.Forms.Core.UITests;
#endif
namespace Xamarin.Forms.Controls.Issues
{
[Issue(IssueTracker.Github, 9580, "[Bug] CollectionView - iOS - Crash when adding first item to empty item group",
PlatformAffected.iOS)]
public class Issue9580 : TestContentPage
{
const string Success = "Success";
const string Test9580 = "9580";
protected override void Init()
{
var layout = new StackLayout();
var cv = new CollectionView
{
IsGrouped = true
};
var groups = new ObservableCollection<_9580Group>()
{
new _9580Group() { Name = "One" }, new _9580Group(){ Name = "Two" }, new _9580Group(){ Name = "Three" },
new _9580Group() { Name = "Four" }, new _9580Group(){ Name = "Five" }, new _9580Group(){ Name = "Six" }
};
cv.ItemTemplate = new DataTemplate(() => {
var label = new Label() { Margin = new Thickness(5, 0, 0, 0) };
label.SetBinding(Label.TextProperty, new Binding("Text"));
return label;
});
cv.GroupHeaderTemplate = new DataTemplate(() => {
var label = new Label();
label.SetBinding(Label.TextProperty, new Binding("Name"));
return label;
});
cv.ItemsSource = groups;
var instructions = new Label { Text = $"Tap the '{Test9580}' button. The application doesn't crash, this test has passed." };
var result = new Label { };
var button = new Button { Text = Test9580 };
button.Clicked += (sender, args) =>
{
groups[0].Add(new _9580Item { Text = "An Item" });
result.Text = Success;
};
layout.Children.Add(instructions);
layout.Children.Add(result);
layout.Children.Add(button);
layout.Children.Add(cv);
Content = layout;
}
class _9580Item
{
public string Text { get; set; }
}
class _9580Group : ObservableCollection<_9580Item>
{
public string Name { get; set; }
}
#if UITEST
[Category(UITestCategories.CollectionView)]
[Test]
public void AllEmptyGroupsShouldNotCrashOnItemInsert()
{
RunningApp.WaitForElement(Test9580);
RunningApp.Tap(Test9580);
RunningApp.WaitForElement(Success);
}
#endif
}
}

Просмотреть файл

@ -0,0 +1,191 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Input;
using Xamarin.Forms.CustomAttributes;
#if UITEST
using Xamarin.UITest;
using NUnit.Framework;
using Xamarin.Forms.Core.UITests;
#endif
namespace Xamarin.Forms.Controls.Issues
{
[Issue(IssueTracker.Github, 9686, "[Bug, CollectionView,iOS] Foundation.Monotouch Exception in Grouped CollectionView",
PlatformAffected.iOS)]
public class Issue9686 : TestContentPage
{
const string Success = "Success";
const string Run = "Run";
protected override void Init()
{
var layout = new StackLayout();
var cv = new CollectionView
{
IsGrouped = true
};
BindingContext = new _9686ViewModel();
cv.ItemTemplate = new DataTemplate(() => {
var label = new Label() { Margin = new Thickness(5, 0, 0, 0) };
label.SetBinding(Label.TextProperty, new Binding("Name"));
return label;
});
cv.GroupHeaderTemplate = new DataTemplate(() => {
var label = new Label();
label.SetBinding(Label.TextProperty, new Binding("GroupName"));
return label;
});
cv.SetBinding(ItemsView.ItemsSourceProperty, new Binding(nameof(_9686ViewModel.Groups)));
var instructions = new Label { Text = $"Tap the button once, then again. The application doesn't crash, this test has passed." };
var result = new Label { };
var button = new Button { Text = Run, AutomationId = Run };
button.Command = ((_9686ViewModel)BindingContext).ShowOrHideCommand;
button.CommandParameter = ((_9686ViewModel)BindingContext).Groups[0];
layout.Children.Add(instructions);
layout.Children.Add(button);
layout.Children.Add(result);
layout.Children.Add(cv);
Content = layout;
}
public class _9686Item
{
public string Name { get; set; }
}
public class _9686Group : List<_9686Item>
{
public string GroupName { get; set; }
public _9686Group(string groupName, ObservableCollection<_9686Item> items) : base(items)
{
GroupName = groupName;
}
}
public class _9686ViewModel
{
public ICommand ShowOrHideCommand { get; set; }
public ObservableCollection<_9686Group> Groups { get; set; }
public _9686Group PreviousGroup { get; set; }
public _9686ViewModel()
{
ShowOrHideCommand = new Command<_9686Group>((group) => ShowOrHideItems(group));
Groups = new ObservableCollection<_9686Group>
{
new _9686Group("Group 1", new ObservableCollection<_9686Item>()),
new _9686Group("Group 2", new ObservableCollection<_9686Item>()),
new _9686Group("Group 3", new ObservableCollection<_9686Item>()),
new _9686Group("Group 4", new ObservableCollection<_9686Item>()),
new _9686Group("Group 5", new ObservableCollection<_9686Item>()),
new _9686Group("Group 6", new ObservableCollection<_9686Item>())
};
}
void ShowOrHideItems(_9686Group group)
{
if (PreviousGroup == group)
{
if (PreviousGroup.Any())
{
PreviousGroup.Clear();
}
else
{
PreviousGroup.AddRange(new List<_9686Item>
{
new _9686Item
{
Name = "Item 1"
},
new _9686Item
{
Name = "Item 2"
},
new _9686Item
{
Name = "Item 3"
},
new _9686Item
{
Name = "Item 4"
},
});
}
UpdateCollection(PreviousGroup);
}
else
{
if (PreviousGroup != null)
{
PreviousGroup.Clear();
UpdateCollection(PreviousGroup);
}
group.AddRange(new List<_9686Item>
{
new _9686Item
{
Name = "Item 1"
},
new _9686Item
{
Name = "Item 2"
},
new _9686Item
{
Name = "Item 3"
},
new _9686Item
{
Name = "Item 4"
},
});
UpdateCollection(group);
PreviousGroup = group;
}
}
void UpdateCollection(_9686Group group)
{
var index = Groups.IndexOf(group);
Groups.Remove(group);
if (group.Count == 0)
{
group.GroupName = Success;
}
Groups.Insert(index, group);
}
}
#if UITEST
[Category(UITestCategories.CollectionView)]
[Test]
public void AddRemoveEmptyGroupsShouldNotCrashOnInsert()
{
RunningApp.WaitForElement(Run);
RunningApp.Tap(Run);
RunningApp.WaitForElement("Item 1");
RunningApp.Tap(Run);
RunningApp.WaitForElement(Success);
}
#endif
}
}

Просмотреть файл

@ -37,6 +37,7 @@
<DependentUpon>Issue8902.xaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Include="$(MSBuildThisFileDirectory)Issue9580.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Issue9682.xaml.cs">
<DependentUpon>Issue9682.xaml</DependentUpon>
<SubType>Code</SubType>
@ -216,6 +217,7 @@
<DependentUpon>Issue9783.xaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Include="$(MSBuildThisFileDirectory)Issue9686.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Issue9694.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Issue9771.xaml.cs">
<SubType>Code</SubType>

Просмотреть файл

@ -2,6 +2,8 @@
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Threading;
using System.Threading.Tasks;
using Foundation;
using UIKit;
@ -13,6 +15,7 @@ namespace Xamarin.Forms.Platform.iOS
readonly UICollectionViewController _collectionViewController;
readonly IList _groupSource;
bool _disposed;
SemaphoreSlim _batchUpdating = new SemaphoreSlim(1, 1);
List<ObservableItemsSource> _groups = new List<ObservableItemsSource>();
public ObservableGroupedSource(IEnumerable groupSource, UICollectionViewController collectionViewController)
@ -39,7 +42,7 @@ namespace Xamarin.Forms.Platform.iOS
public int GroupCount => _groupSource.Count;
int IItemsViewSource.ItemCount
public int ItemCount
{
get
{
@ -127,47 +130,53 @@ namespace Xamarin.Forms.Platform.iOS
}
}
void CollectionChanged(object sender, NotifyCollectionChangedEventArgs args)
async void CollectionChanged(object sender, NotifyCollectionChangedEventArgs args)
{
if (Device.IsInvokeRequired)
{
Device.BeginInvokeOnMainThread(() => CollectionChanged(args));
await Device.InvokeOnMainThreadAsync(async () => await CollectionChanged(args));
}
else
{
CollectionChanged(args);
await CollectionChanged(args);
}
}
void CollectionChanged(NotifyCollectionChangedEventArgs args)
async Task CollectionChanged(NotifyCollectionChangedEventArgs args)
{
switch (args.Action)
{
case NotifyCollectionChangedAction.Add:
Add(args);
await Add(args);
break;
case NotifyCollectionChangedAction.Remove:
Remove(args);
await Remove(args);
break;
case NotifyCollectionChangedAction.Replace:
Replace(args);
await Replace(args);
break;
case NotifyCollectionChangedAction.Move:
Move(args);
break;
case NotifyCollectionChangedAction.Reset:
Reload();
await Reload();
break;
default:
throw new ArgumentOutOfRangeException();
}
}
void Reload()
async Task Reload()
{
ResetGroupTracking();
await _batchUpdating.WaitAsync();
_collectionView.ReloadData();
_collectionView.CollectionViewLayout.InvalidateLayout();
_batchUpdating.Release();
}
NSIndexSet CreateIndexSetFrom(int startIndex, int count)
@ -182,8 +191,14 @@ namespace Xamarin.Forms.Platform.iOS
return !_collectionViewController.IsViewLoaded || _collectionViewController.View.Window == null;
}
void Add(NotifyCollectionChangedEventArgs args)
async Task Add(NotifyCollectionChangedEventArgs args)
{
if (ReloadRequired())
{
await Reload();
return;
}
var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : _groupSource.IndexOf(args.NewItems[0]);
var count = args.NewItems.Count;
@ -191,53 +206,40 @@ namespace Xamarin.Forms.Platform.iOS
// is to reset all the group tracking to get it up-to-date
ResetGroupTracking();
if (NotLoadedYet())
{
_collectionView.ReloadData();
return;
// Queue up the updates to the UICollectionView
BatchUpdate(() => _collectionView.InsertSections(CreateIndexSetFrom(startIndex, count)));
}
if (_collectionView.NumberOfSections() == 0)
{
// Okay, we're going from completely empty to more than 0 items; there's an iOS bug which apparently
// will just crash if we call InsertItems here, so we have to do ReloadData.
_collectionView.ReloadData();
return;
}
_collectionView.InsertSections(CreateIndexSetFrom(startIndex, count));
}
void Remove(NotifyCollectionChangedEventArgs args)
async Task 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();
// collection. So the best we can do is a complete reload
await 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;
if (ReloadRequired())
{
await Reload();
return;
}
// 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();
if (NotLoadedYet())
{
_collectionView.ReloadData();
}
else
{
_collectionView.DeleteSections(CreateIndexSetFrom(startIndex, count));
}
// Since 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;
// Queue up the updates to the UICollectionView
BatchUpdate(() => _collectionView.DeleteSections(CreateIndexSetFrom(startIndex, count)));
}
void Replace(NotifyCollectionChangedEventArgs args)
async Task Replace(NotifyCollectionChangedEventArgs args)
{
var newCount = args.NewItems.Count;
@ -254,7 +256,7 @@ namespace Xamarin.Forms.Platform.iOS
// 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();
await Reload();
}
void Move(NotifyCollectionChangedEventArgs args)
@ -339,6 +341,36 @@ namespace Xamarin.Forms.Platform.iOS
return -1;
}
bool ReloadRequired()
{
// If the UICollectionView has never been loaded, or doesn't yet have any sections, or has no actual
// cells (just supplementary views like Header/Footer), any insert/delete operations are gonna crash
// hard. We'll need to reload the data instead.
return NotLoadedYet()
|| _collectionView.NumberOfSections() == 0
|| _collectionView.VisibleCells.Length == 0;
}
void BatchUpdate(Action update)
{
_collectionView.PerformBatchUpdates(() =>
{
if (_batchUpdating.CurrentCount > 0)
{
_batchUpdating.Wait();
}
update();
},
(_) =>
{
if (_batchUpdating.CurrentCount == 0)
{
_batchUpdating.Release();
}
});
}
}
}

Просмотреть файл

@ -1,6 +1,8 @@
using System;
using System.Collections;
using System.Collections.Specialized;
using System.Threading;
using System.Threading.Tasks;
using Foundation;
using UIKit;
@ -14,6 +16,7 @@ namespace Xamarin.Forms.Platform.iOS
readonly int _section;
readonly IEnumerable _itemsSource;
bool _disposed;
SemaphoreSlim _batchUpdating = new SemaphoreSlim(1, 1);
public ObservableItemsSource(IEnumerable itemSource, UICollectionViewController collectionViewController, int group = -1)
{
@ -94,36 +97,36 @@ namespace Xamarin.Forms.Platform.iOS
}
}
void CollectionChanged(object sender, NotifyCollectionChangedEventArgs args)
async void CollectionChanged(object sender, NotifyCollectionChangedEventArgs args)
{
if (Device.IsInvokeRequired)
{
Device.BeginInvokeOnMainThread(() => CollectionChanged(args));
await Device.InvokeOnMainThreadAsync(async () => await CollectionChanged(args));
}
else
{
CollectionChanged(args);
await CollectionChanged(args);
}
}
void CollectionChanged(NotifyCollectionChangedEventArgs args)
async Task CollectionChanged(NotifyCollectionChangedEventArgs args)
{
switch (args.Action)
{
case NotifyCollectionChangedAction.Add:
Add(args);
await Add(args);
break;
case NotifyCollectionChangedAction.Remove:
Remove(args);
await Remove(args);
break;
case NotifyCollectionChangedAction.Replace:
Replace(args);
await Replace(args);
break;
case NotifyCollectionChangedAction.Move:
Move(args);
break;
case NotifyCollectionChangedAction.Reset:
Reload();
await Reload();
break;
default:
throw new ArgumentOutOfRangeException();
@ -132,11 +135,15 @@ namespace Xamarin.Forms.Platform.iOS
CollectionItemsSourceChanged?.Invoke(this, args);
}
void Reload()
async Task Reload()
{
await _batchUpdating.WaitAsync();
_collectionView.ReloadData();
_collectionView.CollectionViewLayout.InvalidateLayout();
Count = ItemsCount();
_batchUpdating.Release();
}
NSIndexPath[] CreateIndexesFrom(int startIndex, int count)
@ -151,70 +158,49 @@ namespace Xamarin.Forms.Platform.iOS
return result;
}
bool NotLoadedYet()
async Task Add(NotifyCollectionChangedEventArgs args)
{
// If the UICollectionView hasn't actually been loaded, then calling InsertItems or DeleteItems is
// going to crash or get in an unusable state; instead, ReloadData should be used
return !_collectionViewController.IsViewLoaded || _collectionViewController.View.Window == null;
}
void Add(NotifyCollectionChangedEventArgs args)
if (ReloadRequired())
{
if (NotLoadedYet())
{
_collectionView.ReloadData();
await Reload();
return;
}
var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : IndexOf(args.NewItems[0]);
var count = args.NewItems.Count;
if (!_grouped && _collectionView.NumberOfItemsInSection(_section) == 0)
{
// Okay, we're going from completely empty to more than 0 items; there's an iOS bug which apparently
// will just crash if we call InsertItems here, so we have to do ReloadData.
_collectionView.ReloadData();
Count += count;
return;
var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : IndexOf(args.NewItems[0]);
// Queue up the updates to the UICollectionView
BatchUpdate(() => _collectionView.InsertItems(CreateIndexesFrom(startIndex, count)));
}
_collectionView.PerformBatchUpdates(() =>
async Task Remove(NotifyCollectionChangedEventArgs args)
{
var indexes = CreateIndexesFrom(startIndex, count);
_collectionView.InsertItems(indexes);
Count += count;
}, null);
}
void Remove(NotifyCollectionChangedEventArgs args)
{
if (NotLoadedYet())
{
_collectionView.ReloadData();
return;
}
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();
await Reload();
return;
}
if (ReloadRequired())
{
await 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.PerformBatchUpdates(() =>
{
_collectionView.DeleteItems(CreateIndexesFrom(startIndex, count));
Count -= count;
}, null);
// Queue up the updates to the UICollectionView
BatchUpdate(() => _collectionView.DeleteItems(CreateIndexesFrom(startIndex, count)));
}
void Replace(NotifyCollectionChangedEventArgs args)
async Task Replace(NotifyCollectionChangedEventArgs args)
{
var newCount = args.NewItems.Count;
@ -229,7 +215,7 @@ namespace Xamarin.Forms.Platform.iOS
// 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();
await Reload();
}
void Move(NotifyCollectionChangedEventArgs args)
@ -293,5 +279,52 @@ namespace Xamarin.Forms.Platform.iOS
return -1;
}
bool NotLoadedYet()
{
// If the UICollectionView hasn't actually been loaded, then calling InsertItems or DeleteItems is
// going to crash or get in an unusable state; instead, ReloadData should be used
return !_collectionViewController.IsViewLoaded || _collectionViewController.View.Window == null;
}
bool ReloadRequired()
{
if (NotLoadedYet())
{
return true;
}
// UICollectionView doesn't like when we insert items into a completely empty un-grouped CV,
// and it doesn't like when we insert items into a grouped CV with no actual cells (just empty groups)
// In those circumstances, we just need to ask it to reload the data so it can get its internal
// accounting in order
if (!_grouped && _collectionView.NumberOfItemsInSection(_section) == 0)
{
return true;
}
return _collectionView.VisibleCells.Length == 0;
}
void BatchUpdate(Action update)
{
_collectionView.PerformBatchUpdates(() =>
{
if (_batchUpdating.CurrentCount > 0)
{
_batchUpdating.Wait();
}
update();
},
(_) =>
{
if (_batchUpdating.CurrentCount > 0)
{
_batchUpdating.Release();
}
});
}
}
}