// Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using Avalonia.Collections; using Avalonia.Controls.Generators; using Avalonia.Data; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; using Avalonia.Styling; using Avalonia.VisualTree; 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 selection 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); /// /// 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 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 int _selectedIndex = -1; private object _selectedItem; private IList _selectedItems; private bool _ignoreContainerSelectionChanged; private bool _syncingSelectedItems; private int _updateCount; private int _updateSelectedIndex; private IList _updateSelectedItems; /// /// Initializes static members of the class. /// static SelectingItemsControl() { IsSelectedChangedEvent.AddClassHandler(x => x.ContainerSelectionChanged); } /// /// 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 { return _selectedIndex; } set { if (_updateCount == 0) { SetAndRaise(SelectedIndexProperty, ref _selectedIndex, (int val, ref int backing, Action notifyWrapper) => { var old = backing; var effective = (val >= 0 && val < Items?.Cast().Count()) ? val : -1; if (old != effective) { backing = effective; notifyWrapper(() => RaisePropertyChanged( SelectedIndexProperty, old, effective, BindingPriority.LocalValue)); SelectedItem = ElementAt(Items, effective); } }, value); } else { _updateSelectedIndex = value; _updateSelectedItems = null; } } } /// /// Gets or sets the selected item. /// public object SelectedItem { get { return _selectedItem; } set { if (_updateCount == 0) { SetAndRaise(SelectedItemProperty, ref _selectedItem, (object val, ref object backing, Action notifyWrapper) => { var old = backing; var index = IndexOf(Items, val); var effective = index != -1 ? val : null; if (!object.Equals(effective, old)) { backing = effective; notifyWrapper(() => RaisePropertyChanged( SelectedItemProperty, old, effective, BindingPriority.LocalValue)); SelectedIndex = index; if (effective != null) { if (SelectedItems.Count != 1 || SelectedItems[0] != effective) { _syncingSelectedItems = true; SelectedItems.Clear(); SelectedItems.Add(effective); _syncingSelectedItems = false; } } else if (SelectedItems.Count > 0) { SelectedItems.Clear(); } } }, value); } else { _updateSelectedItems = new AvaloniaList(value); _updateSelectedIndex = int.MinValue; } } } /// /// Gets the selected items. /// protected IList SelectedItems { get { if (_selectedItems == null) { _selectedItems = new AvaloniaList(); SubscribeToSelectedItems(); } return _selectedItems; } set { if (value?.IsFixedSize == true || value?.IsReadOnly == true) { throw new NotSupportedException( "Cannot use a fixed size or read-only collection as SelectedItems."); } UnsubscribeFromSelectedItems(); _selectedItems = value ?? new AvaloniaList(); SubscribeToSelectedItems(); } } /// /// Gets or sets the selection mode. /// protected SelectionMode SelectionMode { get { return GetValue(SelectionModeProperty); } set { SetValue(SelectionModeProperty, value); } } /// /// Gets a value indicating whether is set. /// protected bool AlwaysSelected => (SelectionMode & SelectionMode.AlwaysSelected) != 0; /// public override void BeginInit() { base.BeginInit(); ++_updateCount; _updateSelectedIndex = int.MinValue; } /// public override void EndInit() { if (--_updateCount == 0) { UpdateFinished(); } base.EndInit(); } /// /// Scrolls the specified item into view. /// /// The item. public void ScrollIntoView(object item) => Presenter?.ScrollIntoView(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) { var item = ((IVisual)eventSource).GetSelfAndVisualAncestors() .OfType() .FirstOrDefault(x => x.LogicalParent == this && ItemContainerGenerator?.IndexFromContainer(x) != -1); return item; } /// protected override void ItemsChanged(AvaloniaPropertyChangedEventArgs e) { base.ItemsChanged(e); if (_updateCount == 0) { var newIndex = -1; if (SelectedIndex != -1) { newIndex = IndexOf((IEnumerable)e.NewValue, SelectedItem); } if (AlwaysSelected && Items != null && Items.Cast().Any()) { newIndex = 0; } SelectedIndex = newIndex; } } /// protected override void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { base.ItemsCollectionChanged(sender, e); switch (e.Action) { case NotifyCollectionChangedAction.Add: if (AlwaysSelected && SelectedIndex == -1) { SelectedIndex = 0; } break; case NotifyCollectionChangedAction.Remove: case NotifyCollectionChangedAction.Replace: var selectedIndex = SelectedIndex; if (selectedIndex >= e.OldStartingIndex && selectedIndex < e.OldStartingIndex + e.OldItems.Count) { if (!AlwaysSelected) { selectedIndex = SelectedIndex = -1; } else { LostSelection(); } } var items = Items?.Cast(); if (selectedIndex >= items.Count()) { selectedIndex = SelectedIndex = items.Count() - 1; } break; case NotifyCollectionChangedAction.Move: case NotifyCollectionChangedAction.Reset: SelectedIndex = IndexOf(Items, SelectedItem); break; } } /// protected override void OnContainersMaterialized(ItemContainerEventArgs e) { base.OnContainersMaterialized(e); var selectedIndex = SelectedIndex; var selectedContainer = e.Containers .FirstOrDefault(x => (x.ContainerControl as ISelectable)?.IsSelected == true); if (selectedContainer != null) { SelectedIndex = selectedContainer.Index; } else if (selectedIndex >= e.StartingIndex && selectedIndex < e.StartingIndex + e.Containers.Count) { var container = e.Containers[selectedIndex - e.StartingIndex]; if (container.ContainerControl != null) { MarkContainerSelected(container.ContainerControl, true); } } } /// 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) { var ms = MemberSelector; bool selected = ms == null ? SelectedItems.Contains(i.Item) : SelectedItems.OfType().Any(v => Equals(ms.Select(v), i.Item)); MarkContainerSelected(i.ContainerControl, selected); } } } /// protected override void OnDataContextBeginUpdate() { base.OnDataContextBeginUpdate(); ++_updateCount; } /// protected override void OnDataContextEndUpdate() { base.OnDataContextEndUpdate(); if (--_updateCount == 0) { UpdateFinished(); } } 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 (this.SelectionMode == SelectionMode.Multiple && Match(keymap.SelectAll)) { SynchronizeItems(SelectedItems, Items?.Cast()); e.Handled = true; } } } /// /// 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). protected void UpdateSelection( int index, bool select = true, bool rangeModifier = false, bool toggleModifier = false) { if (index != -1) { if (select) { var mode = SelectionMode; var toggle = toggleModifier || (mode & SelectionMode.Toggle) != 0; var multi = (mode & SelectionMode.Multiple) != 0; var range = multi && SelectedIndex != -1 && rangeModifier; if (!toggle && !range) { SelectedIndex = index; } else if (multi && range) { SynchronizeItems( SelectedItems, GetRange(Items, SelectedIndex, index)); } else { var item = ElementAt(Items, index); var i = SelectedItems.IndexOf(item); if (i != -1 && (!AlwaysSelected || SelectedItems.Count > 1)) { SelectedItems.Remove(item); } else { if (multi) { SelectedItems.Add(item); } else { SelectedIndex = index; } } } if (Presenter?.Panel != null) { var container = ItemContainerGenerator.ContainerFromIndex(index); KeyboardNavigation.SetTabOnceActiveElement( (InputElement)Presenter.Panel, container); } } else { LostSelection(); } } } /// /// 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). protected void UpdateSelection( IControl container, bool select = true, bool rangeModifier = false, bool toggleModifier = false) { var index = ItemContainerGenerator?.IndexFromContainer(container) ?? -1; if (index != -1) { UpdateSelection(index, select, rangeModifier, toggleModifier); } } /// /// 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). /// /// 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) { var container = GetContainerFromEventSource(eventSource); if (container != null) { UpdateSelection(container, select, rangeModifier, toggleModifier); return true; } return false; } /// /// Makes a list of objects equal another. /// /// The items collection. /// The desired items. internal static void SynchronizeItems(IList items, IEnumerable desired) { var index = 0; foreach (object item in desired) { int itemIndex = items.IndexOf(item); if (itemIndex == -1) { items.Insert(index, item); } else if(itemIndex != index) { items.RemoveAt(itemIndex); items.Insert(index, item); } ++index; } while (index < items.Count) { items.RemoveAt(items.Count - 1); } } /// /// Gets a range of items from an IEnumerable. /// /// The items. /// The index of the first item. /// The index of the last item. /// The items. private static IEnumerable GetRange(IEnumerable items, int first, int last) { var list = (items as IList) ?? items.Cast().ToList(); int step = first > last ? -1 : 1; for (int i = first; i != last; i += step) { yield return list[i]; } yield return list[last]; } /// /// Called when a container raises the . /// /// The event. private void ContainerSelectionChanged(RoutedEventArgs e) { if (!_ignoreContainerSelectionChanged) { var control = e.Source as IControl; var selectable = e.Source as ISelectable; if (control != null && selectable != null && control.LogicalParent == this && ItemContainerGenerator?.IndexFromContainer(control) != -1) { UpdateSelection(control, selectable.IsSelected); } } if (e.Source != this) { e.Handled = true; } } /// /// Called when the currently selected item is lost and the selection must be changed /// depending on the property. /// private void LostSelection() { var items = Items?.Cast(); if (items != null && AlwaysSelected) { var index = Math.Min(SelectedIndex, items.Count() - 1); if (index > -1) { SelectedItem = items.ElementAt(index); return; } } SelectedIndex = -1; } /// /// 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 { var selectable = container as ISelectable; bool result; _ignoreContainerSelectionChanged = true; if (selectable != null) { result = selectable.IsSelected; selectable.IsSelected = selected; } else { result = container.Classes.Contains(":selected"); ((IPseudoClasses)container.Classes).Set(":selected", selected); } return result; } finally { _ignoreContainerSelectionChanged = false; } } /// /// Sets an item container's 'selected' class or . /// /// The index of the item. /// Whether the item should be selected or deselected. private void MarkItemSelected(int index, bool selected) { var container = ItemContainerGenerator?.ContainerFromIndex(index); if (container != null) { MarkContainerSelected(container, selected); } } /// /// Sets an item container's 'selected' class or . /// /// The item. /// Whether the item should be selected or deselected. private void MarkItemSelected(object item, bool selected) { var index = IndexOf(Items, item); if (index != -1) { MarkItemSelected(index, selected); } } /// /// Called when the CollectionChanged event is raised. /// /// The event sender. /// The event args. private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { var generator = ItemContainerGenerator; IList added = null; IList removed = null; switch (e.Action) { case NotifyCollectionChangedAction.Add: SelectedItemsAdded(e.NewItems.Cast().ToList()); if (AutoScrollToSelectedItem) { ScrollIntoView(e.NewItems[0]); } added = e.NewItems; break; case NotifyCollectionChangedAction.Remove: if (SelectedItems.Count == 0) { if (!_syncingSelectedItems) { SelectedIndex = -1; } } foreach (var item in e.OldItems) { MarkItemSelected(item, false); } removed = e.OldItems; break; case NotifyCollectionChangedAction.Reset: if (generator != null) { removed = new List(); foreach (var item in generator.Containers) { if (item?.ContainerControl != null) { if (MarkContainerSelected(item.ContainerControl, false)) { removed.Add(item.Item); } } } } if (SelectedItems.Count > 0) { _selectedItem = null; SelectedItemsAdded(SelectedItems); added = SelectedItems; } else if (!_syncingSelectedItems) { SelectedIndex = -1; } break; case NotifyCollectionChangedAction.Replace: foreach (var item in e.OldItems) { MarkItemSelected(item, false); } foreach (var item in e.NewItems) { MarkItemSelected(item, true); } if (SelectedItem != SelectedItems[0] && !_syncingSelectedItems) { var oldItem = SelectedItem; var oldIndex = SelectedIndex; var item = SelectedItems[0]; var index = IndexOf(Items, item); _selectedIndex = index; _selectedItem = item; RaisePropertyChanged(SelectedIndexProperty, oldIndex, index, BindingPriority.LocalValue); RaisePropertyChanged(SelectedItemProperty, oldItem, item, BindingPriority.LocalValue); } added = e.NewItems; removed = e.OldItems; break; } if (added?.Count > 0 || removed?.Count > 0) { var changed = new SelectionChangedEventArgs( SelectionChangedEvent, added ?? Empty, removed ?? Empty); RaiseEvent(changed); } } /// /// Called when items are added to the collection. /// /// The added items. private void SelectedItemsAdded(IList items) { if (items.Count > 0) { foreach (var item in items) { MarkItemSelected(item, true); } if (SelectedItem == null && !_syncingSelectedItems) { var index = IndexOf(Items, items[0]); if (index != -1) { _selectedItem = items[0]; _selectedIndex = index; RaisePropertyChanged(SelectedIndexProperty, -1, index, BindingPriority.LocalValue); RaisePropertyChanged(SelectedItemProperty, null, items[0], BindingPriority.LocalValue); } } } } /// /// Subscribes to the CollectionChanged event, if any. /// private void SubscribeToSelectedItems() { var incc = _selectedItems as INotifyCollectionChanged; if (incc != null) { incc.CollectionChanged += SelectedItemsCollectionChanged; } SelectedItemsCollectionChanged( _selectedItems, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } /// /// Unsubscribes from the CollectionChanged event, if any. /// private void UnsubscribeFromSelectedItems() { var incc = _selectedItems as INotifyCollectionChanged; if (incc != null) { incc.CollectionChanged -= SelectedItemsCollectionChanged; } } private void UpdateFinished() { if (_updateSelectedIndex != int.MinValue) { SelectedIndex = _updateSelectedIndex; } else if (_updateSelectedItems != null) { SelectedItems = _updateSelectedItems; } } } }