[iOS] Prevent crash when adding item to source of CollectionView in tab (#7940)

* Prevent crash when adding item to source of CollectionView that's never been displayed;
fixes #7700

* Fix renaming error in UI test

* Add test and fix for adding groups offscreen

* Fix rebase error

* Remove flags

* Add instructions

* Better instructions
This commit is contained in:
E.Z. Hart 2019-10-16 14:54:16 -06:00 коммит произвёл GitHub
Родитель eb1c563aa7
Коммит 10b3d774c7
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
9 изменённых файлов: 231 добавлений и 32 удалений

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

@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
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.Github, 7700, "[Bug][iOS] If CollectionView in other Tab gets changed before it's displayed, it stays invisible",
PlatformAffected.iOS)]
public class Issue7700 : TestTabbedPage
{
readonly ObservableCollection<string> _source = new ObservableCollection<string>(){ "one", "two", "three" };
readonly ObservableCollection<Group> _groupedSource = new ObservableCollection<Group>();
[Preserve(AllMembers = true)]
class Group : List<string>
{
public string Text { get; set; }
public Group()
{
Add("Uno");
Add("Dos");
Add("Tres");
}
}
const string Add1 = "Add1";
const string Add2 = "Add2";
const string Success = "Success";
const string Tab2 = "Tab2";
const string Tab3 = "Tab3";
const string Add1Label = "Add to List";
const string Add2Label = "Add to Grouped List";
protected override void Init()
{
#if APP
Children.Add(FirstPage());
Children.Add(CollectionViewPage());
Children.Add(GroupedCollectionViewPage());
#endif
}
ContentPage FirstPage()
{
var page = new ContentPage() { Title = "7700 First Page", Padding = 40 };
var instructions = new Label { Text = $"Tap the button marked '{Add1Label}'. Then tap the button marked '{Add2Label}'. If the application does not crash, the test has passed." };
var button1 = new Button() { Text = Add1Label, AutomationId = Add1 };
button1.Clicked += Button1Clicked;
var button2 = new Button() { Text = Add2Label, AutomationId = Add2 };
button2.Clicked += Button2Clicked;
var layout = new StackLayout { Children = { instructions, button1, button2 } };
page.Content = layout;
return page;
}
void Button1Clicked(object sender, EventArgs e)
{
_source.Insert(0, Success);
}
void Button2Clicked(object sender, EventArgs e)
{
_groupedSource.Insert(0, new Group() { Text = Success });
}
ContentPage CollectionViewPage()
{
var cv = new CollectionView
{
ItemTemplate = new DataTemplate(() =>
{
var label = new Label();
label.SetBinding(Label.TextProperty, new Binding("."));
return label;
}),
ItemsSource = _source
};
var page = new ContentPage() { Title = Tab2, Padding = 40 };
page.Content = cv;
return page;
}
ContentPage GroupedCollectionViewPage()
{
var cv = new CollectionView
{
ItemTemplate = new DataTemplate(() =>
{
var label = new Label();
label.SetBinding(Label.TextProperty, new Binding("."));
return label;
}),
GroupHeaderTemplate = new DataTemplate(() =>
{
var label = new Label();
label.SetBinding(Label.TextProperty, new Binding("Text"));
return label;
}),
GroupFooterTemplate = new DataTemplate(() =>
{
var label = new Label();
label.SetBinding(Label.TextProperty, new Binding("Text"));
return label;
}),
ItemsSource = _groupedSource,
IsGrouped = true
};
var page = new ContentPage() { Title = Tab3, Padding = 40 };
page.Content = cv;
return page;
}
#if UITEST
[Test]
public void AddingItemToUnviewedCollectionViewShouldNotCrash()
{
RunningApp.WaitForElement(Add1);
RunningApp.Tap(Add1);
RunningApp.Tap(Tab2);
RunningApp.WaitForElement(Success);
}
[Test]
public void AddingGroupToUnviewedGroupedCollectionViewShouldNotCrash()
{
RunningApp.WaitForElement(Add2);
RunningApp.Tap(Add2);
RunningApp.Tap(Tab3);
RunningApp.WaitForElement(Success);
}
#endif
}
}

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

