Skip to content

Commit 79fd31a

Browse files
committed
feat: nested selection host support for IR-extension
1 parent 7e9da83 commit 79fd31a

File tree

5 files changed

+105
-17
lines changed

5 files changed

+105
-17
lines changed

doc/helpers/itemsrepeater-extensions.md

+12
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ Provides selection support for `ItemsRepeater`.
77
## Properties
88
Property|Type|Description
99
-|-|-
10+
IsSelectionHost|bool|Used to mark an element within the ItemsRepeater.ItemTemplate to be the host control that will handle the selection.\*
1011
SelectedItem|object|Two-ways bindable property for the current/first(in Multiple mode) selected item.\*
1112
SelectedIndex|int|Two-ways bindable property for the current/first(in Multiple mode) selected index.\*
1213
SelectedItems|IList\<object>|Two-ways bindable property for the current selected items.\*
1314
SelectedIndexes|IList\<int>|Two-ways bindable property for the current selected indexes.\*
1415
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.
16+
UseNestedSelectionHost|bool|Used to signal a selection-host should be found in the ItemTemplate, and it would replace the item template root.\*
17+
1518

1619
### Remarks
1720
- `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
2124
- `SingleOrNone`: Up to one item can be selected at a time. The current item can be deselected.
2225
- `Single`: One item is selected at any time. The current item cannot be deselected.
2326
- `Multiple`: The current item cannot be deselected.
27+
- Use `IsSelectionHost` and `UseNestedSelectionHost` when the target of selection cannot be the root element of the ItemTemplate:
28+
```xml
29+
<muxc:ItemsRepeater utu:ItemsRepeaterExtensions.UseNestedSelectionHost="True">
30+
<muxc:ItemsRepeater.ItemTemplate>
31+
<DataTemplate>
32+
<Border>
33+
<utu:Chip Content="{Binding}" utu:ItemsRepeaterExtensions.IsSelectionHost="True"/>
34+
```
2435

2536
## Usage
2637
```xml
@@ -45,4 +56,5 @@ xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
4556

4657
### Remarks
4758
- The selection feature from this extensions support ItemTemplate whose the root element is a `SelectorItem` or `ToggleButton`(which includes `Chip`).
59+
- Use `IsSelectionHost` and `UseNestedSelectionHost` when the target of selection cannot be the root element of the ItemTemplate.
4860
- `RadioButton`: Multiple mode is not supported due to control limitation.

src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ internal static ItemsRepeater SetupItemsRepeater(object source, ItemsSelectionMo
153153
ItemsSource = source,
154154
ItemTemplate = XamlHelper.LoadXaml<DataTemplate>("""
155155
<DataTemplate>
156-
<utu:Chip />
156+
<utu:Chip Content="{Binding}" />
157157
</DataTemplate>
158158
"""),
159159
};

src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterExtensionTests.cs

+35-1
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,17 @@
55
using System.Text;
66
using System.Threading.Tasks;
77
using Microsoft.VisualStudio.TestTools.UnitTesting;
8-
using Windows.UI.Xaml;
98
using Uno.Toolkit.RuntimeTests.Helpers;
109
using Uno.Toolkit.UI;
1110
using Uno.UI.RuntimeTests;
11+
12+
#if IS_WINUI
13+
using Microsoft.UI.Xaml;
14+
#else
15+
using Windows.UI.Xaml;
16+
#endif
17+
18+
using ChipControl = Uno.Toolkit.UI.Chip; // ios/macos: to avoid collision with `global::Chip` namespace...
1219
using ItemsRepeater = Microsoft.UI.Xaml.Controls.ItemsRepeater;
1320
using static Uno.Toolkit.RuntimeTests.Tests.ItemsRepeaterChipTests; // to borrow helper methods
1421

@@ -40,4 +47,31 @@ public async Task When_Selection_Property_Changed(string property)
4047
})();
4148
Assert.AreEqual(true, IsChipSelectedAt(SUT, 1));
4249
}
50+
51+
[TestMethod]
52+
public async Task When_NestedSelectionHost()
53+
{
54+
var source = Enumerable.Range(0, 3).ToList();
55+
var SUT = new ItemsRepeater
56+
{
57+
ItemsSource = source,
58+
ItemTemplate = XamlHelper.LoadXaml<DataTemplate>("""
59+
<DataTemplate>
60+
<Border>
61+
<utu:Chip Content="{Binding}" utu:ItemsRepeaterExtensions.IsSelectionHost="True" />
62+
</Border>
63+
</DataTemplate>
64+
"""),
65+
};
66+
ItemsRepeaterExtensions.SetUseNestedSelectionHost(SUT, true);
67+
ItemsRepeaterExtensions.SetSelectionMode(SUT, ItemsSelectionMode.Single);
68+
ItemsRepeaterExtensions.SetSelectedIndex(SUT, 1);
69+
70+
await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
71+
72+
var root = SUT.TryGetElement(1);
73+
var chip = root?.FindChild<ChipControl>();
74+
75+
Assert.IsTrue(chip?.IsChecked == true);
76+
}
4377
}

