-
Notifications
You must be signed in to change notification settings - Fork 27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: nested selection host support for IR-extension #618
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
|
||
/// <summary> | ||
/// Property used to mark an element within the ItemsRepeater.ItemTemplate to be the host control that will handle the selection. | ||
/// </summary> | ||
/// <remarks> | ||
/// This is used when the target of selection cannot be the root element of the ItemTemplate. | ||
/// Note that <seealso cref="UseNestedSelectionHostProperty"/> should also be set on the ItemRepeater when using this property. | ||
/// </remarks> | ||
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 | ||
|
||
/// <summary> | ||
/// Property used to signal a selection-host should be found in the ItemTemplate, and it would replace the item template root. | ||
/// </summary> | ||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we maybe avoid needing to have both a Perhaps just search for a selection host if the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we absolutely need both properties, perhaps we should bubble up some sort of warning/error when one is used without the other? |
||
? (itemRoot.GetFirstDescendant<DependencyObject>(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}"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. leftover debug message? |
||
if (!IsWasm && !IsAndroid) return; | ||
|
||
var host = GetUseNestedSelectionHost(ir) | ||
? (itemRoot.GetFirstDescendant<DependencyObject>(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<DependencyObject>(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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -33,14 +33,22 @@ public static int IndexOf(this ItemsSourceView isv, object item) | |
} | ||
#endif | ||
|
||
/// <summary> | ||
/// Update the selection indexes by toggling the provided index, and then coerced according to the selection mode. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TIL "indexes" is a thing and you're only supposed to use "indices" in the context of mathematical expressions |
||
/// </summary> | ||
/// <param name="mode">Selection mode</param> | ||
/// <param name="length">Length of items</param> | ||
/// <param name="selection">Current selection</param> | ||
/// <param name="index">Index to toggle</param> | ||
/// <returns>Updated selection indexes</returns> | ||
public static int[] ToggleSelectionAtCoerced(ItemsSelectionMode mode, int length, IList<int> 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<int>(); | ||
return Array.Empty<int>(); | ||
} | ||
else if (mode is ItemsSelectionMode.Single or ItemsSelectionMode.SingleOrNone) | ||
{ | ||
|
@@ -67,14 +75,14 @@ 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: | ||
// ItemsRepeater > (item template root) > (layer0...n) > (tapped element) | ||
return node.GetAncestors(includeCurrent: true) | ||
.ZipSkipOne() | ||
.FirstOrDefault(x => x.Current is ItemsRepeater) | ||
.Previous as UIElement; | ||
.Previous; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.