@ -28,8 +28,6 @@ namespace Xamarin.Forms.Controls.Issues
#if APP #if APP
public Issue7993() public Issue7993()
{ {
Device.SetFlags(new List<string> { CollectionView.CollectionViewExperimental });
InitializeComponent(); InitializeComponent();
BindingContext = new ViewModel7993(); BindingContext = new ViewModel7993();

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

@ -59,6 +59,7 @@
<Compile Include="$(MSBuildThisFileDirectory)Issue7519Xaml.xaml.cs"> <Compile Include="$(MSBuildThisFileDirectory)Issue7519Xaml.xaml.cs">
<SubType>Code</SubType> <SubType>Code</SubType>
</Compile> </Compile>
<Compile Include="$(MSBuildThisFileDirectory)Issue7700.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Issue7758.xaml.cs"> <Compile Include="$(MSBuildThisFileDirectory)Issue7758.xaml.cs">
<SubType>Code</SubType> <SubType>Code</SubType>
</Compile> </Compile>

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

@ -30,7 +30,7 @@ namespace Xamarin.Forms.Platform.iOS
// Use the BindableProperty here (instead of _isGroupingEnabled) because the cached value might not be set yet // Use the BindableProperty here (instead of _isGroupingEnabled) because the cached value might not be set yet
if (ItemsView.IsGrouped) if (ItemsView.IsGrouped)
{ {
return ItemsSourceFactory.CreateGrouped(ItemsView.ItemsSource, CollectionView); return ItemsSourceFactory.CreateGrouped(ItemsView.ItemsSource, this);
} }
return base.CreateItemsViewSource(); return base.CreateItemsViewSource();

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

@ -7,7 +7,7 @@ namespace Xamarin.Forms.Platform.iOS
{ {
internal static class ItemsSourceFactory internal static class ItemsSourceFactory
{ {
public static IItemsViewSource Create(IEnumerable itemsSource, UICollectionView collectionView) public static IItemsViewSource Create(IEnumerable itemsSource, UICollectionViewController collectionViewController)
{ {
if (itemsSource == null) if (itemsSource == null)
{ {
@ -17,21 +17,21 @@ namespace Xamarin.Forms.Platform.iOS
switch (itemsSource) switch (itemsSource)
{ {
case INotifyCollectionChanged _: case INotifyCollectionChanged _:
return new ObservableItemsSource(itemsSource as IList, collectionView); return new ObservableItemsSource(itemsSource as IList, collectionViewController);
case IEnumerable _: case IEnumerable _:
default: default:
return new ListSource(itemsSource); return new ListSource(itemsSource);
} }
} }
public static IItemsViewSource CreateGrouped(IEnumerable itemsSource, UICollectionView collectionView) public static IItemsViewSource CreateGrouped(IEnumerable itemsSource, UICollectionViewController collectionViewController)
{ {
if (itemsSource == null) if (itemsSource == null)
{ {
return new EmptySource(); return new EmptySource();
} }
return new ObservableGroupedSource(itemsSource, collectionView); return new ObservableGroupedSource(itemsSource, collectionViewController);
} }
} }
} }

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

@ -166,7 +166,7 @@ namespace Xamarin.Forms.Platform.iOS
protected virtual IItemsViewSource CreateItemsViewSource() protected virtual IItemsViewSource CreateItemsViewSource()
{ {
return ItemsSourceFactory.Create(ItemsView.ItemsSource, CollectionView); return ItemsSourceFactory.Create(ItemsView.ItemsSource, this);
} }
public virtual void UpdateItemsSource() public virtual void UpdateItemsSource()

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

@ -42,7 +42,7 @@ namespace Xamarin.Forms.Platform.iOS
} }
} }
public int GroupCount => Count == 0 ? 0 : 1; public int GroupCount => 1;
public int ItemCount => Count; public int ItemCount => Count;

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

