Make CollectionView SelectedItem and SelectedItems binding function correctly (#6085)

* Add automated test for CollectionView single selection bound item

* Make SelectedItem Two-Way

* Multiple selection test page

* Bindable SelectedItems implementation

* Add automated test

* Simplify null checks

* Add Preserve attribute so linker doesn't break test

* Make multi-item select test smaller so it passes UITests on smaller screens

* Clearer list-to-string method

* Clear native selection on iOS when SelectedItem set to null

fixes #6158
fixes #5832
This commit is contained in:
E.Z. Hart 2019-05-28 11:54:09 -06:00 коммит произвёл Samantha Houts
Родитель 6169b972a2
Коммит 34165550f5
15 изменённых файлов: 457 добавлений и 44 удалений

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

@ -0,0 +1,63 @@
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, 47803, "CollectionView: Multi Selection Binding", PlatformAffected.All)]
public class CollectionViewBoundMultiSelection : TestNavigationPage
{
protected override void Init()
{
#if APP
Device.SetFlags(new List<string>(Device.Flags ?? new List<string>()) { "CollectionView_Experimental" });
PushAsync(new GalleryPages.CollectionViewGalleries.SelectionGalleries.MultipleBoundSelection());
#endif
}
#if UITEST
[Test]
public void ItemsFromViewModelShouldBeSelected()
{
// Initially Items 1 and 2 should be selected (from the view model)
RunningApp.WaitForElement("Selected: Item 1, Item 2");
// Tapping Item 3 should select it and updating the binding
RunningApp.Tap("Item 3");
RunningApp.WaitForElement("Selected: Item 1, Item 2, Item 3");
// Test clearing the selection from the view model and updating it
RunningApp.Tap("ClearAndAdd");
RunningApp.WaitForElement("Selected: Item 1, Item 2");
// Test removing an item from the selection
RunningApp.Tap("Item 2");
RunningApp.WaitForElement("Selected: Item 1");
// Test setting a new selection list in the view mdoel
RunningApp.Tap("Reset");
RunningApp.WaitForElement("Selected: Item 1, Item 2");
RunningApp.Tap("Item 0");
// Test setting the selection directly with CollectionView.SelectedItems
RunningApp.Tap("DirectUpdate");
RunningApp.WaitForElement("Selected: Item 0, Item 3");
}
#endif
}
}

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

@ -0,0 +1,44 @@
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, 4539134, "CollectionView: Single Selection Binding", PlatformAffected.All)]
public class CollectionViewBoundSingleSelection : TestNavigationPage
{
protected override void Init()
{
#if APP
Device.SetFlags(new List<string>(Device.Flags ?? new List<string>()) { "CollectionView_Experimental" });
PushAsync(new GalleryPages.CollectionViewGalleries.SelectionGalleries.SingleBoundSelection());
#endif
}
#if UITEST
[Test]
public void SelectionShouldUpdateBinding()
{
// Initially Item 2 should be selected (from the view model)
RunningApp.WaitForElement("Selected: Item: 2");
// Tapping Item 3 should select it and updating the binding
RunningApp.Tap("Item 3");
RunningApp.WaitForElement("Selected: Item: 3");
}
#endif
}
}

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

@ -14,6 +14,8 @@
<Compile Include="$(MSBuildThisFileDirectory)Bugzilla59172.cs" />
<Compile Include="$(MSBuildThisFileDirectory)FlagTestHelpers.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Issue5766.cs" />
<Compile Include="$(MSBuildThisFileDirectory)CollectionViewBoundMultiSelection.cs" />
<Compile Include="$(MSBuildThisFileDirectory)CollectionViewBoundSingleSelection.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Issue4684.xaml.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Issue4992.xaml.cs">
<DependentUpon>Issue4992.xaml</DependentUpon>

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

