Skip to content

Commit

Permalink
feat: selection support with ItemsRepeaterExtensions
Browse files Browse the repository at this point in the history
  • Loading branch information
Xiaoy312 committed Feb 23, 2023
1 parent c25fc7b commit 52b96ae
Show file tree
Hide file tree
Showing 13 changed files with 948 additions and 87 deletions.
1 change: 1 addition & 0 deletions doc/controls-styles.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The `Uno.Toolkit.UI` library adds the following helper classes:
- [`AncestorBinding` and `ItemsControlBinding`](helpers\ancestor-itemscontrol-binding.md): These markup extensions provides relative binding based on ancestor type. If you are familiar with WPF, they are very similar to `{RelativeSource Mode=FindAncestor}`.
- [`CommandExtensions`](helpers\command-extensions.md): Provides Command/CommandParameter attached properties for common scenarios.
- [`InputExtensions`](helpers\input-extensions.md): Provides various attached properties for _input controls_, such as `TextBox` and `PasswordBox`.
- [`ItemsRepeaterExtensions`](helpers\itemsrepeater-extensions.md): Provides selection support for ItemsRepeater.
- [`StatusBar`](helpers\StatusBar-extensions.md): Provides two attached properties on `Page` to controls the visual of the status bar on mobile platforms.
- [`TabBarItemExtensions`](helpers\TabBarItem-extensions.md): Provides additional features for `TabBarItem`.
- [`VisualStateManagerExtensions`](helpers\VisualStateManager-extensions.md): Provides a way of manipulating the visual states of Control with attached property.
Expand Down
3 changes: 1 addition & 2 deletions doc/controls/ChipAndChipGroup.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
`ChipGroup` is a container that can house a collection of `Chip`s.

## Chip
`Chip` is derived from `ToggleButton`, a control that a user can select (check) or clear (uncheck).
`Chip` is derived from `ToggleButton`, a control that an user can select (check) or deselect (uncheck).

### C#
```csharp
Expand Down Expand Up @@ -38,7 +38,6 @@ CanRemove|bool|Gets or sets whether the remove button is visible.
Elevation|double|Gets or sets the elevation of the `Chip`.
Icon|object|Gets or sets the icon of the `Chip`.
IconTemplate|DataTemplate|Gets or sets the data template that is used to display the icon of the `Chip`.
IsCheckable|bool|Gets or sets whether the chip can be checked. Used to prevent showing selection state.<br/>note: When nested under the `ChipGroup`, this property will be overwritten by `ChipGroup.SelectionMode`.
RemovedCommand|ICommand|Gets or sets the command to invoke when the remove button is pressed.
RemovedCommandParameter|object|Gets or sets the parameter to pass to the RemovedCommand property.

Expand Down
45 changes: 45 additions & 0 deletions doc/helpers/itemsrepeater-extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ItemsRepeaterExtensions Attached Properties
Provides selection support for `ItemsRepeater`.

## Properties
Property|Type|Description
-|-|-
SelectedItem|object|Two-ways bindable property for the current/first(in Multiple mode) selected item.\*
SelectedIndex|int|Two-ways bindable property for the current/first(in Multiple mode) selected index.\*
SelectedItems|IList\<object>|Two-ways bindable property for the current selected items.\*
SelectedIndexes|IList\<int>|Two-ways bindable property for the current selected indexes.\*
SelectionMode|ItemsSelectionMode|Gets or sets the selection behavior: `None`, `SingleOrNone`, `Single`, `Multiple` <br/> note: Changing this value will cause the `Selected-`properties to be re-coerced.

### Remarks
- `Selected-`properties only takes effect when `SelectionMode` is set to a valid value that is not `None`.
- `ItemsSelectionMode`: Defines constants that specify the selection behavior.
> Different numbers of selected items are guaranteed: None=0, SingleOrNone=0 or 1, Single=1, Multiple=0 or many.
- `None`: Selection is disabled.
- `SingleOrNone`: Up to one item can be selected at a time. The current item can be deselected.
- `Single`: One item is selected at any time. The current item cannot be deselected.
- `Multiple`: The current item cannot be deselected.

## Usage
```xml
xmlns:utu="using:Uno.Toolkit.UI"
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
...