@ -4,19 +4,22 @@ using System.Collections.Generic;
using System.Collections.Specialized; using System.Collections.Specialized;
using Foundation; using Foundation;
using UIKit; using UIKit;
using Xamarin.Forms.Internals;
namespace Xamarin.Forms.Platform.iOS namespace Xamarin.Forms.Platform.iOS
{ {
internal class ObservableGroupedSource : IItemsViewSource internal class ObservableGroupedSource : IItemsViewSource
{ {
readonly UICollectionView _collectionView; readonly UICollectionView _collectionView;
UICollectionViewController _collectionViewController;
readonly IList _groupSource; readonly IList _groupSource;
bool _disposed; bool _disposed;
List<ObservableItemsSource> _groups = new List<ObservableItemsSource>(); List<ObservableItemsSource> _groups = new List<ObservableItemsSource>();
public ObservableGroupedSource(IEnumerable groupSource, UICollectionView collectionView) public ObservableGroupedSource(IEnumerable groupSource, UICollectionViewController collectionViewController)
{ {
_collectionView = collectionView; _collectionViewController = collectionViewController;
_collectionView = _collectionViewController.CollectionView;
_groupSource = groupSource as IList ?? new ListSource(groupSource); _groupSource = groupSource as IList ?? new ListSource(groupSource);
if (_groupSource is INotifyCollectionChanged incc) if (_groupSource is INotifyCollectionChanged incc)
@ -120,7 +123,7 @@ namespace Xamarin.Forms.Platform.iOS
{ {
if (_groupSource[n] is INotifyCollectionChanged && _groupSource[n] is IList list) if (_groupSource[n] is INotifyCollectionChanged && _groupSource[n] is IList list)
{ {
_groups.Add(new ObservableItemsSource(list, _collectionView, n)); _groups.Add(new ObservableItemsSource(list, _collectionViewController, n));
} }
} }
} }
@ -161,6 +164,13 @@ namespace Xamarin.Forms.Platform.iOS
return NSIndexSet.FromNSRange(new NSRange(startIndex, count)); return NSIndexSet.FromNSRange(new NSRange(startIndex, count));
} }
bool NotLoadedYet()
{
// If the UICollectionView hasn't actually been loaded, then calling InsertSections or DeleteSections 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) void Add(NotifyCollectionChangedEventArgs args)
{ {
var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : _groupSource.IndexOf(args.NewItems[0]); var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : _groupSource.IndexOf(args.NewItems[0]);
@ -170,6 +180,12 @@ namespace Xamarin.Forms.Platform.iOS
// is to reset all the group tracking to get it up-to-date // is to reset all the group tracking to get it up-to-date
ResetGroupTracking(); ResetGroupTracking();
if (NotLoadedYet())
{
_collectionView.ReloadData();
return;
}
_collectionView.InsertSections(CreateIndexSetFrom(startIndex, count)); _collectionView.InsertSections(CreateIndexSetFrom(startIndex, count));
} }
@ -192,7 +208,14 @@ namespace Xamarin.Forms.Platform.iOS
// is to reset all the group tracking to get it up-to-date // is to reset all the group tracking to get it up-to-date
ResetGroupTracking(); ResetGroupTracking();
_collectionView.DeleteSections(CreateIndexSetFrom(startIndex, count)); if (NotLoadedYet())
{
_collectionView.ReloadData();
}
else
{
_collectionView.DeleteSections(CreateIndexSetFrom(startIndex, count));
}
} }
void Replace(NotifyCollectionChangedEventArgs args) void Replace(NotifyCollectionChangedEventArgs args)

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

@ -8,21 +8,23 @@ namespace Xamarin.Forms.Platform.iOS
{ {
internal class ObservableItemsSource : IItemsViewSource internal class ObservableItemsSource : IItemsViewSource
{ {
readonly UICollectionViewController _collectionViewController;
readonly UICollectionView _collectionView; readonly UICollectionView _collectionView;
readonly bool _grouped; readonly bool _grouped;
readonly int _section; readonly int _section;
readonly IList _itemsSource; readonly IList _itemsSource;
bool _disposed; bool _disposed;
public ObservableItemsSource(IList itemSource, UICollectionView collectionView, int group = -1) public ObservableItemsSource(IList itemSource, UICollectionViewController collectionViewController, int group = -1)
{ {
_collectionView = collectionView; _collectionViewController = collectionViewController;
_collectionView = _collectionViewController.CollectionView;
_section = group < 0 ? 0 : group; _section = group < 0 ? 0 : group;
_grouped = group >= 0; _grouped = group >= 0;
_itemsSource = itemSource; _itemsSource = itemSource;
((INotifyCollectionChanged)itemSource).CollectionChanged += CollectionChanged; ((INotifyCollectionChanged)itemSource).CollectionChanged += CollectionChanged;
} }
@ -71,7 +73,7 @@ namespace Xamarin.Forms.Platform.iOS
return NSIndexPath.Create(-1, -1); return NSIndexPath.Create(-1, -1);
} }
public int GroupCount => _itemsSource.Count == 0 ? 0 : 1; public int GroupCount => 1;
public int ItemCount => _itemsSource.Count; public int ItemCount => _itemsSource.Count;
@ -130,32 +132,49 @@ namespace Xamarin.Forms.Platform.iOS
return result; return result;
} }
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;
}
void Add(NotifyCollectionChangedEventArgs args) void Add(NotifyCollectionChangedEventArgs args)
{ {
var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : _itemsSource.IndexOf(args.NewItems[0]); var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : _itemsSource.IndexOf(args.NewItems[0]);
var count = args.NewItems.Count; var count = args.NewItems.Count;
if (!_grouped && _collectionView.NumberOfSections() != GroupCount && count > 0) if (NotLoadedYet())
{ {
// Okay, we're going from completely empty to more than 0 items; this means we don't even
// have a section 0 yet. Inserting a section 0 manually results in an unexplained crash, so instead
// we'll just reload the data so the UICollectionView can get its internal state sorted out.
_collectionView.ReloadData(); _collectionView.ReloadData();
return;
} }
else
if (!_grouped && _collectionView.NumberOfItemsInSection(_section) == 0)
{ {
_collectionView.PerformBatchUpdates(() => // 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.PerformBatchUpdates(() =>
{ {
var indexes = CreateIndexesFrom(startIndex, count); var indexes = CreateIndexesFrom(startIndex, count);
_collectionView.InsertItems(indexes); _collectionView.InsertItems(indexes);
}, null); }, null);
}
} }
void Remove(NotifyCollectionChangedEventArgs args) void Remove(NotifyCollectionChangedEventArgs args)
{ {
var startIndex = args.OldStartingIndex; var startIndex = args.OldStartingIndex;
if (NotLoadedYet())
{
_collectionView.ReloadData();
return;
}
if (startIndex < 0) if (startIndex < 0)
{ {
// INCC implementation isn't giving us enough information to know where the removed items were in the // INCC implementation isn't giving us enough information to know where the removed items were in the
@ -163,20 +182,13 @@ namespace Xamarin.Forms.Platform.iOS
Reload(); Reload();
return; return;
} }
// If we have a start index, we can be more clever about removing the item(s) (and get the nifty animations) // 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; var count = args.OldItems.Count;
_collectionView.PerformBatchUpdates(() => _collectionView.PerformBatchUpdates(() =>
{ {
_collectionView.DeleteItems(CreateIndexesFrom(startIndex, count)); _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); }, null);
} }