@ -0,0 +1,82 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Xamarin.Forms.Internals;
namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.SelectionGalleries
{
[Preserve(AllMembers = true)]
internal class BoundSelectionModel : INotifyPropertyChanged
{
private CollectionViewGalleryTestItem _selectedItem;
private ObservableCollection<CollectionViewGalleryTestItem> _items;
private ObservableCollection<object> _selectedItems;
public event PropertyChangedEventHandler PropertyChanged;
public BoundSelectionModel()
{
Items = new ObservableCollection<CollectionViewGalleryTestItem>();
for (int n = 0; n < 4; n++)
{
Items.Add(new CollectionViewGalleryTestItem(DateTime.Now.AddDays(n), $"Item {n}", "coffee.png", n));
}
SelectedItem = Items[2];
SelectedItems = new ObservableCollection<object>()
{
Items[1], Items[2]
};
}
private void SelectedItemsCollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(SelectedItemsText));
}
void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public CollectionViewGalleryTestItem SelectedItem
{
get => _selectedItem;
set
{
_selectedItem = value;
OnPropertyChanged();
}
}
public ObservableCollection<object> SelectedItems
{
get => _selectedItems;
set
{
if (_selectedItems != null)
{
_selectedItems.CollectionChanged -= SelectedItemsCollectionChanged;
}
_selectedItems = value;
_selectedItems.CollectionChanged += SelectedItemsCollectionChanged;
OnPropertyChanged();
OnPropertyChanged(nameof(SelectedItemsText));
}
}
public ObservableCollection<CollectionViewGalleryTestItem> Items
{
get => _items;
set { _items = value; OnPropertyChanged(); }
}
public string SelectedItemsText => SelectedItems.ToCommaSeparatedList();
}
}

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

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.SelectionGalleries.MultipleBoundSelection">
<ContentPage.Content>
<StackLayout Spacing="2">
<Label Text="The selected items in the CollectionView should always match the 'Selected' Label below. If it does not, this test has failed."
FontSize="10" VerticalOptions="CenterAndExpand" HorizontalOptions="CenterAndExpand" />
<Label Text="{Binding SelectedItemsText, StringFormat='{}Selected: {0}'}" FontAttributes="Bold"
FontSize="10" VerticalOptions="CenterAndExpand" HorizontalOptions="CenterAndExpand" />
<Button AutomationId="ClearAndAdd" HeightRequest="35" FontSize="10" Text="Clear VM selection and add Items 1 and 2" Clicked="ClearAndAdd" />
<Button AutomationId="Reset" HeightRequest="35" FontSize="10" Text="Set VM selection to new list" Clicked="ResetClicked" />
<Button AutomationId="DirectUpdate" HeightRequest="35" FontSize="10" Text="Clear CV selection and add Items 0 and 3" Clicked="DirectUpdateClicked" />
<CollectionView x:Name="CollectionView" ItemsSource="{Binding Items}"
SelectionMode="Multiple" SelectedItems="{Binding SelectedItems}">
<CollectionView.ItemTemplate>
<DataTemplate>
<StackLayout>
<Image Source="{Binding Image}" HeightRequest="30" />
<Label FontSize="10" Text="{Binding Caption}"></Label>
</StackLayout>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</StackLayout>
</ContentPage.Content>
</ContentPage>

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

@ -0,0 +1,42 @@
using System;
using System.Collections.ObjectModel;
using Xamarin.Forms.Xaml;
namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.SelectionGalleries
{
[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class MultipleBoundSelection : ContentPage
{
BoundSelectionModel _vm;
public MultipleBoundSelection()
{
_vm = new BoundSelectionModel();
BindingContext = _vm;
InitializeComponent();
}
private void ClearAndAdd(object sender, EventArgs e)
{
_vm.SelectedItems.Clear();
_vm.SelectedItems.Add(_vm.Items[1]);
_vm.SelectedItems.Add(_vm.Items[2]);
}
private void ResetClicked(object sender, EventArgs e)
{
_vm.SelectedItems = new ObservableCollection<object>
{
_vm.Items[1],
_vm.Items[2]
};
}
private void DirectUpdateClicked(object sender, EventArgs e)
{
CollectionView.SelectedItems.Clear();
CollectionView.SelectedItems.Add(_vm.Items[0]);
CollectionView.SelectedItems.Add(_vm.Items[3]);
}
}
}

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

@ -22,6 +22,10 @@
new PreselectedItemGallery(), Navigation),
GalleryBuilder.NavButton("Preselected Items", () =>
new PreselectedItemsGallery(), Navigation),
GalleryBuilder.NavButton("Single Selection, Bound", () =>
new SingleBoundSelection(), Navigation),
GalleryBuilder.NavButton("Multiple Selection, Bound", () =>
new MultipleBoundSelection(), Navigation),
}
}
};

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

