src/Avalonia.Controls/Primitives/SelectingItemsControl.cs (980 lines of code) (raw):
using System;
using System.Collections;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Avalonia.Controls.Selection;
using Avalonia.Controls.Utils;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Metadata;
using Avalonia.Threading;
namespace Avalonia.Controls.Primitives
{
/// <summary>
/// An <see cref="ItemsControl"/> that maintains a selection.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="SelectingItemsControl"/> provides a base class for <see cref="ItemsControl"/>s
/// that maintain a selection (single or multiple). By default only its
/// <see cref="SelectedIndex"/> and <see cref="SelectedItem"/> properties are visible; the
/// current multiple <see cref="Selection"/> and <see cref="SelectedItems"/> together with the
/// <see cref="SelectionMode"/> properties are protected, however a derived class can expose
/// these if it wishes to support multiple selection.
/// </para>
/// <para>
/// <see cref="SelectingItemsControl"/> maintains a selection respecting the current
/// <see cref="SelectionMode"/> but it does not react to user input; this must be handled in a
/// derived class. It does, however, respond to <see cref="IsSelectedChangedEvent"/> events
/// from items and updates the selection accordingly.
/// </para>
/// </remarks>
public class SelectingItemsControl : ItemsControl
{
/// <summary>
/// Defines the <see cref="AutoScrollToSelectedItem"/> property.
/// </summary>
public static readonly StyledProperty<bool> AutoScrollToSelectedItemProperty =
AvaloniaProperty.Register<SelectingItemsControl, bool>(
nameof(AutoScrollToSelectedItem),
defaultValue: true);
/// <summary>
/// Defines the <see cref="SelectedIndex"/> property.
/// </summary>
public static readonly DirectProperty<SelectingItemsControl, int> SelectedIndexProperty =
AvaloniaProperty.RegisterDirect<SelectingItemsControl, int>(
nameof(SelectedIndex),
o => o.SelectedIndex,
(o, v) => o.SelectedIndex = v,
unsetValue: -1,
defaultBindingMode: BindingMode.TwoWay);
/// <summary>
/// Defines the <see cref="SelectedItem"/> property.
/// </summary>
public static readonly DirectProperty<SelectingItemsControl, object?> SelectedItemProperty =
AvaloniaProperty.RegisterDirect<SelectingItemsControl, object?>(
nameof(SelectedItem),
o => o.SelectedItem,
(o, v) => o.SelectedItem = v,
defaultBindingMode: BindingMode.TwoWay, enableDataValidation: true);
/// <summary>
/// Defines the <see cref="SelectedValue"/> property
/// </summary>
public static readonly StyledProperty<object?> SelectedValueProperty =
AvaloniaProperty.Register<SelectingItemsControl, object?>(nameof(SelectedValue),
defaultBindingMode: BindingMode.TwoWay);
/// <summary>
/// Defines the <see cref="SelectedValueBinding"/> property
/// </summary>
public static readonly StyledProperty<IBinding?> SelectedValueBindingProperty =
AvaloniaProperty.Register<SelectingItemsControl, IBinding?>(nameof(SelectedValueBinding));
/// <summary>
/// Defines the <see cref="SelectedItems"/> property.
/// </summary>
protected static readonly DirectProperty<SelectingItemsControl, IList?> SelectedItemsProperty =
AvaloniaProperty.RegisterDirect<SelectingItemsControl, IList?>(
nameof(SelectedItems),
o => o.SelectedItems,
(o, v) => o.SelectedItems = v);
/// <summary>
/// Defines the <see cref="Selection"/> property.
/// </summary>
protected static readonly DirectProperty<SelectingItemsControl, ISelectionModel> SelectionProperty =
AvaloniaProperty.RegisterDirect<SelectingItemsControl, ISelectionModel>(
nameof(Selection),
o => o.Selection,
(o, v) => o.Selection = v);
/// <summary>
/// Defines the <see cref="SelectionMode"/> property.
/// </summary>
protected static readonly StyledProperty<SelectionMode> SelectionModeProperty =
AvaloniaProperty.Register<SelectingItemsControl, SelectionMode>(
nameof(SelectionMode));
/// <summary>
/// Defines the IsSelected attached property.
/// </summary>
public static readonly StyledProperty<bool> IsSelectedProperty =
AvaloniaProperty.RegisterAttached<SelectingItemsControl, Control, bool>(
"IsSelected",
defaultBindingMode: BindingMode.TwoWay);
/// <summary>
/// Defines the <see cref="IsTextSearchEnabled"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsTextSearchEnabledProperty =
AvaloniaProperty.Register<SelectingItemsControl, bool>(nameof(IsTextSearchEnabled), false);
/// <summary>
/// Event that should be raised by containers when their selection state changes to notify
/// the parent <see cref="SelectingItemsControl"/> that their selection state has changed.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> IsSelectedChangedEvent =
RoutedEvent.Register<SelectingItemsControl, RoutedEventArgs>(
"IsSelectedChanged",
RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="SelectionChanged"/> event.
/// </summary>
public static readonly RoutedEvent<SelectionChangedEventArgs> SelectionChangedEvent =
RoutedEvent.Register<SelectingItemsControl, SelectionChangedEventArgs>(
nameof(SelectionChanged),
RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="WrapSelection"/> property.
/// </summary>
public static readonly StyledProperty<bool> WrapSelectionProperty =
AvaloniaProperty.Register<SelectingItemsControl, bool>(nameof(WrapSelection), defaultValue: false);
private string _textSearchTerm = string.Empty;
private DispatcherTimer? _textSearchTimer;
private ISelectionModel? _selection;
private int _oldSelectedIndex;
private WeakReference _oldSelectedItem = new(null);
private WeakReference<IList?> _oldSelectedItems = new(null);
private bool _ignoreContainerSelectionChanged;
private UpdateState? _updateState;
private bool _hasScrolledToSelectedItem;
private BindingEvaluator<object?>? _selectedValueBindingEvaluator;
private bool _isSelectionChangeActive;
public SelectingItemsControl()
{
((ItemCollection)ItemsView).SourceChanged += OnItemsViewSourceChanged;
}
/// <summary>
/// Initializes static members of the <see cref="SelectingItemsControl"/> class.
/// </summary>
static SelectingItemsControl()
{
IsSelectedChangedEvent.AddClassHandler<SelectingItemsControl>((x, e) => x.ContainerSelectionChanged(e));
}
/// <summary>
/// Occurs when the control's selection changes.
/// </summary>
public event EventHandler<SelectionChangedEventArgs>? SelectionChanged
{
add => AddHandler(SelectionChangedEvent, value);
remove => RemoveHandler(SelectionChangedEvent, value);
}
/// <summary>
/// Gets or sets a value indicating whether to automatically scroll to newly selected items.
/// </summary>
public bool AutoScrollToSelectedItem
{
get => GetValue(AutoScrollToSelectedItemProperty);
set => SetValue(AutoScrollToSelectedItemProperty, value);
}
/// <summary>
/// Gets or sets the index of the selected item.
/// </summary>
public int SelectedIndex
{
get
{
// When a Begin/EndInit/DataContext update is in place we return the value to be
// updated here, even though it's not yet active and the property changed notification
// has not yet been raised. If we don't do this then the old value will be written back
// to the source when two-way bound, and the update value will be lost.
if (_updateState is not null)
{
return _updateState.SelectedIndex.HasValue ?
_updateState.SelectedIndex.Value :
TryGetExistingSelection()?.SelectedIndex ?? -1;
}
return Selection.SelectedIndex;
}
set
{
if (_updateState is object)
{
_updateState.SelectedIndex = value;
}
else
{
Selection.SelectedIndex = value;
}
}
}
/// <summary>
/// Gets or sets the selected item.
/// </summary>
public object? SelectedItem
{
get
{
// See SelectedIndex getter for more information.
if (_updateState is not null)
{
return _updateState.SelectedItem.HasValue ?
_updateState.SelectedItem.Value :
TryGetExistingSelection()?.SelectedItem;
}
return Selection.SelectedItem;
}
set
{
if (_updateState is object)
{
_updateState.SelectedItem = value;
}
else
{
Selection.SelectedItem = value;
}
}
}
/// <summary>
/// Gets the <see cref="IBinding"/> instance used to obtain the
/// <see cref="SelectedValue"/> property
/// </summary>
[AssignBinding]
[InheritDataTypeFromItems(nameof(ItemsSource))]
public IBinding? SelectedValueBinding
{
get => GetValue(SelectedValueBindingProperty);
set => SetValue(SelectedValueBindingProperty, value);
}
/// <summary>
/// Gets or sets the value of the selected item, obtained using
/// <see cref="SelectedValueBinding"/>
/// </summary>
public object? SelectedValue
{
get => GetValue(SelectedValueProperty);
set => SetValue(SelectedValueProperty, value);
}
/// <summary>
/// Gets or sets the selected items.
/// </summary>
/// <remarks>
/// By default returns a collection that can be modified in order to manipulate the control
/// selection, however this property will return null if <see cref="Selection"/> is
/// re-assigned; you should only use _either_ Selection or SelectedItems.
/// </remarks>
protected IList? SelectedItems
{
get
{
// See SelectedIndex setter for more information.
if (_updateState?.SelectedItems.HasValue == true)
{
return _updateState.SelectedItems.Value;
}
else if (Selection is InternalSelectionModel ism)
{
var result = ism.WritableSelectedItems;
_oldSelectedItems.SetTarget(result);
return result;
}
return null;
}
set
{
if (_updateState is object)
{
_updateState.SelectedItems = new Optional<IList?>(value);
}
else if (Selection is InternalSelectionModel i)
{
i.WritableSelectedItems = value;
}
else
{
throw new InvalidOperationException("Cannot set both Selection and SelectedItems.");
}
}
}
/// <summary>
/// Gets or sets the model that holds the current selection.
/// </summary>
[AllowNull]
protected ISelectionModel Selection
{
get => _updateState?.Selection.HasValue == true ?
_updateState.Selection.Value :
GetOrCreateSelectionModel();
set
{
value ??= CreateDefaultSelectionModel();
if (_updateState is object)
{
_updateState.Selection = new Optional<ISelectionModel>(value);
}
else if (_selection != value)
{
if (value.Source != null && value.Source != ItemsView.Source)
{
throw new ArgumentException(
"The supplied ISelectionModel already has an assigned Source but this " +
"collection is different to the Items on the control.");
}
var oldSelection = _selection?.SelectedItems.ToArray();
DeinitializeSelectionModel(_selection);
_selection = value;
if (oldSelection?.Length > 0)
{
RaiseEvent(new SelectionChangedEventArgs(
SelectionChangedEvent,
oldSelection,
Array.Empty<object>()));
}
InitializeSelectionModel(_selection);
var selectedItems = SelectedItems;
_oldSelectedItems.TryGetTarget(out var oldSelectedItems);
if (oldSelectedItems != selectedItems)
{
RaisePropertyChanged(SelectedItemsProperty, oldSelectedItems, selectedItems);
_oldSelectedItems.SetTarget(selectedItems);
}
}
}
}
/// <summary>
/// Gets or sets a value that specifies whether a user can jump to a value by typing.
/// </summary>
public bool IsTextSearchEnabled
{
get => GetValue(IsTextSearchEnabledProperty);
set => SetValue(IsTextSearchEnabledProperty, value);
}
/// <summary>
/// Gets or sets a value which indicates whether to wrap around when the first
/// or last item is reached.
/// </summary>
public bool WrapSelection
{
get => GetValue(WrapSelectionProperty);
set => SetValue(WrapSelectionProperty, value);
}
/// <summary>
/// Gets or sets the selection mode.
/// </summary>
/// <remarks>
/// Note that the selection mode only applies to selections made via user interaction.
/// Multiple selections can be made programmatically regardless of the value of this property.
/// </remarks>
protected SelectionMode SelectionMode
{
get => GetValue(SelectionModeProperty);
set => SetValue(SelectionModeProperty, value);
}
/// <summary>
/// Gets a value indicating whether <see cref="SelectionMode.AlwaysSelected"/> is set.
/// </summary>
protected bool AlwaysSelected => SelectionMode.HasAllFlags(SelectionMode.AlwaysSelected);
/// <inheritdoc/>
public override void BeginInit()
{
base.BeginInit();
BeginUpdating();
}
/// <inheritdoc/>
public override void EndInit()
{
base.EndInit();
EndUpdating();
}
/// <summary>
/// Gets the value of the <see cref="IsSelectedProperty"/> on the specified control.
/// </summary>
/// <param name="control">The control.</param>
/// <returns>The value of the attached property.</returns>
public static bool GetIsSelected(Control control) => control.GetValue(IsSelectedProperty);
/// <summary>
/// Gets the value of the <see cref="IsSelectedProperty"/> on the specified control.
/// </summary>
/// <param name="control">The control.</param>
/// <param name="value">The value of the property.</param>
/// <returns>The value of the attached property.</returns>
public static void SetIsSelected(Control control, bool value) => control.SetValue(IsSelectedProperty, value);
/// <summary>
/// Tries to get the container that was the source of an event.
/// </summary>
/// <param name="eventSource">The control that raised the event.</param>
/// <returns>The container or null if the event did not originate in a container.</returns>
protected Control? GetContainerFromEventSource(object? eventSource)
{
for (var current = eventSource as Visual; current != null; current = current.VisualParent)
{
if (current is Control control && control.Parent == this &&
IndexFromContainer(control) != -1)
{
return control;
}
}
return null;
}
private protected override void OnItemsViewCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
base.OnItemsViewCollectionChanged(sender!, e);
//Do not change SelectedIndex during initialization
if (_updateState is not null)
{
return;
}
if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0)
{
SelectedIndex = 0;
}
}
/// <inheritdoc />
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
AutoScrollToSelectedItemIfNecessary(GetAnchorIndex());
}
/// <inheritdoc />
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
void ExecuteScrollWhenLayoutUpdated(object? sender, EventArgs e)
{
LayoutUpdated -= ExecuteScrollWhenLayoutUpdated;
AutoScrollToSelectedItemIfNecessary(GetAnchorIndex());
}
if (AutoScrollToSelectedItem)
{
LayoutUpdated += ExecuteScrollWhenLayoutUpdated;
}
}
internal int GetAnchorIndex()
{
var selection = _updateState is not null ? TryGetExistingSelection() : Selection;
return selection?.AnchorIndex ?? -1;
}
private ISelectionModel? TryGetExistingSelection()
=> _updateState?.Selection.HasValue == true ? _updateState.Selection.Value : _selection;
protected internal override void PrepareContainerForItemOverride(Control container, object? item, int index)
{
// Ensure that the selection model is created at this point so that accessing it in
// ContainerForItemPreparedOverride doesn't cause it to be initialized (which can
// make containers become deselected when they're synced with the empty selection
// mode).
GetOrCreateSelectionModel();
base.PrepareContainerForItemOverride(container, item, index);
}
protected internal override void ContainerForItemPreparedOverride(Control container, object? item, int index)
{
base.ContainerForItemPreparedOverride(container, item, index);
// Once the container has been full prepared and added to the tree, any bindings from
// styles or item container themes are guaranteed to be applied.
if (!container.IsSet(IsSelectedProperty))
{
// The IsSelected property is not set on the container: update the container
// selection based on the current selection as understood by this control.
MarkContainerSelected(container, Selection.IsSelected(index));
}
else
{
// The IsSelected property is set on the container: there is a style or item
// container theme which has bound the IsSelected property. Update our selection
// based on the selection state of the container.
var containerIsSelected = GetIsSelected(container);
UpdateSelection(index, containerIsSelected, toggleModifier: true);
}
if (Selection.AnchorIndex == index)
KeyboardNavigation.SetTabOnceActiveElement(this, container);
}
/// <inheritdoc />
protected override void ContainerIndexChangedOverride(Control container, int oldIndex, int newIndex)
{
base.ContainerIndexChangedOverride(container, oldIndex, newIndex);
MarkContainerSelected(container, Selection.IsSelected(newIndex));
}
/// <inheritdoc />
protected internal override void ClearContainerForItemOverride(Control element)
{
base.ClearContainerForItemOverride(element);
try
{
_ignoreContainerSelectionChanged = true;
element.ClearValue(IsSelectedProperty);
}
finally
{
_ignoreContainerSelectionChanged = false;
}
}
/// <inheritdoc/>
protected override void OnDataContextBeginUpdate()
{
base.OnDataContextBeginUpdate();
BeginUpdating();
}
/// <inheritdoc/>
protected override void OnDataContextEndUpdate()
{
base.OnDataContextEndUpdate();
EndUpdating();
}
/// <summary>
/// Called to update the validation state for properties for which data validation is
/// enabled.
/// </summary>
/// <param name="property">The property.</param>
/// <param name="state">The current data binding state.</param>
/// <param name="error">The current data binding error, if any.</param>
protected override void UpdateDataValidation(
AvaloniaProperty property,
BindingValueType state,
Exception? error)
{
if (property == SelectedItemProperty)
{
DataValidationErrors.SetError(this, error);
}
}
/// <inheritdoc />
protected override void OnInitialized()
{
base.OnInitialized();
TryInitializeSelectionSource(_selection, _updateState is null);
}
/// <inheritdoc />
protected override void OnTextInput(TextInputEventArgs e)
{
if (!e.Handled)
{
if (!IsTextSearchEnabled)
return;
StopTextSearchTimer();
_textSearchTerm += e.Text;
var newIndex = GetIndexFromTextSearch(_textSearchTerm);
if (newIndex >= 0)
{
SelectedIndex = newIndex;
}
StartTextSearchTimer();
e.Handled = true;
}
base.OnTextInput(e);
}
/// <inheritdoc />
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == AutoScrollToSelectedItemProperty)
{
AutoScrollToSelectedItemIfNecessary(GetAnchorIndex());
}
else if (change.Property == SelectionModeProperty && _selection is object)
{
var newValue = change.GetNewValue<SelectionMode>();
_selection.SingleSelect = !newValue.HasAllFlags(SelectionMode.Multiple);
}
else if (change.Property == WrapSelectionProperty)
{
WrapFocus = WrapSelection;
}
else if (change.Property == SelectedValueProperty)
{
if (_isSelectionChangeActive)
return;
if (_updateState is not null)
{
_updateState.SelectedValue = change.NewValue;
return;
}
SelectItemWithValue(change.NewValue);
}
else if (change.Property == SelectedValueBindingProperty)
{
var idx = SelectedIndex;
// If no selection is active, don't do anything as SelectedValue is already null
if (idx == -1)
{
return;
}
var value = change.GetNewValue<IBinding?>();
if (value is null)
{
// Clearing SelectedValueBinding makes the SelectedValue the item itself
SetCurrentValue(SelectedValueProperty, SelectedItem);
return;
}
var selectedItem = SelectedItem;
try
{
_isSelectionChangeActive = true;
var bindingEvaluator = GetSelectedValueBindingEvaluator(value);
// Re-evaluate SelectedValue with the new binding
SetCurrentValue(SelectedValueProperty, bindingEvaluator.Evaluate(selectedItem));
}
finally
{
_isSelectionChangeActive = false;
}
}
}
/// <summary>
/// Moves the selection in the specified direction relative to the current selection.
/// </summary>
/// <param name="direction">The direction to move.</param>
/// <param name="wrap">Whether to wrap when the selection reaches the first or last item.</param>
/// <param name="rangeModifier">Whether the range modifier is enabled (i.e. shift key).</param>
/// <returns>True if the selection was moved; otherwise false.</returns>
protected bool MoveSelection(
NavigationDirection direction,
bool wrap = false,
bool rangeModifier = false)
{
var focused = FocusManager.GetFocusManager(this)?.GetFocusedElement();
var from = GetContainerFromEventSource(focused) ?? ContainerFromIndex(Selection.AnchorIndex);
return MoveSelection(from, direction, wrap, rangeModifier);
}
/// <summary>
/// Moves the selection in the specified direction relative to the specified container.
/// </summary>
/// <param name="from">The container which serves as a starting point for the movement.</param>
/// <param name="direction">The direction to move.</param>
/// <param name="wrap">Whether to wrap when the selection reaches the first or last item.</param>
/// <param name="rangeModifier">Whether the range modifier is enabled (i.e. shift key).</param>
/// <returns>True if the selection was moved; otherwise false.</returns>
protected bool MoveSelection(
Control? from,
NavigationDirection direction,
bool wrap = false,
bool rangeModifier = false)
{
if (Presenter?.Panel is not INavigableContainer container)
return false;
if (from is null)
{
direction = direction switch
{
NavigationDirection.Down => NavigationDirection.First,
NavigationDirection.Up => NavigationDirection.Last,
NavigationDirection.Right => NavigationDirection.First,
NavigationDirection.Left => NavigationDirection.Last,
_ => direction,
};
}
if (GetNextControl(container, direction, from, wrap) is Control next)
{
var index = IndexFromContainer(next);
if (index != -1)
{
UpdateSelection(index, true, rangeModifier);
next.Focus();
return true;
}
}
return false;
}
/// <summary>
/// Updates the selection for an item based on user interaction.
/// </summary>
/// <param name="index">The index of the item.</param>
/// <param name="select">Whether the item should be selected or unselected.</param>
/// <param name="rangeModifier">Whether the range modifier is enabled (i.e. shift key).</param>
/// <param name="toggleModifier">Whether the toggle modifier is enabled (i.e. ctrl key).</param>
/// <param name="rightButton">Whether the event is a right-click.</param>
/// <param name="fromFocus">Wheter the event is a focus event</param>
protected void UpdateSelection(
int index,
bool select = true,
bool rangeModifier = false,
bool toggleModifier = false,
bool rightButton = false,
bool fromFocus = false)
{
if (index < 0 || index >= ItemCount)
{
return;
}
var mode = SelectionMode;
var multi = mode.HasAllFlags(SelectionMode.Multiple);
var toggle = toggleModifier || mode.HasAllFlags(SelectionMode.Toggle);
var range = multi && rangeModifier;
if (!select)
{
Selection.Deselect(index);
}
else if (rightButton)
{
if (Selection.IsSelected(index) == false)
{
SelectedIndex = index;
}
}
else if (range)
{
using var operation = Selection.BatchUpdate();
if (!toggleModifier)
{
Selection.Clear();
}
Selection.SelectRange(Selection.AnchorIndex, index);
}
else if (!fromFocus && toggle)
{
if (multi)
{
if (Selection.IsSelected(index))
{
Selection.Deselect(index);
}
else
{
Selection.Select(index);
}
}
else
{
SelectedIndex = (SelectedIndex == index) ? -1 : index;
}
}
else if (!toggle)
{
using var operation = Selection.BatchUpdate();
Selection.Clear();
Selection.Select(index);
}
}
/// <summary>
/// Updates the selection for a container based on user interaction.
/// </summary>
/// <param name="container">The container.</param>
/// <param name="select">Whether the container should be selected or unselected.</param>
/// <param name="rangeModifier">Whether the range modifier is enabled (i.e. shift key).</param>
/// <param name="toggleModifier">Whether the toggle modifier is enabled (i.e. ctrl key).</param>
/// <param name="rightButton">Whether the event is a right-click.</param>
/// <param name="fromFocus">Wheter the event is a focus event</param>
protected void UpdateSelection(
Control container,
bool select = true,
bool rangeModifier = false,
bool toggleModifier = false,
bool rightButton = false,
bool fromFocus = false)
{
var index = IndexFromContainer(container);
if (index != -1)
{
UpdateSelection(index, select, rangeModifier, toggleModifier, rightButton, fromFocus);
}
}
/// <summary>
/// Updates the selection based on an event that may have originated in a container that
/// belongs to the control.
/// </summary>
/// <param name="eventSource">The control that raised the event.</param>
/// <param name="select">Whether the container should be selected or unselected.</param>
/// <param name="rangeModifier">Whether the range modifier is enabled (i.e. shift key).</param>
/// <param name="toggleModifier">Whether the toggle modifier is enabled (i.e. ctrl key).</param>
/// <param name="rightButton">Whether the event is a right-click.</param>
/// <param name="fromFocus">Wheter the event is a focus event</param>
/// <returns>
/// True if the event originated from a container that belongs to the control; otherwise
/// false.
/// </returns>
protected bool UpdateSelectionFromEventSource(
object? eventSource,
bool select = true,
bool rangeModifier = false,
bool toggleModifier = false,
bool rightButton = false,
bool fromFocus = false)
{
var container = GetContainerFromEventSource(eventSource);
if (container != null)
{
UpdateSelection(container, select, rangeModifier, toggleModifier, rightButton, fromFocus);
return true;
}
return false;
}
private ISelectionModel GetOrCreateSelectionModel()
{
if (_selection is null)
{
_selection = CreateDefaultSelectionModel();
InitializeSelectionModel(_selection);
}
return _selection;
}
private void OnItemsViewSourceChanged(object? sender, EventArgs e)
{
if (_updateState is null)
TryInitializeSelectionSource(_selection, true);
}
/// <summary>
/// Called when <see cref="INotifyPropertyChanged.PropertyChanged"/> is raised on
/// <see cref="Selection"/>.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The event args.</param>
private void OnSelectionModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(ISelectionModel.AnchorIndex))
{
_hasScrolledToSelectedItem = false;
var anchorIndex = GetAnchorIndex();
KeyboardNavigation.SetTabOnceActiveElement(this, ContainerFromIndex(anchorIndex));
AutoScrollToSelectedItemIfNecessary(anchorIndex);
}
else if (e.PropertyName == nameof(ISelectionModel.SelectedIndex))
{
var selectedIndex = SelectedIndex;
var oldSelectedIndex = _oldSelectedIndex;
if (_oldSelectedIndex != selectedIndex)
{
RaisePropertyChanged(SelectedIndexProperty, oldSelectedIndex, selectedIndex);
_oldSelectedIndex = selectedIndex;
}
}
else if (e.PropertyName == nameof(ISelectionModel.SelectedItem))
{
var selectedItem = SelectedItem;
var oldSelectedItem = _oldSelectedItem.Target;
if (selectedItem != oldSelectedItem)
{
RaisePropertyChanged(SelectedItemProperty, oldSelectedItem, selectedItem);
_oldSelectedItem.Target = selectedItem;
}
}
else if (e.PropertyName == nameof(InternalSelectionModel.WritableSelectedItems))
{
_oldSelectedItems.TryGetTarget(out var oldSelectedItems);
if (oldSelectedItems != (Selection as InternalSelectionModel)?.SelectedItems)
{
var selectedItems = SelectedItems;
RaisePropertyChanged(SelectedItemsProperty, oldSelectedItems, selectedItems);
_oldSelectedItems.SetTarget(selectedItems);
}
}
else if (e.PropertyName == nameof(ISelectionModel.Source))
{
ClearValue(SelectedValueProperty);
}
}
/// <summary>
/// Called when <see cref="ISelectionModel.SelectionChanged"/> event is raised on
/// <see cref="Selection"/>.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The event args.</param>
private void OnSelectionModelSelectionChanged(object? sender, SelectionModelSelectionChangedEventArgs e)
{
void Mark(int index, bool selected)
{
var container = ContainerFromIndex(index);
if (container != null)
{
MarkContainerSelected(container, selected);
}
}
foreach (var i in e.SelectedIndexes)
{
Mark(i, true);
}
foreach (var i in e.DeselectedIndexes)
{
Mark(i, false);
}
if (!_isSelectionChangeActive)
{
UpdateSelectedValueFromItem();
}
var route = BuildEventRoute(SelectionChangedEvent);
if (route.HasHandlers)
{
var ev = new SelectionChangedEventArgs(
SelectionChangedEvent,
e.DeselectedItems.ToArray(),
e.SelectedItems.ToArray());
RaiseEvent(ev);
}
}
/// <summary>
/// Called when <see cref="ISelectionModel.LostSelection"/> event is raised on
/// <see cref="Selection"/>.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The event args.</param>
private void OnSelectionModelLostSelection(object? sender, EventArgs e)
{
if (AlwaysSelected && ItemsView.Count > 0)
{
SelectedIndex = 0;
}
}
private void SelectItemWithValue(object? value)
{
if (ItemCount == 0 || _isSelectionChangeActive)
return;
try
{
_isSelectionChangeActive = true;
var si = FindItemWithValue(value);
if (si != AvaloniaProperty.UnsetValue)
{
SelectedItem = si;
}
else
{
SelectedItem = null;
}
}
finally
{
_isSelectionChangeActive = false;
}
}
private object? FindItemWithValue(object? value)
{
if (ItemCount == 0 || value is null)
{
return AvaloniaProperty.UnsetValue;
}
var items = ItemsView;
var binding = SelectedValueBinding;
if (binding is null)
{
// No SelectedValueBinding set, SelectedValue is the item itself
// Still verify the value passed in is in the Items list
var index = items!.IndexOf(value);
if (index >= 0)
{
return value;
}
else
{
return AvaloniaProperty.UnsetValue;
}
}
var bindingEvaluator = GetSelectedValueBindingEvaluator(binding);
// Matching UWP behavior, if duplicates are present, return the first item matching
// the SelectedValue provided
foreach (var item in items!)
{
var itemValue = bindingEvaluator.Evaluate(item);
if (Equals(itemValue, value))
{
bindingEvaluator.ClearDataContext();
return item;
}
}
bindingEvaluator.ClearDataContext();
return AvaloniaProperty.UnsetValue;
}
private void UpdateSelectedValueFromItem()
{
if (_isSelectionChangeActive)
return;
var binding = SelectedValueBinding;
var item = SelectedItem;
if (binding is null || item is null)
{
// No SelectedValueBinding, SelectedValue is Item itself
try
{
_isSelectionChangeActive = true;
SetCurrentValue(SelectedValueProperty, item);
}
finally
{
_isSelectionChangeActive = false;
}
return;
}
var bindingEvaluator = GetSelectedValueBindingEvaluator(binding);
try
{
_isSelectionChangeActive = true;
SetCurrentValue(SelectedValueProperty, bindingEvaluator.Evaluate(item));
}
finally
{
_isSelectionChangeActive = false;
}
}
private void AutoScrollToSelectedItemIfNecessary(int anchorIndex)
{
if (AutoScrollToSelectedItem &&
!_hasScrolledToSelectedItem &&
Presenter is object &&
anchorIndex >= 0 &&
IsAttachedToVisualTree)
{
Dispatcher.UIThread.Post(state =>
{
ScrollIntoView((int)state!);
_hasScrolledToSelectedItem = true;
}, anchorIndex);
}
}
/// <summary>
/// Called when a container raises the <see cref="IsSelectedChangedEvent"/>.
/// </summary>
/// <param name="e">The event.</param>
private void ContainerSelectionChanged(RoutedEventArgs e)
{
if (!_ignoreContainerSelectionChanged &&
e.Source is Control control &&
control.Parent == this &&
IndexFromContainer(control) is var index &&
index >= 0)
{
if (GetIsSelected(control))
Selection.Select(index);
else
Selection.Deselect(index);
}
if (e.Source != this)
{
e.Handled = true;
}
}
/// <summary>
/// Sets the <see cref="IsSelectedProperty"/> on the specified container.
/// </summary>
/// <param name="container">The container.</param>
/// <param name="selected">Whether the control is selected</param>
/// <returns>The previous selection state.</returns>
private void MarkContainerSelected(Control container, bool selected)
{
_ignoreContainerSelectionChanged = true;
try
{
container.SetCurrentValue(IsSelectedProperty, selected);
}
finally
{
_ignoreContainerSelectionChanged = false;
}
}
private void UpdateContainerSelection()
{
if (Presenter?.Panel is { } panel)
{
foreach (var container in panel.Children)
{
MarkContainerSelected(
container,
Selection.IsSelected(IndexFromContainer(container)));
}
}
}
private ISelectionModel CreateDefaultSelectionModel()
{
return new InternalSelectionModel
{
SingleSelect = !SelectionMode.HasAllFlags(SelectionMode.Multiple),
};
}
private void InitializeSelectionModel(ISelectionModel model)
{
if (_updateState is null)
{
TryInitializeSelectionSource(model, false);
}
model.PropertyChanged += OnSelectionModelPropertyChanged;
model.SelectionChanged += OnSelectionModelSelectionChanged;
model.LostSelection += OnSelectionModelLostSelection;
if (model.SingleSelect)
{
SelectionMode &= ~SelectionMode.Multiple;
}
else
{
SelectionMode |= SelectionMode.Multiple;
}
_oldSelectedIndex = model.SelectedIndex;
_oldSelectedItem.Target = model.SelectedItem;
if (_updateState is null && AlwaysSelected && model.Count == 0)
{
model.SelectedIndex = 0;
}
UpdateContainerSelection();
if (SelectedIndex != -1)
{
RaiseEvent(new SelectionChangedEventArgs(
SelectionChangedEvent,
Array.Empty<object>(),
Selection.SelectedItems.ToArray()));
}
}
private void TryInitializeSelectionSource(ISelectionModel? selection, bool shouldSelectItemFromSelectedValue)
{
if (selection is not null && ItemsView.TryGetInitializedSource() is { } source)
{
// InternalSelectionModel keeps the SelectedIndex and SelectedItem values before the ItemsSource is set.
// However, SelectedValue isn't part of that model, so we have to set the SelectedItem from
// SelectedValue manually now that we have a source.
//
// While this works, this is messy: we effectively have "lazy selection initialization" in 3 places:
// - UpdateState (all selection properties, for BeginInit/EndInit)
// - InternalSelectionModel (SelectedIndex/SelectedItem)
// - SelectedItemsControl (SelectedValue)
//
// There's the opportunity to have a single place responsible for this logic.
// TODO12 (or 13): refactor this.
if (shouldSelectItemFromSelectedValue && selection.SelectedIndex == -1 && selection.SelectedItem is null)
{
var item = FindItemWithValue(SelectedValue);
if (item != AvaloniaProperty.UnsetValue)
selection.SelectedItem = item;
}
selection.Source = source;
}
}
private void DeinitializeSelectionModel(ISelectionModel? model)
{
if (model is object)
{
model.PropertyChanged -= OnSelectionModelPropertyChanged;
model.SelectionChanged -= OnSelectionModelSelectionChanged;
}
}
private void BeginUpdating()
{
_updateState ??= new UpdateState();
_updateState.UpdateCount++;
}
private void EndUpdating()
{
if (_updateState is object && --_updateState.UpdateCount == 0)
{
var state = _updateState;
_updateState = null;
if (state.Selection.HasValue)
{
Selection = state.Selection.Value;
}
if (_selection is InternalSelectionModel s)
{
s.Update(ItemsView.TryGetInitializedSource(), state.SelectedItems);
}
else
{
if (state.SelectedItems.HasValue)
{
SelectedItems = state.SelectedItems.Value;
}
TryInitializeSelectionSource(Selection, false);
}
if (state.SelectedValue.HasValue)
{
var item = FindItemWithValue(state.SelectedValue.Value);
if (item != AvaloniaProperty.UnsetValue)
state.SelectedItem = item;
}
// SelectedIndex vs SelectedItem:
// - If only one has a value, use it
// - If both have a value, prefer the one having a "non-empty" value, e.g. not -1 nor null
// - If both have a "non-empty" value, prefer the index
if (state.SelectedIndex.HasValue)
{
var selectedIndex = state.SelectedIndex.Value;
if (selectedIndex >= 0 || !state.SelectedItem.HasValue)
SelectedIndex = selectedIndex;
else
SelectedItem = state.SelectedItem.Value;
}
else if (state.SelectedItem.HasValue)
{
SelectedItem = state.SelectedItem.Value;
}
if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0)
{
SelectedIndex = 0;
}
}
}
private void StartTextSearchTimer()
{
_textSearchTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_textSearchTimer.Tick += TextSearchTimer_Tick;
_textSearchTimer.Start();
}
private void StopTextSearchTimer()
{
if (_textSearchTimer == null)
{
return;
}
_textSearchTimer.Tick -= TextSearchTimer_Tick;
_textSearchTimer.Stop();
_textSearchTimer = null;
}
private void TextSearchTimer_Tick(object? sender, EventArgs e)
{
_textSearchTerm = string.Empty;
StopTextSearchTimer();
}
private int GetIndexFromTextSearch(string textSearchTerm)
{
if (string.IsNullOrEmpty(textSearchTerm))
return -1;
var count = Items.Count;
if (count == 0)
return -1;
var textBinding = TextSearch.GetTextBinding(this) ?? DisplayMemberBinding;
using var textBindingEvaluator = BindingEvaluator<string?>.TryCreate(textBinding);
for (var i = 0; i < count; i++)
{
var text = TextSearch.GetEffectiveText(Items[i], textBindingEvaluator);
if (text.StartsWith(textSearchTerm, StringComparison.OrdinalIgnoreCase))
{
return i;
}
}
return -1;
}
private BindingEvaluator<object?> GetSelectedValueBindingEvaluator(IBinding binding)
{
_selectedValueBindingEvaluator ??= new();
_selectedValueBindingEvaluator.UpdateBinding(binding);
return _selectedValueBindingEvaluator;
}
// When in a BeginInit..EndInit block, or when the DataContext is updating, we need to
// defer changes to the selection model because we have no idea in which order properties
// will be set. Consider:
//
// - Both Items and SelectedItem are bound
// - The DataContext changes
// - The binding for SelectedItem updates first, producing an item
// - Items is searched to find the index of the new selected item
// - However Items isn't yet updated; the item is not found
// - SelectedIndex is incorrectly set to -1
//
// This logic cannot be encapsulated in SelectionModel because the selection model can also
// be bound, consider:
//
// - Both Items and Selection are bound
// - The DataContext changes
// - The binding for Items updates first
// - The new items are assigned to Selection.Source
// - The binding for Selection updates, producing a new SelectionModel
// - Both the old and new SelectionModels have the incorrect Source
private class UpdateState
{
public int UpdateCount { get; set; }
public Optional<ISelectionModel> Selection { get; set; }
public Optional<IList?> SelectedItems { get; set; }
public Optional<int> SelectedIndex { get; set; }
public Optional<object?> SelectedItem { get; set; }
public Optional<object?> SelectedValue { get; set; }
}
}
}