using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using Avalonia.Controls.Generators;
using Avalonia.Controls.Selection;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
#nullable enable
namespace Avalonia.Controls.Primitives
{
///
/// An that maintains a selection.
///
///
///
/// provides a base class for s
/// that maintain a selection (single or multiple). By default only its
/// and properties are visible; the
/// current multiple and together with the
/// properties are protected, however a derived class can expose
/// these if it wishes to support multiple selection.
///
///
/// maintains a selection respecting the current
/// but it does not react to user input; this must be handled in a
/// derived class. It does, however, respond to events
/// from items and updates the selection accordingly.
///
///
public class SelectingItemsControl : ItemsControl
{
///
/// Defines the property.
///
public static readonly StyledProperty AutoScrollToSelectedItemProperty =
AvaloniaProperty.Register(
nameof(AutoScrollToSelectedItem),
defaultValue: true);
///
/// Defines the property.
///
public static readonly DirectProperty SelectedIndexProperty =
AvaloniaProperty.RegisterDirect(
nameof(SelectedIndex),
o => o.SelectedIndex,
(o, v) => o.SelectedIndex = v,
unsetValue: -1,
defaultBindingMode: BindingMode.TwoWay);
///
/// Defines the property.
///
public static readonly DirectProperty SelectedItemProperty =
AvaloniaProperty.RegisterDirect(
nameof(SelectedItem),
o => o.SelectedItem,
(o, v) => o.SelectedItem = v,
defaultBindingMode: BindingMode.TwoWay, enableDataValidation: true);
///
/// Defines the property.
///
protected static readonly DirectProperty SelectedItemsProperty =
AvaloniaProperty.RegisterDirect(
nameof(SelectedItems),
o => o.SelectedItems,
(o, v) => o.SelectedItems = v);
///
/// Defines the property.
///
protected static readonly DirectProperty SelectionProperty =
AvaloniaProperty.RegisterDirect(
nameof(Selection),
o => o.Selection,
(o, v) => o.Selection = v);
///
/// Defines the property.
///
protected static readonly StyledProperty SelectionModeProperty =
AvaloniaProperty.Register(
nameof(SelectionMode));
///
/// Event that should be raised by items that implement to
/// notify the parent that their selection state
/// has changed.
///
public static readonly RoutedEvent IsSelectedChangedEvent =
RoutedEvent.Register(
"IsSelectedChanged",
RoutingStrategies.Bubble);
///
/// Defines the event.
///
public static readonly RoutedEvent SelectionChangedEvent =
RoutedEvent.Register(
"SelectionChanged",
RoutingStrategies.Bubble);
private static readonly IList Empty = Array.Empty();
private ISelectionModel? _selection;
private int _oldSelectedIndex;
private object? _oldSelectedItem;
private IList? _oldSelectedItems;
private bool _ignoreContainerSelectionChanged;
private UpdateState? _updateState;
private bool _hasScrolledToSelectedItem;
///
/// Initializes static members of the class.
///
static SelectingItemsControl()
{
IsSelectedChangedEvent.AddClassHandler((x, e) => x.ContainerSelectionChanged(e));
}
///
/// Occurs when the control's selection changes.
///
public event EventHandler SelectionChanged
{
add { AddHandler(SelectionChangedEvent, value); }
remove { RemoveHandler(SelectionChangedEvent, value); }
}
///
/// Gets or sets a value indicating whether to automatically scroll to newly selected items.
///
public bool AutoScrollToSelectedItem
{
get { return GetValue(AutoScrollToSelectedItemProperty); }
set { SetValue(AutoScrollToSelectedItemProperty, value); }
}
///
/// Gets or sets the index of the selected item.
///
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.
return _updateState?.SelectedIndex.HasValue == true ?
_updateState.SelectedIndex.Value :
Selection.SelectedIndex;
}
set
{
if (_updateState is object)
{
_updateState.SelectedIndex = value;
}
else
{
Selection.SelectedIndex = value;
}
}
}
///
/// Gets or sets the selected item.
///
public object? SelectedItem
{
get
{
// See SelectedIndex setter for more information.
return _updateState?.SelectedItem.HasValue == true ?
_updateState.SelectedItem.Value :
Selection.SelectedItem;
}
set
{
if (_updateState is object)
{
_updateState.SelectedItem = value;
}
else
{
Selection.SelectedItem = value;
}
}
}
///
/// Gets or sets the selected items.
///
///
/// By default returns a collection that can be modified in order to manipulate the control
/// selection, however this property will return null if is
/// re-assigned; you should only use _either_ Selection or SelectedItems.
///
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 = result;
return result;
}
return null;
}
set
{
if (_updateState is object)
{
_updateState.SelectedItems = new Optional(value);
}
else if (Selection is InternalSelectionModel i)
{
i.WritableSelectedItems = value;
}
else
{
throw new InvalidOperationException("Cannot set both Selection and SelectedItems.");
}
}
}
///
/// Gets or sets the model that holds the current selection.
///
protected ISelectionModel Selection
{
get
{
if (_updateState?.Selection.HasValue == true)
{
return _updateState.Selection.Value;
}
else
{
if (_selection is null)
{
_selection = CreateDefaultSelectionModel();
InitializeSelectionModel(_selection);
}
return _selection;
}
}
set
{
value ??= CreateDefaultSelectionModel();
if (_updateState is object)
{
_updateState.Selection = new Optional(value);
}
else if (_selection != value)
{
if (value.Source != null && value.Source != Items)
{
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.ToList();
DeinitializeSelectionModel(_selection);
_selection = value;
if (oldSelection?.Count > 0)
{
RaiseEvent(new SelectionChangedEventArgs(
SelectionChangedEvent,
oldSelection,
Array.Empty()));
}
InitializeSelectionModel(_selection);
if (_oldSelectedItems != SelectedItems)
{
RaisePropertyChanged(
SelectedItemsProperty,
new Optional(_oldSelectedItems),
new BindingValue(SelectedItems));
_oldSelectedItems = SelectedItems;
}
}
}
}
///
/// Gets or sets the selection mode.
///
///
/// Note that the selection mode only applies to selections made via user interaction.
/// Multiple selections can be made programatically regardless of the value of this property.
///
protected SelectionMode SelectionMode
{
get { return GetValue(SelectionModeProperty); }
set { SetValue(SelectionModeProperty, value); }
}
///
/// Gets a value indicating whether is set.
///
protected bool AlwaysSelected => SelectionMode.HasFlagCustom(SelectionMode.AlwaysSelected);
///
public override void BeginInit()
{
base.BeginInit();
BeginUpdating();
}
///
public override void EndInit()
{
base.EndInit();
EndUpdating();
}
///
/// Scrolls the specified item into view.
///
/// The index of the item.
public void ScrollIntoView(int index) => Presenter?.ScrollIntoView(index);
///
/// Scrolls the specified item into view.
///
/// The item.
public void ScrollIntoView(object item) => ScrollIntoView(IndexOf(Items, item));
///
/// Tries to get the container that was the source of an event.
///
/// The control that raised the event.
/// The container or null if the event did not originate in a container.
protected IControl? GetContainerFromEventSource(IInteractive? eventSource)
{
for (var current = eventSource as IVisual; current != null; current = current.VisualParent)
{
if (current is IControl control && control.LogicalParent == this &&
ItemContainerGenerator?.IndexFromContainer(control) != -1)
{
return control;
}
}
return null;
}
protected override void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
base.ItemsCollectionChanged(sender, e);
if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0)
{
SelectedIndex = 0;
}
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
AutoScrollToSelectedItemIfNecessary();
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
void ExecuteScrollWhenLayoutUpdated(object sender, EventArgs e)
{
LayoutUpdated -= ExecuteScrollWhenLayoutUpdated;
AutoScrollToSelectedItemIfNecessary();
}
if (AutoScrollToSelectedItem)
{
LayoutUpdated += ExecuteScrollWhenLayoutUpdated;
}
}
///
protected override void OnContainersMaterialized(ItemContainerEventArgs e)
{
base.OnContainersMaterialized(e);
foreach (var container in e.Containers)
{
if ((container.ContainerControl as ISelectable)?.IsSelected == true)
{
Selection.Select(container.Index);
MarkContainerSelected(container.ContainerControl, true);
}
else
{
var selected = Selection.IsSelected(container.Index);
MarkContainerSelected(container.ContainerControl, selected);
}
}
}
///
protected override void OnContainersDematerialized(ItemContainerEventArgs e)
{
base.OnContainersDematerialized(e);
var panel = (InputElement)Presenter.Panel;
if (panel != null)
{
foreach (var container in e.Containers)
{
if (KeyboardNavigation.GetTabOnceActiveElement(panel) == container.ContainerControl)
{
KeyboardNavigation.SetTabOnceActiveElement(panel, null);
break;
}
}
}
}
protected override void OnContainersRecycled(ItemContainerEventArgs e)
{
foreach (var i in e.Containers)
{
if (i.ContainerControl != null && i.Item != null)
{
bool selected = Selection.IsSelected(i.Index);
MarkContainerSelected(i.ContainerControl, selected);
}
}
}
///
protected override void OnDataContextBeginUpdate()
{
base.OnDataContextBeginUpdate();
BeginUpdating();
}
///
protected override void OnDataContextEndUpdate()
{
base.OnDataContextEndUpdate();
EndUpdating();
}
///
/// Called to update the validation state for properties for which data validation is
/// enabled.
///
/// The property.
/// The new binding value for the property.
protected override void UpdateDataValidation(AvaloniaProperty property, BindingValue value)
{
if (property == SelectedItemProperty)
{
DataValidationErrors.SetError(this, value.Error);
}
}
protected override void OnInitialized()
{
base.OnInitialized();
if (_selection is object)
{
_selection.Source = Items;
}
}
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (!e.Handled)
{
var keymap = AvaloniaLocator.Current.GetService();
bool Match(List gestures) => gestures.Any(g => g.Matches(e));
if (ItemCount > 0 &&
Match(keymap.SelectAll) &&
SelectionMode.HasFlagCustom(SelectionMode.Multiple))
{
Selection.SelectAll();
e.Handled = true;
}
}
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == AutoScrollToSelectedItemProperty)
{
AutoScrollToSelectedItemIfNecessary();
}
if (change.Property == ItemsProperty && _updateState is null && _selection is object)
{
var newValue = change.NewValue.GetValueOrDefault();
_selection.Source = newValue;
if (newValue is null)
{
_selection.Clear();
}
}
else if (change.Property == SelectionModeProperty && _selection is object)
{
var newValue = change.NewValue.GetValueOrDefault();
_selection.SingleSelect = !newValue.HasFlagCustom(SelectionMode.Multiple);
}
}
///
/// Moves the selection in the specified direction relative to the current selection.
///
/// The direction to move.
/// Whether to wrap when the selection reaches the first or last item.
/// True if the selection was moved; otherwise false.
protected bool MoveSelection(NavigationDirection direction, bool wrap)
{
var from = SelectedIndex != -1 ? ItemContainerGenerator.ContainerFromIndex(SelectedIndex) : null;
return MoveSelection(from, direction, wrap);
}
///
/// Moves the selection in the specified direction relative to the specified container.
///
/// The container which serves as a starting point for the movement.
/// The direction to move.
/// Whether to wrap when the selection reaches the first or last item.
/// True if the selection was moved; otherwise false.
protected bool MoveSelection(IControl? from, NavigationDirection direction, bool wrap)
{
if (Presenter?.Panel is INavigableContainer container &&
GetNextControl(container, direction, from, wrap) is IControl next)
{
var index = ItemContainerGenerator.IndexFromContainer(next);
if (index != -1)
{
SelectedIndex = index;
return true;
}
}
return false;
}
///
/// Updates the selection for an item based on user interaction.
///
/// The index of the item.
/// Whether the item should be selected or unselected.
/// Whether the range modifier is enabled (i.e. shift key).
/// Whether the toggle modifier is enabled (i.e. ctrl key).
/// Whether the event is a right-click.
protected void UpdateSelection(
int index,
bool select = true,
bool rangeModifier = false,
bool toggleModifier = false,
bool rightButton = false)
{
if (index < 0 || index >= ItemCount)
{
return;
}
var mode = SelectionMode;
var multi = mode.HasFlagCustom(SelectionMode.Multiple);
var toggle = toggleModifier || mode.HasFlagCustom(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();
Selection.Clear();
Selection.SelectRange(Selection.AnchorIndex, index);
}
else if (multi && toggle)
{
if (Selection.IsSelected(index) == true)
{
Selection.Deselect(index);
}
else
{
Selection.Select(index);
}
}
else if (toggle)
{
SelectedIndex = (SelectedIndex == index) ? -1 : index;
}
else
{
using var operation = Selection.BatchUpdate();
Selection.Clear();
Selection.Select(index);
}
if (Presenter?.Panel != null)
{
var container = ItemContainerGenerator.ContainerFromIndex(index);
KeyboardNavigation.SetTabOnceActiveElement(
(InputElement)Presenter.Panel,
container);
}
}
///
/// Updates the selection for a container based on user interaction.
///
/// The container.
/// Whether the container should be selected or unselected.
/// Whether the range modifier is enabled (i.e. shift key).
/// Whether the toggle modifier is enabled (i.e. ctrl key).
/// Whether the event is a right-click.
protected void UpdateSelection(
IControl container,
bool select = true,
bool rangeModifier = false,
bool toggleModifier = false,
bool rightButton = false)
{
var index = ItemContainerGenerator?.IndexFromContainer(container) ?? -1;
if (index != -1)
{
UpdateSelection(index, select, rangeModifier, toggleModifier, rightButton);
}
}
///
/// Updates the selection based on an event that may have originated in a container that
/// belongs to the control.
///
/// The control that raised the event.
/// Whether the container should be selected or unselected.
/// Whether the range modifier is enabled (i.e. shift key).
/// Whether the toggle modifier is enabled (i.e. ctrl key).
/// Whether the event is a right-click.
///
/// True if the event originated from a container that belongs to the control; otherwise
/// false.
///
protected bool UpdateSelectionFromEventSource(
IInteractive? eventSource,
bool select = true,
bool rangeModifier = false,
bool toggleModifier = false,
bool rightButton = false)
{
var container = GetContainerFromEventSource(eventSource);
if (container != null)
{
UpdateSelection(container, select, rangeModifier, toggleModifier, rightButton);
return true;
}
return false;
}
///
/// Called when is raised on
/// .
///
/// The sender.
/// The event args.
private void OnSelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(ISelectionModel.AnchorIndex))
{
_hasScrolledToSelectedItem = false;
AutoScrollToSelectedItemIfNecessary();
}
else if (e.PropertyName == nameof(ISelectionModel.SelectedIndex) && _oldSelectedIndex != SelectedIndex)
{
RaisePropertyChanged(SelectedIndexProperty, _oldSelectedIndex, SelectedIndex);
_oldSelectedIndex = SelectedIndex;
}
else if (e.PropertyName == nameof(ISelectionModel.SelectedItem) && _oldSelectedItem != SelectedItem)
{
RaisePropertyChanged(SelectedItemProperty, _oldSelectedItem, SelectedItem);
_oldSelectedItem = SelectedItem;
}
else if (e.PropertyName == nameof(InternalSelectionModel.WritableSelectedItems) &&
_oldSelectedItems != (Selection as InternalSelectionModel)?.SelectedItems)
{
RaisePropertyChanged(
SelectedItemsProperty,
new Optional(_oldSelectedItems),
new BindingValue(SelectedItems));
_oldSelectedItems = SelectedItems;
}
}
///
/// Called when event is raised on
/// .
///
/// The sender.
/// The event args.
private void OnSelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e)
{
void Mark(int index, bool selected)
{
var container = ItemContainerGenerator.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);
}
var route = BuildEventRoute(SelectionChangedEvent);
if (route.HasHandlers)
{
var ev = new SelectionChangedEventArgs(
SelectionChangedEvent,
e.DeselectedItems.ToList(),
e.SelectedItems.ToList());
RaiseEvent(ev);
}
}
///
/// Called when event is raised on
/// .
///
/// The sender.
/// The event args.
private void OnSelectionModelLostSelection(object sender, EventArgs e)
{
if (AlwaysSelected && Items is object)
{
SelectedIndex = 0;
}
}
private void AutoScrollToSelectedItemIfNecessary()
{
if (AutoScrollToSelectedItem &&
!_hasScrolledToSelectedItem &&
Presenter is object &&
Selection.AnchorIndex >= 0 &&
((IVisual)this).IsAttachedToVisualTree)
{
ScrollIntoView(Selection.AnchorIndex);
_hasScrolledToSelectedItem = true;
}
}
///
/// Called when a container raises the .
///
/// The event.
private void ContainerSelectionChanged(RoutedEventArgs e)
{
if (!_ignoreContainerSelectionChanged &&
e.Source is IControl control &&
e.Source is ISelectable selectable &&
control.LogicalParent == this &&
ItemContainerGenerator?.IndexFromContainer(control) != -1)
{
UpdateSelection(control, selectable.IsSelected);
}
if (e.Source != this)
{
e.Handled = true;
}
}
///
/// Sets a container's 'selected' class or .
///
/// The container.
/// Whether the control is selected
/// The previous selection state.
private bool MarkContainerSelected(IControl container, bool selected)
{
try
{
bool result;
_ignoreContainerSelectionChanged = true;
if (container is ISelectable selectable)
{
result = selectable.IsSelected;
selectable.IsSelected = selected;
}
else
{
result = container.Classes.Contains(":selected");
((IPseudoClasses)container.Classes).Set(":selected", selected);
}
return result;
}
finally
{
_ignoreContainerSelectionChanged = false;
}
}
private void UpdateContainerSelection()
{
if (Presenter?.Panel is IPanel panel)
{
foreach (var container in panel.Children)
{
MarkContainerSelected(
container,
Selection.IsSelected(ItemContainerGenerator.IndexFromContainer(container)));
}
}
}
private ISelectionModel CreateDefaultSelectionModel()
{
return new InternalSelectionModel
{
SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple),
};
}
private void InitializeSelectionModel(ISelectionModel model)
{
if (_updateState is null)
{
model.Source = Items;
}
model.PropertyChanged += OnSelectionModelPropertyChanged;
model.SelectionChanged += OnSelectionModelSelectionChanged;
model.LostSelection += OnSelectionModelLostSelection;
if (model.SingleSelect)
{
SelectionMode &= ~SelectionMode.Multiple;
}
else
{
SelectionMode |= SelectionMode.Multiple;
}
_oldSelectedIndex = model.SelectedIndex;
_oldSelectedItem = model.SelectedItem;
if (AlwaysSelected && model.Count == 0)
{
model.SelectedIndex = 0;
}
UpdateContainerSelection();
if (SelectedIndex != -1)
{
RaiseEvent(new SelectionChangedEventArgs(
SelectionChangedEvent,
Array.Empty(),
Selection.SelectedItems.ToList()));
}
}
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 (state.SelectedItems.HasValue)
{
SelectedItems = state.SelectedItems.Value;
}
Selection.Source = Items;
if (Items is null)
{
Selection.Clear();
}
if (state.SelectedIndex.HasValue)
{
SelectedIndex = state.SelectedIndex.Value;
}
else if (state.SelectedItem.HasValue)
{
SelectedItem = state.SelectedItem.Value;
}
}
}
// 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
{
private Optional _selectedIndex;
private Optional _selectedItem;
public int UpdateCount { get; set; }
public Optional Selection { get; set; }
public Optional SelectedItems { get; set; }
public Optional SelectedIndex
{
get => _selectedIndex;
set
{
_selectedIndex = value;
_selectedItem = default;
}
}
public Optional SelectedItem
{
get => _selectedItem;
set
{
_selectedItem = value;
_selectedIndex = default;
}
}
}
}
}