@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Linq;
namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.SelectionGalleries
{
internal static class SelectionHelpers
{
public static string ToCommaSeparatedList(this IEnumerable<object> items)
{
if (items == null)
{
return string.Empty;
}
return string.Join(", ", items.Cast<CollectionViewGalleryTestItem>().Select(i => i.Caption));
}
}
}

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

@ -35,8 +35,8 @@ namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.SelectionG
void UpdateSelectionInfo(IEnumerable<object> currentSelectedItems, IEnumerable<object> previousSelectedItems)
{
var previous = ToList(previousSelectedItems);
var current = ToList(currentSelectedItems);
var previous = previousSelectedItems.ToCommaSeparatedList();
var current = currentSelectedItems.ToCommaSeparatedList();
if (string.IsNullOrEmpty(previous))
{
@ -58,7 +58,7 @@ namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.SelectionG
if(CollectionView.SelectionMode == SelectionMode.Multiple)
{
current = ToList(CollectionView?.SelectedItems);
current = CollectionView?.SelectedItems.ToCommaSeparatedList();
}
else if (CollectionView.SelectionMode == SelectionMode.Single)
{
@ -67,16 +67,5 @@ namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.SelectionG
SelectedItemsCommand.Text = $"Selection (command): {current}";
}
static string ToList(IEnumerable<object> items)
{
if (items == null)
{
return string.Empty;
}
return items.Aggregate(string.Empty,
(s, o) => s + (s.Length == 0 ? "" : ", ") + ((CollectionViewGalleryTestItem)o).Caption);
}
}
}

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

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.SelectionGalleries.SingleBoundSelection">
<ContentPage.Content>
<StackLayout Spacing="5">
<Label Text="The selected item in the CollectionView should match the 'Selected' Label below. If it does not, this test has failed."
VerticalOptions="CenterAndExpand"
HorizontalOptions="CenterAndExpand" />
<Label Text="{Binding SelectedItem, StringFormat='{}Selected: {0}'}"
VerticalOptions="CenterAndExpand"
HorizontalOptions="CenterAndExpand" />
<Button AutomationId="Reset" Text="Reset Selection to Item 0" Clicked="ResetClicked" />
<Button AutomationId="Clear" Text="Clear Selection" Clicked="ClearClicked" />
<CollectionView ItemsSource="{Binding Items}" SelectionMode="Single" SelectedItem="{Binding SelectedItem}">
<CollectionView.ItemTemplate>
<DataTemplate>
<StackLayout>
<Image Source="{Binding Image}" HeightRequest="50" />
<Label Text="{Binding Caption}"></Label>
</StackLayout>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</StackLayout>
</ContentPage.Content>
</ContentPage>

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

