diff --git a/doc/controls-styles.md b/doc/controls-styles.md index 6fca397a6..468a9220f 100644 --- a/doc/controls-styles.md +++ b/doc/controls-styles.md @@ -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. diff --git a/doc/controls/ChipAndChipGroup.md b/doc/controls/ChipAndChipGroup.md index b2534dd79..21e7cd51e 100644 --- a/doc/controls/ChipAndChipGroup.md +++ b/doc/controls/ChipAndChipGroup.md @@ -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 @@ -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.
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. diff --git a/doc/helpers/itemsrepeater-extensions.md b/doc/helpers/itemsrepeater-extensions.md new file mode 100644 index 000000000..42084ba8f --- /dev/null +++ b/doc/helpers/itemsrepeater-extensions.md @@ -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\|Two-ways bindable property for the current selected items.\* +SelectedIndexes|IList\|Two-ways bindable property for the current selected indexes.\* +SelectionMode|ItemsSelectionMode|Gets or sets the selection behavior: `None`, `SingleOrNone`, `Single`, `Multiple`
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" +... + + + + + + + + + + + + + +``` + +### 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. diff --git a/doc/toc.yml b/doc/toc.yml index e988a8545..fa8f14bec 100644 --- a/doc/toc.yml +++ b/doc/toc.yml @@ -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 diff --git a/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs b/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs new file mode 100644 index 000000000..dcce720cd --- /dev/null +++ b/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs @@ -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 + { + /// + /// Matches right before the > or \> tail of any tag. + /// + /// + /// It will match an opening or closing or self-closing tag. + /// + private static readonly Regex EndOfTagRegex = new Regex(@"(?=( ?/)?>)"); + + /// + /// Matches any tag without xmlns prefix. + /// + private static readonly Regex NonXmlnsTagRegex = new Regex(@"<\w+[ />]"); + + private static readonly IReadOnlyDictionary KnownXmlnses = new Dictionary + { + [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", + }; + + /// + /// XamlReader.Load the xaml and type-check result. + /// + /// Xaml with single or double quotes + /// Toggle automatic detection of xmlns required and inject to the xaml + public static T LoadXaml(string xaml, bool autoInjectXmlns = true) where T : class + { + var xmlnses = new Dictionary(); + + 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(xaml, xmlnses); + } + + /// + /// XamlReader.Load the xaml and type-check result. + /// + /// Xaml with single or double quotes + /// Xmlns to inject; use string.Empty for the default xmlns' key + public static T LoadXaml(string xaml, Dictionary 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; + } + } +} diff --git a/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs b/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs new file mode 100644 index 000000000..6338eb7e0 --- /dev/null +++ b/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs @@ -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().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(""" + + + + """), + }; + 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() + .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."); + } + } +} diff --git a/src/Uno.Toolkit.UI/Behaviors/ItemsRepeaterExtensions.cs b/src/Uno.Toolkit.UI/Behaviors/ItemsRepeaterExtensions.cs index 3f065a7da..64fc18596 100644 --- a/src/Uno.Toolkit.UI/Behaviors/ItemsRepeaterExtensions.cs +++ b/src/Uno.Toolkit.UI/Behaviors/ItemsRepeaterExtensions.cs @@ -1,66 +1,558 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using System.Windows.Input; +using Microsoft.Extensions.Logging; +using Uno.Disposables; +using Uno.Extensions; +using Uno.Logging; #if IS_WINUI using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Input; #else using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Controls.Primitives; using Windows.UI.Xaml.Input; using ItemsRepeater = Microsoft.UI.Xaml.Controls.ItemsRepeater; #endif -namespace Uno.Toolkit.UI +namespace Uno.Toolkit.UI; + +public static partial class ItemsRepeaterExtensions { - public static partial class ItemsRepeaterExtensions + private static ILogger _logger { get; } = typeof(CommandExtensions).Log(); + + #region DependencyProperty: IsSynchronizingSelection + + private static DependencyProperty IsSynchronizingSelectionProperty { [DynamicDependency(nameof(GetIsSynchronizingSelection))] get; } = DependencyProperty.RegisterAttached( + "IsSynchronizingSelection", + typeof(bool), + typeof(ItemsRepeaterExtensions), + new PropertyMetadata(default(bool))); + + [DynamicDependency(nameof(SetIsSynchronizingSelection))] + private static bool GetIsSynchronizingSelection(ItemsRepeater obj) => (bool)obj.GetValue(IsSynchronizingSelectionProperty); + [DynamicDependency(nameof(GetIsSynchronizingSelection))] + private static void SetIsSynchronizingSelection(ItemsRepeater obj, bool value) => obj.SetValue(IsSynchronizingSelectionProperty, value); + + #endregion + #region DependencyProperty: SelectedItem + + public static DependencyProperty SelectedItemProperty { [DynamicDependency(nameof(GetSelectedItem))] get; } = DependencyProperty.RegisterAttached( + "SelectedItem", + typeof(object), + typeof(ItemsRepeaterExtensions), + new PropertyMetadata(default(object), OnSelectedItemChanged)); + + [DynamicDependency(nameof(SetSelectedItem))] + public static object? GetSelectedItem(ItemsRepeater obj) => obj.GetValue(SelectedItemProperty); + [DynamicDependency(nameof(GetSelectedItem))] + public static void SetSelectedItem(ItemsRepeater obj, object? value) => obj.SetValue(SelectedItemProperty, value); + + #endregion + #region DependencyProperty: SelectedItems + + public static DependencyProperty SelectedItemsProperty { [DynamicDependency(nameof(GetSelectedItems))] get; } = DependencyProperty.RegisterAttached( + "SelectedItems", + typeof(IList), + typeof(ItemsRepeaterExtensions), + new PropertyMetadata(default(IList), OnSelectedItemsChanged)); + + [DynamicDependency(nameof(SetSelectedItems))] + public static IList GetSelectedItems(ItemsRepeater obj) => (IList)obj.GetValue(SelectedItemsProperty); + [DynamicDependency(nameof(GetSelectedItems))] + public static void SetSelectedItems(ItemsRepeater obj, IList value) => obj.SetValue(SelectedItemsProperty, value); + + #endregion + #region DependencyProperty: SelectedIndex = -1 + + public static DependencyProperty SelectedIndexProperty { [DynamicDependency(nameof(GetSelectedIndex))] get; } = DependencyProperty.RegisterAttached( + "SelectedIndex", + typeof(int), + typeof(ItemsRepeaterExtensions), + new PropertyMetadata(-1, OnSelectedIndexChanged)); + + [DynamicDependency(nameof(SetSelectedIndex))] + public static int GetSelectedIndex(ItemsRepeater obj) => (int)obj.GetValue(SelectedIndexProperty); + [DynamicDependency(nameof(GetSelectedIndex))] + public static void SetSelectedIndex(ItemsRepeater obj, int value) => obj.SetValue(SelectedIndexProperty, value); + + #endregion + #region DependencyProperty: SelectedIndexes + + public static DependencyProperty SelectedIndexesProperty { [DynamicDependency(nameof(GetSelectedIndexes))] get; } = DependencyProperty.RegisterAttached( + "SelectedIndexes", + typeof(IList), + typeof(ItemsRepeaterExtensions), + new PropertyMetadata(default(IList), OnSelectedIndexesChanged)); + + [DynamicDependency(nameof(SetSelectedIndexes))] + public static IList GetSelectedIndexes(ItemsRepeater obj) => (IList)obj.GetValue(SelectedIndexesProperty); + [DynamicDependency(nameof(GetSelectedIndexes))] + public static void SetSelectedIndexes(ItemsRepeater obj, IList value) => obj.SetValue(SelectedIndexesProperty, value); + + #endregion + #region DependencyProperty: SelectionMode = (ItemsSelectionMode)0 + + public static DependencyProperty SelectionModeProperty { [DynamicDependency(nameof(GetSelectionMode))] get; } = DependencyProperty.RegisterAttached( + "SelectionMode", + typeof(ItemsSelectionMode), + typeof(ItemsRepeaterExtensions), + new PropertyMetadata((ItemsSelectionMode)0, OnSelectionModeChanged)); + + [DynamicDependency(nameof(SetSelectionMode))] + public static ItemsSelectionMode GetSelectionMode(ItemsRepeater obj) => (ItemsSelectionMode)obj.GetValue(SelectionModeProperty); + [DynamicDependency(nameof(GetSelectionMode))] + public static void SetSelectionMode(ItemsRepeater obj, ItemsSelectionMode value) => obj.SetValue(SelectionModeProperty, value); + + #endregion + #region DependencyProperty: SelectionSubscription + + private static DependencyProperty SelectionSubscriptionProperty { [DynamicDependency(nameof(GetSelectionSubscription))] get; } = DependencyProperty.RegisterAttached( + "SelectionSubscription", + typeof(IDisposable), + typeof(ItemsRepeaterExtensions), + new PropertyMetadata(default(IDisposable))); + + [DynamicDependency(nameof(SetSelectionSubscription))] + private static IDisposable GetSelectionSubscription(ItemsRepeater obj) => (IDisposable)obj.GetValue(SelectionSubscriptionProperty); + [DynamicDependency(nameof(GetSelectionSubscription))] + private static void SetSelectionSubscription(ItemsRepeater obj, IDisposable value) => obj.SetValue(SelectionSubscriptionProperty, value); + + #endregion + + #region ItemCommand Impl + internal static void OnItemCommandChanged(ItemsRepeater sender, DependencyPropertyChangedEventArgs e) + { + if (e.OldValue is ICommand) + { + if (e.NewValue is not ICommand) // tear down + { + sender.Tapped -= OnItemsRepeaterCommandTapped; + } + else + { + // When transitioning from one command to another, there is no need to rewire the event. + // Since the handler is setup to invoke the command in the DP. + } + } + else if (e.NewValue is ICommand command) + { + sender.Tapped += OnItemsRepeaterCommandTapped; + } + } + + private static void OnItemsRepeaterCommandTapped(object sender, TappedRoutedEventArgs e) + { + // ItemsRepeater is more closely related to Panel than ItemsControl, and it cannot be templated. + // It is safe to assume all direct children of IR are materialized item template, + // and there can't be header/footer or wrapper (ItemContainer) among them. + + if (sender is not ItemsRepeater ir) return; + if (e.OriginalSource is ItemsRepeater) return; + if (e.OriginalSource is DependencyObject source) + { + if (ir.FindRootElementOf(source) is FrameworkElement root) + { + CommandExtensions.TryInvokeCommand(ir, CommandExtensions.GetCommandParameter(root) ?? root.DataContext); + } + } + } + #endregion + + // ItemsRepeater is more closely related to Panel than ItemsControl, and it cannot be templated. + // It is safe to assume all direct children of IR are materialized item template, + // and there can't be header/footer or wrapper (ItemContainer) among them. + + // ItemsRepeater.ItemsSource can contain UIElement, although it is not possible to define directly in xaml like you can with ListView+LVI. + // In the above case, the UIElement will be nested directly under the IR, unless an IR.ItemTemplate has been defined. + + // ItemsRepeater's children contains only materialized element; materialization and de-materialization can be track with + // ElementPrepared and ElementClearing events. Recycled elements are reused based on FIFO-rule, resulting in index desync. + // Selection state saved on the element (LVI.IsSelect, Chip.IsChecked) will also desync when it happens. + // !!! So it is important to save the selection state into a dp, and validate against that on element materialization and correct when necessary. + + // Unlike ToggleButton (or Chip which derives from), SelectorItem is not normally selectable on click, unless nested under a Selector. + + private static void OnSelectedItemChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => OnSelectionStateChanged(sender, e); + private static void OnSelectedItemsChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => OnSelectionStateChanged(sender, e); + private static void OnSelectedIndexChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => OnSelectionStateChanged(sender, e); + private static void OnSelectedIndexesChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => OnSelectionStateChanged(sender, e); + + private static void OnSelectionModeChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) + { + if (sender is not ItemsRepeater ir) return; + + if (e.NewValue is ItemsSelectionMode value && value != 0) + { + // Skip events setup, when we are just switching between valid modes. + if (e.OldValue is ItemsSelectionMode oldValue && oldValue == 0) + { + ir.Tapped += OnItemsRepeaterTapped; + ir.ElementPrepared += OnItemsRepeaterElementPrepared; + + SetSelectionSubscription(ir, new CompositeDisposable( + Disposable.Create(() => + { + ir.Tapped -= OnItemsRepeaterTapped; + ir.ElementPrepared -= OnItemsRepeaterElementPrepared; + }), + ir.RegisterDisposablePropertyChangedCallback(ItemsRepeater.ItemsSourceProperty, OnItemsRepeaterItemsSourceChanged) + )); + } + + if (ir.ItemsSourceView is { Count: > 0 }) + { + try + { + SetIsSynchronizingSelection(ir, true); + + TrySynchronizeDefaultSelection(ir); + SynchronizeMaterializedElementsSelection(ir); + } + finally + { + SetIsSynchronizingSelection(ir, false); + } + } + } + else + { + GetSelectionSubscription(ir)?.Dispose(); + } + } + private static void OnSelectionStateChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) + { + if (sender is not ItemsRepeater ir) return; + if (GetIsSynchronizingSelection(ir)) return; + + try + { + SetIsSynchronizingSelection(ir, true); + + if (ir.ItemsSourceView is { Count: > 0 }) + { + var indexes = MapStateToIndexes(); + if (indexes.All(x => 0 <= x && x < ir.ItemsSourceView.Count)) + { + SetSelectionStates(ir, indexes); + } + else + { + ir.SetValue(e.Property, e.OldValue); + } + } + else + { + SetEmptySelection(ir); + } + } + finally + { + SetIsSynchronizingSelection(ir, false); + } + + int[] MapStateToIndexes() + { + if (e.Property == SelectedItemProperty || e.Property == SelectedItemsProperty) + { + if (e.NewValue == null) return Array.Empty(); + + var selectedItems = e.Property == SelectedItemsProperty + ? new[] { e.NewValue } + : (IList)e.NewValue; + +#if HAS_UNO || true // ItemsSourceView::IndexOf is not defined + return Enumerable.Join( + Enumerable.Range(0, ir.ItemsSourceView.Count), // all valid indexes + selectedItems, + ir.ItemsSourceView.GetAt, + x => x, + (index, item) => index + ).ToArray(); +#else + return items.Select(ir.ItemsSourceView.IndexOf).ToArray(); +#endif + } + else if (e.Property == SelectedIndexProperty) + { + if (e.NewValue is int index && index != -1) + { + return new int[] { index }; + } + else + { + return Array.Empty(); + } + } + else if (e.Property == SelectedIndexesProperty) + { + // Out-of-bounds values will be handled by the outside block + return (e.NewValue as IList)?.ToArray() ?? Array.Empty(); + } + else + { + throw new InvalidOperationException($"Unknown selection state property: {e.Property}"); + } + } + } + + private static void OnItemsRepeaterElementPrepared(ItemsRepeater sender, Microsoft.UI.Xaml.Controls.ItemsRepeaterElementPreparedEventArgs args) + { + // When we reach here, it should be guaranteed that default selection state has been applied, + // and we can rely on it to synchronize the selection on the view-level. + var selected = GetSelectedIndexes(sender)?.Contains(args.Index) ?? false; + + SetItemSelection(args.Element, selected); + } + private static void OnItemsRepeaterItemsSourceChanged(DependencyObject sender, DependencyProperty dp) + { + // When we reach here, ItemsSourceView is already updated. + if (sender is not ItemsRepeater ir) return; + if (ir.ItemsSourceView is { Count: > 0 }) + { + try + { + SetIsSynchronizingSelection(ir, true); + + TrySynchronizeDefaultSelection(ir, shouldBaseFromSelectionState: false); + // Unlike in OnSelectionModeChanged, ir.GetChildren() still holds old materialized elements from previous ItemsSource. + // There is nothing to do here; We can just let OnItemsRepeaterElementPrepared to handle the rest. + } + finally + { + SetIsSynchronizingSelection(ir, false); + } + } + } + private static void OnItemsRepeaterTapped(object sender, TappedRoutedEventArgs e) { - internal static void OnItemCommandChanged(ItemsRepeater sender, DependencyPropertyChangedEventArgs e) + // By the time we are here, ToggleButton.IsChecked would have already been toggled. + + if (sender is not ItemsRepeater ir) return; + if (e.OriginalSource is ItemsRepeater) return; + if (e.OriginalSource is DependencyObject source) { - if (e.OldValue is ICommand) + if (ir.FindRootElementOf(source) is { } element) { - if (e.NewValue is not ICommand) // tear down + ToggleItemSelectionAtCoerced(ir, ir.GetElementIndex(element)); + } + } + } + + // note: TryCoerceSelectionFromXyz methods should never accept empty-selection as result, so that the next non-empty property can be evaluated. + // Even if all 4 properties fail for being empty, TrySetDefaultSelection or SetEmptySelection can pick from there. + private static bool TrySynchronizeDefaultSelection(ItemsRepeater ir, bool shouldBaseFromSelectionState = true) + { + if (ir.ItemsSourceView is not { Count: > 0 }) return false; + if (GetSelectionMode(ir) is var mode && mode != 0) + { + if (mode is ItemsSelectionMode.None) + { + return SetEmptySelection(ir); + } + else if (mode is ItemsSelectionMode.Multiple) + { + if (shouldBaseFromSelectionState) { - sender.Tapped -= OnItemsRepeaterTapped; + return TryCoerceSelectionFromIndexes(ir) + || TryCoerceSelectionFromItems(ir) + || TryCoerceSelectionFromIndex(ir) + || TryCoerceSelectionFromItem(ir) + || SetEmptySelection(ir); } else { - // When transitioning from one command to another, there is no need to rewire the event. - // Since the handler is setup to invoke the command in the DP. + return SetEmptySelection(ir); + } + } - else if (e.NewValue is ICommand command) + else if (mode is ItemsSelectionMode.Single or ItemsSelectionMode.SingleOrNone) { - sender.Tapped += OnItemsRepeaterTapped; + if (shouldBaseFromSelectionState) + { + return TryCoerceSelectionFromIndex(ir) + || TryCoerceSelectionFromItem(ir) + || TryCoerceSelectionFromIndexes(ir) + || TryCoerceSelectionFromItems(ir) + || TrySetDefaultSelection(ir) + || SetEmptySelection(ir); + } + else + { + return TrySetDefaultSelection(ir) + || SetEmptySelection(ir); + } + } + else + { + throw new ArgumentOutOfRangeException($"ItemsSelectionMode: {mode}"); } } - private static void OnItemsRepeaterTapped(object sender, TappedRoutedEventArgs e) + return false; + } + private static bool TryCoerceSelectionFromIndex(ItemsRepeater ir) + { + if (GetSelectedIndex(ir) is { } index && + 0 <= index && index < ir.ItemsSourceView.Count) { - // ItemsRepeater is more closely related to Panel than ItemsControl, and it cannot be templated. - // It is safe to assume all direct children of IR are materialized item template, - // and there can't be header/footer or wrapper (ItemContainer) among them. + return SetSelectionStates(ir, index); + } - if (sender is not ItemsRepeater owner) return; - if (e.OriginalSource is ItemsRepeater) return; - if (e.OriginalSource is DependencyObject source) + return false; + } + private static bool TryCoerceSelectionFromItem(ItemsRepeater ir) + { + if (GetSelectedItem(ir) is { } item && + ir.ItemsSourceView.IndexOf(item) is { } index && index != -1) + { + return SetSelectionStates(ir, index); + } + + return false; + } + private static bool TryCoerceSelectionFromIndexes(ItemsRepeater ir) + { + // note: Do not accept partially matched indexes/items. + if (GetSelectedIndexes(ir) is { Count: > 0 } indexes && + indexes.All(x => 0 <= x && x <= ir.ItemsSourceView.Count)) + { + return SetSelectionStates(ir, indexes.ToArray()); + } + + return false; + } + private static bool TryCoerceSelectionFromItems(ItemsRepeater ir) + { + // note: Do not accept partially matched indexes/items. + if (GetSelectedItems(ir) is { Count: > 0 } items && + items.Select(ir.ItemsSourceView.IndexOf).ToArray() is { } indexes && + indexes.All(x => 0 <= x && x <= ir.ItemsSourceView.Count)) + { + return SetSelectionStates(ir, indexes.ToArray()); + } + + return false; + } + private static bool TrySetDefaultSelection(ItemsRepeater ir) + { + if (GetSelectionMode(ir) is ItemsSelectionMode.Single) + { + if (ir.ItemsSourceView is { Count: > 0 } isv) + { + return SetSelectionStates(ir, indexes: 0); + } + } + + return false; + } + private static bool SetEmptySelection(ItemsRepeater ir) + { + return SetSelectionStates(ir, indexes: null); + } + private static bool SetSelectionStates(ItemsRepeater ir, params int[]? indexes) + { + if (indexes is { Length: > 0 }) + { + var items = indexes.Select(ir.ItemsSourceView.GetAt).ToArray(); + + SetSelectedIndex(ir, indexes[0]); + SetSelectedItem(ir, items[0]); + SetSelectedIndexes(ir, indexes); + SetSelectedItems(ir, items); + } + else + { + SetSelectedIndex(ir, -1); + SetSelectedItem(ir, null); + SetSelectedIndexes(ir, Array.Empty()); + SetSelectedItems(ir, Array.Empty()); + } + + return true; + } + + private static void SynchronizeMaterializedElementsSelection(ItemsRepeater ir) + { + if (GetSelectedIndexes(ir) is not { } indexes) throw new InvalidOperationException("Selection state is not ready."); + + foreach (var element in ir.GetChildren()) + { + if (element is UIElement uie && + ir.GetElementIndex(uie) is var index && index != -1) + { + SetItemSelection(uie, indexes.Contains(index)); + } + } + } + internal static void ToggleItemSelectionAtCoerced(ItemsRepeater ir, int index) + { + if (GetIsSynchronizingSelection(ir)) + { + if (_logger.IsEnabled(LogLevel.Warning)) { - // e.OriginalSource is the top-most element under the cursor. - // In order to find the materialized item template, we have to walk up the visual-tree, to the first element right below IR: - // ItemsRepeater > (item template root) > (layer0...n) > (tapped element) - var element = source.GetAncestors(includeCurrent: true) - .ZipSkipOne() - .FirstOrDefault(x => x.Current is ItemsRepeater) - .Previous; - if (element is FrameworkElement fe) + _logger.Warn($"ToggleItemSelectionAtCoerced is invoked during selection synchronization."); + } + return; + } + + try + { + SetIsSynchronizingSelection(ir, true); + + var selection = GetSelectedIndexes(ir) ?? Array.Empty(); + var updated = ItemsSelectionHelper.ToggleSelectionAtCoerced( + GetSelectionMode(ir), + ir.ItemsSourceView.Count, + selection, + index + ); + var diffIndexes = new[] { index } // even if selection at index remains unchanged, the element is out of sync already from ToggleButton impl. + .Concat(updated.Except(selection)) + .Concat(selection.Except(updated)) + .Distinct() + .ToArray(); + + SetSelectionStates(ir, updated); + foreach (var diffIndex in diffIndexes) + { + if (ir.TryGetElement(diffIndex) is { } materialized) { - CommandExtensions.TryInvokeCommand(owner, CommandExtensions.GetCommandParameter(fe) ?? fe.DataContext); + SetItemSelection(materialized, updated.Contains(diffIndex)); + } + else + { + // non-materialized element will be handled by OnItemsRepeaterElementPrepared when its is materializing } } + + } + finally + { + SetIsSynchronizingSelection(ir, false); + } + } + internal static void SetItemSelection(DependencyObject x, bool value) + { + if (x is SelectorItem si) + { + si.IsSelected = value; + } + else if (x is ToggleButton toggle) + { + toggle.IsChecked = value; + } + else + { + // todo: generic item is not supported } } } diff --git a/src/Uno.Toolkit.UI/Controls/Chips/Chip.Members.cs b/src/Uno.Toolkit.UI/Controls/Chips/Chip.Members.cs index 0d17d8004..e0814b9d4 100644 --- a/src/Uno.Toolkit.UI/Controls/Chips/Chip.Members.cs +++ b/src/Uno.Toolkit.UI/Controls/Chips/Chip.Members.cs @@ -94,28 +94,6 @@ public DataTemplate? IconTemplate #endregion - #region DependencyProperty: IsCheckable = true - - public static DependencyProperty IsCheckableProperty { get; } = DependencyProperty.Register( - nameof(IsCheckable), - typeof(bool), - typeof(Chip), - new PropertyMetadata(true, (s, e) => (s as Chip)?.OnIsCheckableChanged(e))); - - /// - /// Gets or sets whether the chip can be checked. Used to prevent showing selection state. - /// - /// - /// When nested under the , this property will be overwritten by . - /// - public bool IsCheckable - { - get => (bool)GetValue(IsCheckableProperty); - set => SetValue(IsCheckableProperty, value); - } - - #endregion - #region DependencyProperty: RemovedCommand public static DependencyProperty RemovedCommandProperty { get; } = DependencyProperty.Register( diff --git a/src/Uno.Toolkit.UI/Controls/Chips/Chip.cs b/src/Uno.Toolkit.UI/Controls/Chips/Chip.cs index d3d18d073..820702b55 100644 --- a/src/Uno.Toolkit.UI/Controls/Chips/Chip.cs +++ b/src/Uno.Toolkit.UI/Controls/Chips/Chip.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Windows.Input; @@ -11,6 +12,7 @@ using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Controls.Primitives; +using ItemsRepeater = Microsoft.UI.Xaml.Controls.ItemsRepeater; #endif namespace Uno.Toolkit.UI @@ -20,7 +22,7 @@ public partial class Chip : ToggleButton { private const string RemoveButtonName = "PART_RemoveButton"; - private bool _isMuted; + private bool _shouldRaiseIsCheckedChanged; private ChipGroup? ChipGroup => ItemsControl.ItemsControlFromItemContainer(this) as ChipGroup; @@ -40,9 +42,9 @@ protected override void OnApplyTemplate() } } - private void OnIsCheckableChanged(DependencyPropertyChangedEventArgs e) + internal void OnChipSelectionModeChanged(DependencyPropertyChangedEventArgs e) { - if (!IsCheckable) + if (e.NewValue is ChipSelectionMode.None) { IsChecked = false; } @@ -50,7 +52,7 @@ private void OnIsCheckableChanged(DependencyPropertyChangedEventArgs e) private void RaiseIsCheckedChanged(object sender, RoutedEventArgs e) { - if (!_isMuted) + if (!_shouldRaiseIsCheckedChanged) { IsCheckedChanged?.Invoke(sender, e); } @@ -83,19 +85,24 @@ internal void SetIsCheckedSilently(bool? value) { try { - _isMuted = true; + _shouldRaiseIsCheckedChanged = true; IsChecked = value; } finally { - _isMuted = false; + _shouldRaiseIsCheckedChanged = false; } } protected override void OnToggle() { - if (!IsCheckable) return; - if (IsChecked == true && ChipGroup?.SelectionMode == ChipSelectionMode.Single) + var mode = ChipGroup?.SelectionMode; + if (mode is ChipSelectionMode.None) + { + SetIsCheckedSilently(false); + return; + } + if (mode is ChipSelectionMode.Single && IsChecked == true) { return; } diff --git a/src/Uno.Toolkit.UI/Controls/Chips/ChipGroup.cs b/src/Uno.Toolkit.UI/Controls/Chips/ChipGroup.cs index 3471f604d..a257b3281 100644 --- a/src/Uno.Toolkit.UI/Controls/Chips/ChipGroup.cs +++ b/src/Uno.Toolkit.UI/Controls/Chips/ChipGroup.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Text; -using Uno.Extensions.Specialized; #if IS_WINUI using Microsoft.UI.Xaml; @@ -121,7 +120,7 @@ private void OnSelectedItemChanged() Chip? GetCoercedSelection() => FindContainer(SelectedItem) ?? (SelectionMode is ChipSelectionMode.Single ? GetFallbackSelection() : default); - Chip? GetFallbackSelection () => + Chip? GetFallbackSelection() => GetItemContainers().FirstOrDefault(x => x.IsChecked == true) ?? GetItemContainers().FirstOrDefault(); } @@ -293,8 +292,6 @@ private void EnforceSelectionMode() var selected = default(Chip); foreach (var container in GetItemContainers()) { - container.IsCheckable = SelectionMode != ChipSelectionMode.None; - if (IsSingleSelection && container.IsChecked == true) { // preserve first existing selection and clear the rest @@ -393,7 +390,7 @@ bool ShouldClearSelection(Chip container) private bool IsReady => _isLoaded && HasItems && HasContainers; - private bool HasItems => GetItems().Any(); + private bool HasItems => GetItems().OfType().Any(); private bool HasContainers => GetItemContainers().Any(); @@ -416,7 +413,7 @@ ItemsSource as IEnumerable ?? // Because of this, we retrieve the container using the index instead. if (item is Enum) { - var index = GetItems().IndexOf(item); + var index = GetItems().OfType().ToList().IndexOf(item); if (index != -1) { return ContainerFromIndex(index) as Chip; @@ -435,6 +432,5 @@ item as Chip ?? private IEnumerable GetItemContainers() => ItemsPanelRoot?.Children.OfType() ?? Enumerable.Empty(); - } } diff --git a/src/Uno.Toolkit.UI/Helpers/ItemsSelectionHelper.cs b/src/Uno.Toolkit.UI/Helpers/ItemsSelectionHelper.cs new file mode 100644 index 000000000..a6b95ded5 --- /dev/null +++ b/src/Uno.Toolkit.UI/Helpers/ItemsSelectionHelper.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +#if IS_WINUI +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +#else +using Windows.UI.Xaml; +using ItemsSourceView = Microsoft.UI.Xaml.Controls.ItemsSourceView; +using ItemsRepeater = Microsoft.UI.Xaml.Controls.ItemsRepeater; +#endif + +namespace Uno.Toolkit.UI; + +public enum ItemsSelectionMode +{ + None = 1, + SingleOrNone, + Single, + Multiple, +} + +internal static class ItemsSelectionHelper +{ +#if HAS_UNO + public static int IndexOf(this ItemsSourceView isv, object item) + { + return Enumerable.Range(0, isv.Count) + .Cast() + .FirstOrDefault(x => isv.GetAt((int)x!) == item) ?? -1; + } +#endif + + public static int[] ToggleSelectionAtCoerced(ItemsSelectionMode mode, int length, IList selection, int index) + { + if (length < 0) throw new ArgumentOutOfRangeException(nameof(length)); + if (0 > index || index >= length) throw new ArgumentOutOfRangeException(nameof(index)); + + if (mode is ItemsSelectionMode.None) + { + return Array.Empty(); + } + else if (mode is ItemsSelectionMode.Single or ItemsSelectionMode.SingleOrNone) + { + var wasSelected = selection.Contains(index); + if (wasSelected && mode is ItemsSelectionMode.Single) // prevent deselection + { + return new int[] { index }; + } + + return wasSelected ? Array.Empty() : new int[] { index }; + } + else if (mode is ItemsSelectionMode.Multiple) + { + var wasSelected = selection.Contains(index); + var newSelection = wasSelected + ? selection.Where(x => x != index) + : selection.Append(index); + + return newSelection.ToArray(); + } + else + { + throw new ArgumentOutOfRangeException(nameof(mode)); + } + } + + public static UIElement? FindRootElementOf(this ItemsRepeater ir, DependencyObject node) + { + // e.OriginalSource is the top-most element under the cursor. + // In order to find the materialized element, we have to walk up the visual-tree, to the first element right below IR: + // ItemsRepeater > (item template root) > (layer0...n) > (tapped element) + return node.GetAncestors(includeCurrent: true) + .ZipSkipOne() + .FirstOrDefault(x => x.Current is ItemsRepeater) + .Previous as UIElement; + } +} diff --git a/src/library/Uno.Toolkit.Material/Styles/Controls/v1/Chip.xaml b/src/library/Uno.Toolkit.Material/Styles/Controls/v1/Chip.xaml index febcdfa59..3390640d1 100644 --- a/src/library/Uno.Toolkit.Material/Styles/Controls/v1/Chip.xaml +++ b/src/library/Uno.Toolkit.Material/Styles/Controls/v1/Chip.xaml @@ -357,8 +357,6 @@ - @@ -382,8 +380,6 @@ Value="{StaticResource MaterialOnSurfaceMediumBrush}" /> - @@ -596,8 +592,6 @@ BasedOn="{StaticResource BaseMaterialFilledChipStyle}"> - @@ -712,8 +706,6 @@ BasedOn="{StaticResource BaseMaterialOutlinedChipStyle}"> - diff --git a/src/library/Uno.Toolkit.Material/Styles/Controls/v2/Chip.xaml b/src/library/Uno.Toolkit.Material/Styles/Controls/v2/Chip.xaml index 07bae05ca..6aff1dd33 100644 --- a/src/library/Uno.Toolkit.Material/Styles/Controls/v2/Chip.xaml +++ b/src/library/Uno.Toolkit.Material/Styles/Controls/v2/Chip.xaml @@ -304,8 +304,6 @@ Value="{StaticResource OutlineBrush}" /> - @@ -319,8 +317,6 @@ Value="{StaticResource MaterialChipElevation}" /> - @@ -334,8 +330,6 @@ Value="1" /> - @@ -376,8 +366,6 @@ Value="1" /> -