src/Uno.Toolkit.UI/Behaviors/ItemsRepeaterExtensions.cs

+54-12
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,27 @@ public static partial class ItemsRepeaterExtensions
2828
{
2929
private static ILogger _logger { get; } = typeof(CommandExtensions).Log();
3030

31+
#region DependencyProperty: IsSelectionHost
32+
33+
/// <summary>
34+
/// Property used to mark an element within the ItemsRepeater.ItemTemplate to be the host control that will handle the selection.
35+
/// </summary>
36+
/// <remarks>
37+
/// This is used when the target of selection cannot be the root element of the ItemTemplate.
38+
/// Note that <seealso cref="UseNestedSelectionHostProperty"/> should also be set on the ItemRepeater when using this property.
39+
/// </remarks>
40+
public static DependencyProperty IsSelectionHostProperty { [DynamicDependency(nameof(GetIsSelectionHost))] get; } = DependencyProperty.RegisterAttached(
41+
"IsSelectionHost",
42+
typeof(bool),
43+
typeof(ItemsRepeaterExtensions),
44+
new PropertyMetadata(default(bool)));
45+
46+
[DynamicDependency(nameof(SetIsSelectionHost))]
47+
public static bool GetIsSelectionHost(DependencyObject obj) => (bool)obj.GetValue(IsSelectionHostProperty);
48+
[DynamicDependency(nameof(GetIsSelectionHost))]
49+
public static void SetIsSelectionHost(DependencyObject obj, bool value) => obj.SetValue(IsSelectionHostProperty, value);
50+
51+
#endregion
3152
#region DependencyProperty: IsSynchronizingSelection
3253

3354
private static DependencyProperty IsSynchronizingSelectionProperty { [DynamicDependency(nameof(GetIsSynchronizingSelection))] get; } = DependencyProperty.RegisterAttached(
@@ -126,6 +147,24 @@ public static partial class ItemsRepeaterExtensions
126147
private static void SetSelectionSubscription(ItemsRepeater obj, IDisposable value) => obj.SetValue(SelectionSubscriptionProperty, value);
127148

128149
#endregion
150+
#region DependencyProperty: UseNestedSelectionHost
151+
152+
/// <summary>
153+
/// Property used to signal a selection-host should be found in the ItemTemplate, and it would replace the item template root.
154+
/// </summary>
155+
public static DependencyProperty UseNestedSelectionHostProperty { [DynamicDependency(nameof(GetUseNestedSelectionHost))] get; } = DependencyProperty.RegisterAttached(
156+
"UseNestedSelectionHost",
157+
typeof(bool),
158+
typeof(ItemsRepeaterExtensions),
159+
new PropertyMetadata(default(bool)));
160+
161+
[DynamicDependency(nameof(SetUseNestedSelectionHost))]
162+
public static bool GetUseNestedSelectionHost(DependencyObject obj) => (bool)obj.GetValue(UseNestedSelectionHostProperty);
163+
[DynamicDependency(nameof(GetUseNestedSelectionHost))]
164+
public static void SetUseNestedSelectionHost(DependencyObject obj, bool value) => obj.SetValue(UseNestedSelectionHostProperty, value);
165+
166+
#endregion
167+
129168

130169
#region ItemCommand Impl
131170
internal static void OnItemCommandChanged(ItemsRepeater sender, DependencyPropertyChangedEventArgs e)
@@ -150,14 +189,13 @@ internal static void OnItemCommandChanged(ItemsRepeater sender, DependencyProper
150189

151190
private static void OnItemsRepeaterCommandTapped(object sender, TappedRoutedEventArgs e)
152191
{
153-
// ItemsRepeater is more closely related to Panel than ItemsControl, and it cannot be templated.
154-
// It is safe to assume all direct children of IR are materialized item template,
155-
// and there can't be header/footer or wrapper (ItemContainer) among them.
156-
157192
if (sender is not ItemsRepeater ir) return;
158193
if (e.OriginalSource is ItemsRepeater) return;
159194
if (e.OriginalSource is DependencyObject source)
160195
{
196+
// Unlike for selection behaviors, we don't need to find the "selection host".
197+
// The selection host is a unrelated concept in the command setup. Additionally,
198+
// the template root would generally have the same context as the selection host.
161199
if (ir.FindRootElementOf(source) is FrameworkElement root)
162200
{
163201
CommandExtensions.TryInvokeCommand(ir, CommandExtensions.GetCommandParameter(root) ?? root.DataContext);
@@ -175,7 +213,7 @@ private static void OnItemsRepeaterCommandTapped(object sender, TappedRoutedEven
175213

176214
// ItemsRepeater's children contains only materialized element; materialization and de-materialization can be track with
177215
// ElementPrepared and ElementClearing events. Recycled elements are reused based on FIFO-rule, resulting in index desync.
178-
// Selection state saved on the element (LVI.IsSelect, Chip.IsChecked) will also desync when it happens.
216+
// Selection state is saved on the element (LVI.IsSelect, Chip.IsChecked) will also desync when it happens.
179217
// !!! So it is important to save the selection state into a dp, and validate against that on element materialization and correct when necessary.
180218

181219
// Unlike ToggleButton (or Chip which derives from), SelectorItem is not normally selectable on click, unless nested under a Selector.
@@ -315,7 +353,7 @@ private static void OnItemsRepeaterElementPrepared(ItemsRepeater sender, Microso
315353
// and we can rely on it to synchronize the selection on the view-level.
316354
var selected = GetSelectedIndexes(sender)?.Contains(args.Index) ?? false;
317355

318-
SetItemSelection(args.Element, selected);
356+
SetItemSelection(sender, args.Element, selected);
319357
}
320358
private static void OnItemsRepeaterItemsSourceChanged(DependencyObject sender, DependencyProperty dp)
321359
{
@@ -345,7 +383,7 @@ private static void OnItemsRepeaterTapped(object sender, TappedRoutedEventArgs e
345383
if (e.OriginalSource is ItemsRepeater) return;
346384
if (e.OriginalSource is DependencyObject source)
347385
{
348-
if (ir.FindRootElementOf(source) is { } element)
386+
if (ir.FindRootElementOf(source) is UIElement element)
349387
{
350388
ToggleItemSelectionAtCoerced(ir, ir.GetElementIndex(element));
351389
}
@@ -495,7 +533,7 @@ private static void SynchronizeMaterializedElementsSelection(ItemsRepeater ir)
495533
if (element is UIElement uie &&
496534
ir.GetElementIndex(uie) is var index && index != -1)
497535
{
498-
SetItemSelection(uie, indexes.Contains(index));
536+
SetItemSelection(ir, uie, indexes.Contains(index));
499537
}
500538
}
501539
}
@@ -532,7 +570,7 @@ internal static void ToggleItemSelectionAtCoerced(ItemsRepeater ir, int index)
532570
{
533571
if (ir.TryGetElement(diffIndex) is { } materialized)
534572
{
535-
SetItemSelection(materialized, updated.Contains(diffIndex));
573+
SetItemSelection(ir, materialized, updated.Contains(diffIndex));
536574
}
537575
else
538576
{
@@ -546,13 +584,17 @@ internal static void ToggleItemSelectionAtCoerced(ItemsRepeater ir, int index)
546584
SetIsSynchronizingSelection(ir, false);
547585
}
548586
}
549-
internal static void SetItemSelection(DependencyObject x, bool value)
587+
internal static void SetItemSelection(ItemsRepeater ir, DependencyObject itemRoot, bool value)
550588
{
551-
if (x is SelectorItem si)
589+
var host = GetUseNestedSelectionHost(ir)
590+
? (itemRoot.GetFirstDescendant<DependencyObject>(GetIsSelectionHost) ?? itemRoot)
591+
: itemRoot;
592+
593+
if (host is SelectorItem si)
552594
{
553595
si.IsSelected = value;
554596
}
555-
else if (x is ToggleButton toggle)
597+
else if (host is ToggleButton toggle)
556598
{
557599
toggle.IsChecked = value;
558600
}

src/Uno.Toolkit.UI/Helpers/ItemsSelectionHelper.cs

+3-3
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public static int[] ToggleSelectionAtCoerced(ItemsSelectionMode mode, int length
4040

4141
if (mode is ItemsSelectionMode.None)
4242
{
43-
return Array.Empty<int>();
43+
return Array.Empty<int>();
4444
}
4545
else if (mode is ItemsSelectionMode.Single or ItemsSelectionMode.SingleOrNone)
4646
{
@@ -67,14 +67,14 @@ public static int[] ToggleSelectionAtCoerced(ItemsSelectionMode mode, int length
6767
}
6868
}
6969

70-
public static UIElement? FindRootElementOf(this ItemsRepeater ir, DependencyObject node)
70+
public static DependencyObject? FindRootElementOf(this ItemsRepeater ir, DependencyObject node)
7171
{
7272
// e.OriginalSource is the top-most element under the cursor.
7373
// In order to find the materialized element, we have to walk up the visual-tree, to the first element right below IR:
7474
// ItemsRepeater > (item template root) > (layer0...n) > (tapped element)
7575
return node.GetAncestors(includeCurrent: true)
7676
.ZipSkipOne()
7777
.FirstOrDefault(x => x.Current is ItemsRepeater)
78-
.Previous as UIElement;
78+
.Previous;
7979
}
8080
}

0 commit comments

Comments
 (0)