@ -0,0 +1,28 @@
using System;
using Xamarin.Forms.Xaml;
namespace Xamarin.Forms.Controls.GalleryPages.CollectionViewGalleries.SelectionGalleries
{
[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class SingleBoundSelection : ContentPage
{
BoundSelectionModel _vm;
public SingleBoundSelection()
{
InitializeComponent();
_vm = new BoundSelectionModel();
BindingContext = _vm;
}
private void ResetClicked(object sender, EventArgs e)
{
_vm.SelectedItem = _vm.Items[0];
}
private void ClearClicked(object sender, EventArgs e)
{
_vm.SelectedItem = null;
}
}
}

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

@ -56,6 +56,9 @@
<EmbeddedResource Update="GalleryPages\CollectionViewGalleries\EmptyViewGalleries\EmptyViewSwapGallery.xaml">
<Generator>MSBuild:UpdateDesignTimeXaml</Generator>
</EmbeddedResource>
<EmbeddedResource Update="GalleryPages\CollectionViewGalleries\SelectionGalleries\MultipleBoundSelection.xaml">
<Generator>MSBuild:UpdateDesignTimeXaml</Generator>
</EmbeddedResource>
<EmbeddedResource Update="GalleryPages\CollectionViewGalleries\SelectionGalleries\PreselectedItemsGallery.xaml">
<Generator>MSBuild:UpdateDesignTimeXaml</Generator>
</EmbeddedResource>

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

@ -12,12 +12,15 @@ namespace Xamarin.Forms
public static readonly BindableProperty SelectedItemProperty =
BindableProperty.Create(nameof(SelectedItem), typeof(object), typeof(SelectableItemsView), default(object),
defaultBindingMode: BindingMode.TwoWay,
propertyChanged: SelectedItemPropertyChanged);
static readonly BindablePropertyKey SelectedItemsPropertyKey =
BindableProperty.CreateReadOnly(nameof(SelectedItems), typeof(IList<object>), typeof(SelectableItemsView), null);
public static readonly BindableProperty SelectedItemsProperty = SelectedItemsPropertyKey.BindableProperty;
public static readonly BindableProperty SelectedItemsProperty =
BindableProperty.Create(nameof(SelectedItems), typeof(IList<object>), typeof(SelectableItemsView), null,
defaultBindingMode: BindingMode.OneWay,
propertyChanged: SelectedItemsPropertyChanged,
coerceValue: CoerceSelectedItems,
defaultValueCreator: DefaultValueCreator);
public static readonly BindableProperty SelectionChangedCommandProperty =
BindableProperty.Create(nameof(SelectionChangedCommand), typeof(ICommand), typeof(SelectableItemsView));
@ -26,10 +29,10 @@ namespace Xamarin.Forms
BindableProperty.Create(nameof(SelectionChangedCommandParameter), typeof(object),
typeof(SelectableItemsView));
static readonly IList<object> s_empty = new List<object>(0);
public SelectableItemsView()
{
var selectionList = new SelectionList(this);
SetValue(SelectedItemsPropertyKey, selectionList);
}
public object SelectedItem
@ -41,6 +44,7 @@ namespace Xamarin.Forms
public IList<object> SelectedItems
{
get => (IList<object>)GetValue(SelectedItemsProperty);
set => SetValue(SelectedItemsProperty, new SelectionList(this, value));
}
public ICommand SelectionChangedCommand
@ -67,9 +71,39 @@ namespace Xamarin.Forms
{
}
static object CoerceSelectedItems(BindableObject bindable, object value)
{
if (value == null)
{
return new SelectionList((SelectableItemsView)bindable);
}
if(value is SelectionList)
{
return value;
}
return new SelectionList((SelectableItemsView)bindable, value as IList<object>);
}
static object DefaultValueCreator(BindableObject bindable)
{
return new SelectionList((SelectableItemsView)bindable);
}
static void SelectedItemsPropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
var selectableItemsView = (SelectableItemsView)bindable;
var oldSelection = (IList<object>)oldValue ?? s_empty;
var newSelection = (IList<object>)newValue ?? s_empty;
selectableItemsView.SelectedItemsPropertyChanged(oldSelection, newSelection);
}
internal void SelectedItemsPropertyChanged(IList<object> oldSelection, IList<object> newSelection)
{
SelectionPropertyChanged(this, new SelectionChangedEventArgs(oldSelection, newSelection));
OnPropertyChanged(SelectedItemsProperty.PropertyName);
}

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

