diff --git a/doc/helpers/itemsrepeater-extensions.md b/doc/helpers/itemsrepeater-extensions.md index c785b5a31..c2be70de7 100644 --- a/doc/helpers/itemsrepeater-extensions.md +++ b/doc/helpers/itemsrepeater-extensions.md @@ -7,11 +7,14 @@ Provides selection support for `ItemsRepeater`. ## Properties Property|Type|Description -|-|- +IsSelectionHost|bool|Used to mark an element within the ItemsRepeater.ItemTemplate to be the host control that will handle the selection.\* 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. +UseNestedSelectionHost|bool|Used to signal a selection-host should be found in the ItemTemplate, and it would replace the item template root.\* + ### Remarks - `Selected-`properties only takes effect when `SelectionMode` is set to a valid value that is not `None`. @@ -21,6 +24,14 @@ SelectionMode|ItemsSelectionMode|Gets or sets the selection behavior: `None`, `S - `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. +- Use `IsSelectionHost` and `UseNestedSelectionHost` when the target of selection cannot be the root element of the ItemTemplate: + ```xml + + + + + + ``` ## Usage ```xml @@ -45,4 +56,5 @@ 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`). + - Use `IsSelectionHost` and `UseNestedSelectionHost` when the target of selection cannot be the root element of the ItemTemplate. - `RadioButton`: Multiple mode is not supported due to control limitation. diff --git a/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs b/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs index 5b52b459f..81f93f2d3 100644 --- a/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs +++ b/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs @@ -153,7 +153,7 @@ internal static ItemsRepeater SetupItemsRepeater(object source, ItemsSelectionMo ItemsSource = source, ItemTemplate = XamlHelper.LoadXaml(""" - + """), }; diff --git a/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterExtensionTests.cs b/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterExtensionTests.cs index dc790af97..0c0b82d74 100644 --- a/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterExtensionTests.cs +++ b/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterExtensionTests.cs @@ -5,10 +5,17 @@ using System.Text; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Windows.UI.Xaml; using Uno.Toolkit.RuntimeTests.Helpers; using Uno.Toolkit.UI; using Uno.UI.RuntimeTests; + +#if IS_WINUI +using Microsoft.UI.Xaml; +#else +using Windows.UI.Xaml; +#endif + +using ChipControl = Uno.Toolkit.UI.Chip; // ios/macos: to avoid collision with `global::Chip` namespace... using ItemsRepeater = Microsoft.UI.Xaml.Controls.ItemsRepeater; using static Uno.Toolkit.RuntimeTests.Tests.ItemsRepeaterChipTests; // to borrow helper methods @@ -40,4 +47,31 @@ public async Task When_Selection_Property_Changed(string property) })(); Assert.AreEqual(true, IsChipSelectedAt(SUT, 1)); } + + [TestMethod] + public async Task When_NestedSelectionHost() + { + var source = Enumerable.Range(0, 3).ToList(); + var SUT = new ItemsRepeater + { + ItemsSource = source, + ItemTemplate = XamlHelper.LoadXaml(""" + + + + + + """), + }; + ItemsRepeaterExtensions.SetUseNestedSelectionHost(SUT, true); + ItemsRepeaterExtensions.SetSelectionMode(SUT, ItemsSelectionMode.Single); + ItemsRepeaterExtensions.SetSelectedIndex(SUT, 1); + + await UnitTestUIContentHelperEx.SetContentAndWait(SUT); + + var root = SUT.TryGetElement(1); + var chip = root?.FindChild(); + + Assert.IsTrue(chip?.IsChecked == true); + } } diff --git a/src/Uno.Toolkit.UI/Behaviors/ItemsRepeaterExtensions.cs b/src/Uno.Toolkit.UI/Behaviors/ItemsRepeaterExtensions.cs index 0591f207e..a5577f291 100644 --- a/src/Uno.Toolkit.UI/Behaviors/ItemsRepeaterExtensions.cs +++ b/src/Uno.Toolkit.UI/Behaviors/ItemsRepeaterExtensions.cs @@ -1,7 +1,12 @@ -using System; +#if __ANDROID__ || NETSTANDARD // (NETSTD contains both wasm+skia; only wasm is needed, and the check is done at runtime) +#define APPLY_UNO12632_WORKAROUND +#endif + +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Windows.Input; using Microsoft.Extensions.Logging; @@ -28,6 +33,27 @@ public static partial class ItemsRepeaterExtensions { private static ILogger _logger { get; } = typeof(CommandExtensions).Log(); + #region DependencyProperty: IsSelectionHost + + /// + /// Property used to mark an element within the ItemsRepeater.ItemTemplate to be the host control that will handle the selection. + /// + /// + /// This is used when the target of selection cannot be the root element of the ItemTemplate. + /// Note that should also be set on the ItemRepeater when using this property. + /// + public static DependencyProperty IsSelectionHostProperty { [DynamicDependency(nameof(GetIsSelectionHost))] get; } = DependencyProperty.RegisterAttached( + "IsSelectionHost", + typeof(bool), + typeof(ItemsRepeaterExtensions), + new PropertyMetadata(default(bool))); + + [DynamicDependency(nameof(SetIsSelectionHost))] + public static bool GetIsSelectionHost(DependencyObject obj) => (bool)obj.GetValue(IsSelectionHostProperty); + [DynamicDependency(nameof(GetIsSelectionHost))] + public static void SetIsSelectionHost(DependencyObject obj, bool value) => obj.SetValue(IsSelectionHostProperty, value); + + #endregion #region DependencyProperty: IsSynchronizingSelection private static DependencyProperty IsSynchronizingSelectionProperty { [DynamicDependency(nameof(GetIsSynchronizingSelection))] get; } = DependencyProperty.RegisterAttached( @@ -126,6 +152,24 @@ public static partial class ItemsRepeaterExtensions private static void SetSelectionSubscription(ItemsRepeater obj, IDisposable value) => obj.SetValue(SelectionSubscriptionProperty, value); #endregion + #region DependencyProperty: UseNestedSelectionHost + + /// + /// Property used to signal a selection-host should be found in the ItemTemplate, and it would replace the item template root. + /// + public static DependencyProperty UseNestedSelectionHostProperty { [DynamicDependency(nameof(GetUseNestedSelectionHost))] get; } = DependencyProperty.RegisterAttached( + "UseNestedSelectionHost", + typeof(bool), + typeof(ItemsRepeaterExtensions), + new PropertyMetadata(default(bool))); + + [DynamicDependency(nameof(SetUseNestedSelectionHost))] + public static bool GetUseNestedSelectionHost(DependencyObject obj) => (bool)obj.GetValue(UseNestedSelectionHostProperty); + [DynamicDependency(nameof(GetUseNestedSelectionHost))] + public static void SetUseNestedSelectionHost(DependencyObject obj, bool value) => obj.SetValue(UseNestedSelectionHostProperty, value); + + #endregion + #region ItemCommand Impl internal static void OnItemCommandChanged(ItemsRepeater sender, DependencyPropertyChangedEventArgs e) @@ -150,14 +194,13 @@ internal static void OnItemCommandChanged(ItemsRepeater sender, DependencyProper 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) { + // Unlike for selection behaviors, we don't need to find the "selection host". + // The selection host is a unrelated concept in the command setup. Additionally, + // the template root would generally have the same context as the selection host. if (ir.FindRootElementOf(source) is FrameworkElement root) { CommandExtensions.TryInvokeCommand(ir, CommandExtensions.GetCommandParameter(root) ?? root.DataContext); @@ -175,7 +218,7 @@ private static void OnItemsRepeaterCommandTapped(object sender, TappedRoutedEven // 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. + // Selection state is 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. @@ -196,12 +239,18 @@ private static void OnSelectionModeChanged(DependencyObject sender, DependencyPr { ir.Tapped += OnItemsRepeaterTapped; ir.ElementPrepared += OnItemsRepeaterElementPrepared; +#if APPLY_UNO12632_WORKAROUND + ir.ElementClearing += OnItemsRepeaterElementClearing; +#endif SetSelectionSubscription(ir, new CompositeDisposable( Disposable.Create(() => { ir.Tapped -= OnItemsRepeaterTapped; ir.ElementPrepared -= OnItemsRepeaterElementPrepared; +#if APPLY_UNO12632_WORKAROUND + ir.ElementClearing -= OnItemsRepeaterElementClearing; +#endif }), ir.RegisterDisposablePropertyChangedCallback(ItemsRepeater.ItemsSourceProperty, OnItemsRepeaterItemsSourceChanged) )); @@ -212,7 +261,10 @@ private static void OnSelectionModeChanged(DependencyObject sender, DependencyPr try { SetIsSynchronizingSelection(ir, true); - + +#if APPLY_UNO12632_WORKAROUND + ApplyNestedTappedEventBlocker(ir); +#endif TrySynchronizeDefaultSelection(ir); SynchronizeMaterializedElementsSelection(ir); } @@ -315,8 +367,17 @@ private static void OnItemsRepeaterElementPrepared(ItemsRepeater sender, Microso // 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); + SetItemSelection(sender, args.Element, selected); +#if APPLY_UNO12632_WORKAROUND + ApplyNestedTappedEventBlocker(sender, args.Element); +#endif + } +#if APPLY_UNO12632_WORKAROUND + private static void OnItemsRepeaterElementClearing(ItemsRepeater sender, Microsoft.UI.Xaml.Controls.ItemsRepeaterElementClearingEventArgs args) + { + ClearNestedTappedEventBlocker(sender, args.Element); } +#endif private static void OnItemsRepeaterItemsSourceChanged(DependencyObject sender, DependencyProperty dp) { // When we reach here, ItemsSourceView is already updated. @@ -345,7 +406,7 @@ private static void OnItemsRepeaterTapped(object sender, TappedRoutedEventArgs e if (e.OriginalSource is ItemsRepeater) return; if (e.OriginalSource is DependencyObject source) { - if (ir.FindRootElementOf(source) is { } element) + if (ir.FindRootElementOf(source) is UIElement element) { ToggleItemSelectionAtCoerced(ir, ir.GetElementIndex(element)); } @@ -495,7 +556,7 @@ private static void SynchronizeMaterializedElementsSelection(ItemsRepeater ir) if (element is UIElement uie && ir.GetElementIndex(uie) is var index && index != -1) { - SetItemSelection(uie, indexes.Contains(index)); + SetItemSelection(ir, uie, indexes.Contains(index)); } } } @@ -532,7 +593,7 @@ internal static void ToggleItemSelectionAtCoerced(ItemsRepeater ir, int index) { if (ir.TryGetElement(diffIndex) is { } materialized) { - SetItemSelection(materialized, updated.Contains(diffIndex)); + SetItemSelection(ir, materialized, updated.Contains(diffIndex)); } else { @@ -546,13 +607,17 @@ internal static void ToggleItemSelectionAtCoerced(ItemsRepeater ir, int index) SetIsSynchronizingSelection(ir, false); } } - internal static void SetItemSelection(DependencyObject x, bool value) + internal static void SetItemSelection(ItemsRepeater ir, DependencyObject itemRoot, bool value) { - if (x is SelectorItem si) + var host = GetUseNestedSelectionHost(ir) + ? (itemRoot.GetFirstDescendant(GetIsSelectionHost) ?? itemRoot) + : itemRoot; + + if (host is SelectorItem si) { si.IsSelected = value; } - else if (x is ToggleButton toggle) + else if (host is ToggleButton toggle) { toggle.IsChecked = value; } @@ -561,4 +626,60 @@ internal static void SetItemSelection(DependencyObject x, bool value) // todo: generic item is not supported } } + +#if APPLY_UNO12632_WORKAROUND + // note: This issue only happens with ButtonBase on wasm and android where the Tapped event is registered on. + + private static void ApplyNestedTappedEventBlocker(ItemsRepeater ir) + { + if (!IsWasm && !IsAndroid) return; + + if (ir.ItemsSourceView is { Count: > 0 }) + { + foreach (var element in ir.GetChildren()) + { + ApplyNestedTappedEventBlocker(ir, element); + } + } + } + private static void ApplyNestedTappedEventBlocker(ItemsRepeater ir, DependencyObject itemRoot) + { + Console.WriteLine($"@xy droid:{IsAndroid}, wasm:{IsWasm}"); + if (!IsWasm && !IsAndroid) return; + + var host = GetUseNestedSelectionHost(ir) + ? (itemRoot.GetFirstDescendant(GetIsSelectionHost) ?? itemRoot) + : itemRoot; + + if (host is ButtonBase button) + { + button.Tapped -= BlockNestedTappedEvent; + button.Tapped += BlockNestedTappedEvent; + } + } + private static void ClearNestedTappedEventBlocker(ItemsRepeater ir, DependencyObject itemRoot) + { + if (!IsWasm && !IsAndroid) return; + + var host = GetUseNestedSelectionHost(ir) + ? (itemRoot.GetFirstDescendant(GetIsSelectionHost) ?? itemRoot) + : itemRoot; + + if (host is ButtonBase button) + { + button.Tapped -= BlockNestedTappedEvent; + } + } + private static void BlockNestedTappedEvent(object sender, TappedRoutedEventArgs e) + { + // prevent the event to bubble up to the ItemsReapter. + e.Handled = true; + } + + private static bool IsAndroid { get; } +#if __ANDROID__ + = true; +#endif + private static bool IsWasm { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.Create("BROWSER")); +#endif } diff --git a/src/Uno.Toolkit.UI/Helpers/ItemsSelectionHelper.cs b/src/Uno.Toolkit.UI/Helpers/ItemsSelectionHelper.cs index a6b95ded5..ac8e8066d 100644 --- a/src/Uno.Toolkit.UI/Helpers/ItemsSelectionHelper.cs +++ b/src/Uno.Toolkit.UI/Helpers/ItemsSelectionHelper.cs @@ -33,6 +33,14 @@ public static int IndexOf(this ItemsSourceView isv, object item) } #endif + /// + /// Update the selection indexes by toggling the provided index, and then coerced according to the selection mode. + /// + /// Selection mode + /// Length of items + /// Current selection + /// Index to toggle + /// Updated selection indexes public static int[] ToggleSelectionAtCoerced(ItemsSelectionMode mode, int length, IList selection, int index) { if (length < 0) throw new ArgumentOutOfRangeException(nameof(length)); @@ -40,7 +48,7 @@ public static int[] ToggleSelectionAtCoerced(ItemsSelectionMode mode, int length if (mode is ItemsSelectionMode.None) { - return Array.Empty(); + return Array.Empty(); } else if (mode is ItemsSelectionMode.Single or ItemsSelectionMode.SingleOrNone) { @@ -67,7 +75,7 @@ public static int[] ToggleSelectionAtCoerced(ItemsSelectionMode mode, int length } } - public static UIElement? FindRootElementOf(this ItemsRepeater ir, DependencyObject node) + public static DependencyObject? 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: @@ -75,6 +83,6 @@ public static int[] ToggleSelectionAtCoerced(ItemsSelectionMode mode, int length return node.GetAncestors(includeCurrent: true) .ZipSkipOne() .FirstOrDefault(x => x.Current is ItemsRepeater) - .Previous as UIElement; + .Previous; } }