<muxc:ItemsRepeater ItemsSource="{Binding ...}"
utu:ItemsRepeaterExtensions.SelectionMode="Single">
<muxc:ItemsRepeater.ItemTemplate>
<DataTemplate>
<!-- pick one: -->
<ListViewItem Content="{Binding}" />
<!-- <CheckBox Content="{Binding}" /> -->
<!-- <RadioButton Content="{Binding}" /> -->
<!-- <ToggleButton Content="{Binding}" /> -->
<!-- <utu:Chip Content="{Binding}" /> -->
</DataTemplate>
</muxc:ItemsRepeater.ItemTemplate>
</muxc:ItemsRepeater>
```

### Remarks
- The selection feature from this extensions support ItemTemplate whose the root element is a `SelectorItem` or `ToggleButton`(which includes `Chip`).
- `RadioButton`: Multiple mode is not supported due to control limitation.
2 changes: 2 additions & 0 deletions doc/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
href: helpers/control-extensions.md
- name: InputExtensions attached properties
href: helpers/input-extensions.md
- name: ItemsRepeaterExtensions Attached Properties
href: helpers/itemsrepeater-extensions.md
- name: StatusBar attached properties
href: helpers/StatusBar-extensions.md
- name: TabBarItem attached properties
Expand Down
86 changes: 86 additions & 0 deletions src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.VisualStudio.TestTools.UnitTesting;

#if IS_WINUI
using Microsoft.UI.Xaml.Markup;
#else
using Windows.UI.Xaml.Markup;
#endif

namespace Uno.Toolkit.RuntimeTests.Helpers
{
internal static class XamlHelper
{
/// <summary>
/// Matches right before the &gt; or \&gt; tail of any tag.
/// </summary>
/// <remarks>
/// It will match an opening or closing or self-closing tag.
/// </remarks>
private static readonly Regex EndOfTagRegex = new Regex(@"(?=( ?/)?>)");

/// <summary>
/// Matches any tag without xmlns prefix.
/// </summary>
private static readonly Regex NonXmlnsTagRegex = new Regex(@"<\w+[ />]");

private static readonly IReadOnlyDictionary<string, string> KnownXmlnses = new Dictionary<string, string>
{
[string.Empty] = "http://schemas.microsoft.com/winfx/2006/xaml/presentation",
["x"] = "http://schemas.microsoft.com/winfx/2006/xaml",
["toolkit"] = "using:Uno.UI.Toolkit", // uno utilities
["utu"] = "using:Uno.Toolkit.UI", // this library
["muxc"] = "using:Microsoft.UI.Xaml.Controls",
};

/// <summary>
/// XamlReader.Load the xaml and type-check result.
/// </summary>
/// <param name="xaml">Xaml with single or double quotes</param>
/// <param name="autoInjectXmlns">Toggle automatic detection of xmlns required and inject to the xaml</param>
public static T LoadXaml<T>(string xaml, bool autoInjectXmlns = true) where T : class
{
var xmlnses = new Dictionary<string, string>();

if (autoInjectXmlns)
{
foreach (var xmlns in KnownXmlnses)
{
var match = xmlns.Key == string.Empty
? NonXmlnsTagRegex.IsMatch(xaml)
: xaml.Contains($"<{xmlns.Key}:");
if (match)
{
xmlnses.Add(xmlns.Key, xmlns.Value);
}
}
}

return LoadXaml<T>(xaml, xmlnses);
}

/// <summary>
/// XamlReader.Load the xaml and type-check result.
/// </summary>
/// <param name="xaml">Xaml with single or double quotes</param>
/// <param name="xmlnses">Xmlns to inject; use string.Empty for the default xmlns' key</param>
public static T LoadXaml<T>(string xaml, Dictionary<string, string> xmlnses) where T : class
{
var injection = " " + string.Join(" ", xmlnses
.Select(x => $"xmlns{(string.IsNullOrEmpty(x.Key) ? "" : $":{x.Key}")}=\"{x.Value}\"")
);

xaml = EndOfTagRegex.Replace(xaml, injection.TrimEnd(), 1);

var result = XamlReader.Load(xaml);
Assert.IsNotNull(result, "XamlReader.Load returned null");
Assert.IsInstanceOfType(result, typeof(T), "XamlReader.Load did not return the expected type");

return (T)result;
}
}
}
197 changes: 197 additions & 0 deletions src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Uno.Extensions;
using Uno.Toolkit.RuntimeTests.Extensions;
using Uno.Toolkit.RuntimeTests.Helpers;
using Uno.Toolkit.UI;
using Uno.UI.RuntimeTests;
using ChipControl = Uno.Toolkit.UI.Chip; // ios/macos: to avoid collision with `global::Chip` namespace...
using ItemsRepeater = Microsoft.UI.Xaml.Controls.ItemsRepeater;

#if IS_WINUI
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls.Primitives;
#else
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls.Primitives;
#endif

namespace Uno.Toolkit.RuntimeTests.Tests;

[TestClass]
[RunsOnUIThread]
internal class ItemsRepeaterChipTests
{
// note: the default state of Chip.IsChecked (inherited from ToggleButton) is false, since we don't use IsThreeState

#region Selection via Toggle

[TestMethod]
[DataRow(ItemsSelectionMode.None, new[] { 1 }, null)]
[DataRow(ItemsSelectionMode.SingleOrNone, new[] { 1 }, 1)]
[DataRow(ItemsSelectionMode.SingleOrNone, new[] { 1, 1 }, null)] // deselection
[DataRow(ItemsSelectionMode.SingleOrNone, new[] { 1, 2 }, 2)] // reselection
[DataRow(ItemsSelectionMode.Single, new int[0], 0)] // selection enforced by 'Single'
[DataRow(ItemsSelectionMode.Single, new[] { 1 }, 1)]
[DataRow(ItemsSelectionMode.Single, new[] { 1, 1 }, 1)] // deselection denied
[DataRow(ItemsSelectionMode.Single, new[] { 1, 2 }, 2)] // reselection
[DataRow(ItemsSelectionMode.Multiple, new[] { 1 }, new object[] { 1 })]
[DataRow(ItemsSelectionMode.Multiple, new[] { 1, 2 }, new object[] { 1, 2 })] // multi-select@1,2
[DataRow(ItemsSelectionMode.Multiple, new[] { 1, 2, 2 }, new object[] { 1 })] // multi-select@1,2, deselection@2
public async Task VariousMode_TapSelection(ItemsSelectionMode mode, int[] selectionSequence, object expectation)
{
var source = Enumerable.Range(0, 3).ToArray();
var SUT = SetupItemsRepeater(source, mode);
bool?[] expected, actual;

await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
expected = mode is ItemsSelectionMode.Single
? new bool?[] { true, false, false }
: new bool?[] { false, false, false };
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);

foreach (var i in selectionSequence)
{
FakeTapItemAt(SUT, i);
}
expected = (expectation switch
{
object[] array => source.Select(x => array.Contains(x)),
int i => source.Select(x => x == i),
null => Enumerable.Repeat(false, 3),

_ => throw new ArgumentOutOfRangeException(nameof(expectation)),
}).Cast<bool?>().ToArray();
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);
}

[TestMethod]
public async Task SingleMode_Selection()
{
var source = Enumerable.Range(0, 3).ToArray();
var SUT = SetupItemsRepeater(source, ItemsSelectionMode.SingleOrNone);
bool?[] expected = new bool?[] { false, true, false }, actual;

await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
actual = GetChipsSelectionState(SUT);
Assert.IsTrue(actual.All(x => x == false));

FakeTapItemAt(SUT, 1);
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);
}

#endregion

#region Changing SelectionMode

[TestMethod]
public async Task SingleOrNoneToSingle_NoneSelected_ShouldAutoSelect()
{
var source = Enumerable.Range(0, 3).ToArray();
var SUT = SetupItemsRepeater(source, ItemsSelectionMode.SingleOrNone);
bool?[] expected = new bool?[] { true, false, false }, actual;

await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
actual = GetChipsSelectionState(SUT);
Assert.IsTrue(actual.All(x => x == false));

ItemsRepeaterExtensions.SetSelectionMode(SUT, ItemsSelectionMode.Single);
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);
}

[TestMethod]
public async Task SingleOrNoneToSingle_Selected_ShouldPreserveSelection()
{
var source = Enumerable.Range(0, 3).ToArray();
var SUT = SetupItemsRepeater(source, ItemsSelectionMode.SingleOrNone);
bool?[] expected = new bool?[] { false, false, true }, actual;

await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
FakeTapItemAt(SUT, 2);
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);

ItemsRepeaterExtensions.SetSelectionMode(SUT, ItemsSelectionMode.Single);
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);
}

[TestMethod]
public async Task MultiToSingle_Selected_ShouldPreserveFirstSelection()
{
var source = Enumerable.Range(0, 3).ToArray();
var SUT = SetupItemsRepeater(source, ItemsSelectionMode.Multiple);
bool?[] expected = new bool?[] { false, true, true }, actual;

await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
FakeTapItemAt(SUT, 1);
FakeTapItemAt(SUT, 2);
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);

ItemsRepeaterExtensions.SetSelectionMode(SUT, ItemsSelectionMode.Single);
expected = new bool?[] { false, true, false };
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);
}

#endregion

private static ItemsRepeater SetupItemsRepeater(object source, ItemsSelectionMode mode)
{
var SUT = new ItemsRepeater
{
ItemsSource = source,
ItemTemplate = XamlHelper.LoadXaml<DataTemplate>("""
<DataTemplate>
<utu:Chip />
</DataTemplate>
"""),
};
ItemsRepeaterExtensions.SetSelectionMode(SUT, mode);

return SUT;
}

private static bool? IsChipSelectedAt(ItemsRepeater ir, int index)
{
return (ir.TryGetElement(index) as ChipControl)?.IsChecked;
}

// since we are not using IsThreeState=True, the values can only be true/false.
// if any of them is null, that means there is another problem and should be thrown.
// therefore, only == check should be used in an assertion.
private static bool?[] GetChipsSelectionState(ItemsRepeater ir)
{
return (ir.ItemsSource as IEnumerable)?.Cast<object>()
.Select((_, i) => (ir.TryGetElement(i) as ChipControl)?.IsChecked)
.ToArray() ?? new bool?[0];
}

private static void FakeTapItemAt(ItemsRepeater ir, int index)
{
if (ir.TryGetElement(index) is { } element)
{
// Fake local tap handler on ToggleButton level.
// For SelectorItem, nothing will happen on tap unless nested under a Selector, which isnt the case here.
(element as ToggleButton)?.Toggle();

// This is whats called in ItemsRepeater::Tapped handler.
// Note that the handler will not trigger from a "fake tap" like the line above, so we have to manually invoke here.
ItemsRepeaterExtensions.ToggleItemSelectionAtCoerced(ir, index);
}
else
{
throw new InvalidOperationException($"Element at index={index} is not yet materialized or out of range.");
}
}
}
Loading

0 comments on commit 52b96ae

Please sign in to comment.