@ -1,42 +1,55 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
namespace Xamarin.Forms
{
// Used by the SelectableItemsView to keep track of (and respond to changes in) the SelectedItems property
internal class SelectionList : IList<object>
{
readonly SelectableItemsView _selectableItemsView;
List<object> _internal;
static readonly IList<object> s_empty = new List<object>(0);
readonly SelectableItemsView _selectableItemsView;
readonly IList<object> _internal;
IList<object> _shadow;
bool _externalChange;
public SelectionList(SelectableItemsView selectableItemsView)
public SelectionList(SelectableItemsView selectableItemsView, IList<object> items = null)
{
_selectableItemsView = selectableItemsView ?? throw new ArgumentNullException(nameof(selectableItemsView));
_internal = new List<object>();
_internal = items ?? new List<object>();
_shadow = Copy();
if (items is INotifyCollectionChanged incc)
{
incc.CollectionChanged += OnCollectionChanged;
}
}
public object this[int index] { get => _internal[index]; set => _internal[index] = value; }
public int Count => _internal.Count;
public bool IsReadOnly => false;
public void Add(object item)
{
var oldItems = Copy();
_externalChange = true;
_internal.Add(item);
_externalChange = false;
_selectableItemsView.SelectedItemsPropertyChanged(oldItems, Copy());
_selectableItemsView.SelectedItemsPropertyChanged(_shadow, _internal);
_shadow.Add(item);
}
public void Clear()
{
var oldItems = Copy();
_externalChange = true;
_internal.Clear();
_externalChange = false;
_selectableItemsView.SelectedItemsPropertyChanged(oldItems, s_empty);
_selectableItemsView.SelectedItemsPropertyChanged(_shadow, s_empty);
_shadow.Clear();
}
public bool Contains(object item)
@ -61,22 +74,24 @@ namespace Xamarin.Forms
public void Insert(int index, object item)
{
var oldItems = Copy();
_externalChange = true;
_internal.Insert(index, item);
_externalChange = false;
_selectableItemsView.SelectedItemsPropertyChanged(oldItems, Copy());
_selectableItemsView.SelectedItemsPropertyChanged(_shadow, _internal);
_shadow.Insert(index, item);
}
public bool Remove(object item)
{
var oldItems = Copy();
_externalChange = true;
var removed = _internal.Remove(item);
_externalChange = false;
if (removed)
{
_selectableItemsView.SelectedItemsPropertyChanged(oldItems, Copy());
_selectableItemsView.SelectedItemsPropertyChanged(_shadow, _internal);
_shadow.Remove(item);
}
return removed;
@ -84,11 +99,12 @@ namespace Xamarin.Forms
public void RemoveAt(int index)
{
var oldItems = Copy();
_externalChange = true;
_internal.RemoveAt(index);
_externalChange = false;
_selectableItemsView.SelectedItemsPropertyChanged(oldItems, Copy());
_selectableItemsView.SelectedItemsPropertyChanged(_shadow, _internal);
_shadow.RemoveAt(index);
}
IEnumerator IEnumerable.GetEnumerator()
@ -107,14 +123,19 @@ namespace Xamarin.Forms
return items;
}
public void ClearQuietly()
void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs args)
{
_internal.Clear();
}
if (_externalChange)
{
// If this change was initiated by a renderer or direct manipulation of ColllectionView.SelectedItems,
// we don't need to send a selection change notification
return;
}
public void AddQuietly(object item)
{
_internal.Add(item);
// This change is coming from a bound viewmodel property
// Emit a selection change notification, then bring the shadow copy up-to-date
_selectableItemsView.SelectedItemsPropertyChanged(_shadow, _internal);
_shadow = Copy();
}
}
}

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

@ -34,6 +34,17 @@ namespace Xamarin.Forms.Platform.iOS
CollectionView.SelectItem(index, true, UICollectionViewScrollPosition.None);
}
// Called by Forms to clear the native selection
internal void ClearSelection()
{
var selectedItemIndexes = CollectionView.GetIndexPathsForSelectedItems();
foreach (var index in selectedItemIndexes)
{
CollectionView.DeselectItem(index, true);
}
}
void FormsSelectItem(NSIndexPath indexPath)
{
var mode = SelectableItemsView.SelectionMode;
@ -87,6 +98,11 @@ namespace Xamarin.Forms.Platform.iOS
{
SelectItem(selectedItem);
}
else
{
// SelectedItem has been set to null; if an item is selected, we need to de-select it
ClearSelection();
}
return;
case SelectionMode.Multiple: