generated from unoplatform/template
-
Notifications
You must be signed in to change notification settings - Fork 27
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add generic selection support for ItemsRepeater (#451)
- Loading branch information
Showing
13 changed files
with
949 additions
and
88 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 > or \> 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
197
src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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."); | ||
} | ||
} | ||
} |
Oops, something went wrong.