// 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.Controls.Primitives; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; using Avalonia.Threading; using Avalonia.VisualTree; namespace Avalonia.Controls { /// /// Displays a hierarchical tree of data. /// public class TreeView : ItemsControl, ICustomKeyboardNavigation { /// /// Defines the property. /// public static readonly StyledProperty AutoScrollToSelectedItemProperty = SelectingItemsControl.AutoScrollToSelectedItemProperty.AddOwner(); /// /// Defines the property. /// public static readonly DirectProperty SelectedItemProperty = SelectingItemsControl.SelectedItemProperty.AddOwner( o => o.SelectedItem, (o, v) => o.SelectedItem = v); /// /// Defines the property. /// public static readonly DirectProperty SelectedItemsProperty = ListBox.SelectedItemsProperty.AddOwner( o => o.SelectedItems, (o, v) => o.SelectedItems = v); /// /// Defines the property. /// public static readonly StyledProperty SelectionModeProperty = ListBox.SelectionModeProperty.AddOwner(); private static readonly IList Empty = Array.Empty(); private object _selectedItem; private IList _selectedItems; /// /// Initializes static members of the class. /// static TreeView() { // HACK: Needed or SelectedItem property will not be found in Release build. } /// /// Occurs when the control's selection changes. /// public event EventHandler SelectionChanged { add => AddHandler(SelectingItemsControl.SelectionChangedEvent, value); remove => RemoveHandler(SelectingItemsControl.SelectionChangedEvent, value); } /// /// Gets the for the tree view. /// public new ITreeItemContainerGenerator ItemContainerGenerator => (ITreeItemContainerGenerator)base.ItemContainerGenerator; /// /// Gets or sets a value indicating whether to automatically scroll to newly selected items. /// public bool AutoScrollToSelectedItem { get => GetValue(AutoScrollToSelectedItemProperty); set => SetValue(AutoScrollToSelectedItemProperty, value); } private bool _syncingSelectedItems; /// /// Gets or sets the selection mode. /// public SelectionMode SelectionMode { get => GetValue(SelectionModeProperty); set => SetValue(SelectionModeProperty, value); } /// /// Gets or sets the selected item. /// public object SelectedItem { get => _selectedItem; set { var selectedItems = SelectedItems; SetAndRaise(SelectedItemProperty, ref _selectedItem, value); if (value != null) { if (selectedItems.Count != 1 || selectedItems[0] != value) { _syncingSelectedItems = true; SelectSingleItem(value); _syncingSelectedItems = false; } } else if (SelectedItems.Count > 0) { SelectedItems.Clear(); } } } /// /// Gets the selected items. /// public 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(); } } /// /// Expands the specified all descendent s. /// /// The item to expand. public void ExpandSubTree(TreeViewItem item) { item.IsExpanded = true; var panel = item.Presenter.Panel; if (panel != null) { foreach (var child in panel.Children) { if (child is TreeViewItem treeViewItem) { ExpandSubTree(treeViewItem); } } } } /// /// Selects all items in the . /// /// /// Note that this method only selects nodes currently visible due to their parent nodes /// being expanded: it does not expand nodes. /// public void SelectAll() { SynchronizeItems(SelectedItems, ItemContainerGenerator.Index.Items); } /// /// Deselects all items in the . /// public void UnselectAll() { SelectedItems.Clear(); } /// /// Subscribes to the CollectionChanged event, if any. /// private void SubscribeToSelectedItems() { if (_selectedItems is INotifyCollectionChanged incc) { incc.CollectionChanged += SelectedItemsCollectionChanged; } SelectedItemsCollectionChanged( _selectedItems, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } private void SelectSingleItem(object item) { SelectedItems.Clear(); SelectedItems.Add(item); } /// /// Called when the CollectionChanged event is raised. /// /// The event sender. /// The event args. private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { IList added = null; IList removed = null; switch (e.Action) { case NotifyCollectionChangedAction.Add: SelectedItemsAdded(e.NewItems.Cast().ToArray()); if (AutoScrollToSelectedItem) { var container = (TreeViewItem)ItemContainerGenerator.Index.ContainerFromItem(e.NewItems[0]); container?.BringIntoView(); } added = e.NewItems; break; case NotifyCollectionChangedAction.Remove: if (!_syncingSelectedItems) { if (SelectedItems.Count == 0) { SelectedItem = null; } else { var selectedIndex = SelectedItems.IndexOf(_selectedItem); if (selectedIndex == -1) { var old = _selectedItem; _selectedItem = SelectedItems[0]; RaisePropertyChanged(SelectedItemProperty, old, _selectedItem); } } } foreach (var item in e.OldItems) { MarkItemSelected(item, false); } removed = e.OldItems; break; case NotifyCollectionChangedAction.Reset: foreach (IControl container in ItemContainerGenerator.Index.Containers) { MarkContainerSelected(container, false); } if (SelectedItems.Count > 0) { SelectedItemsAdded(SelectedItems); added = SelectedItems; } else if (!_syncingSelectedItems) { SelectedItem = null; } 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 item = SelectedItems[0]; _selectedItem = item; RaisePropertyChanged(SelectedItemProperty, oldItem, item); } added = e.NewItems; removed = e.OldItems; break; } if (added?.Count > 0 || removed?.Count > 0) { var changed = new SelectionChangedEventArgs( SelectingItemsControl.SelectionChangedEvent, added ?? Empty, removed ?? Empty); RaiseEvent(changed); } } private void MarkItemSelected(object item, bool selected) { var container = ItemContainerGenerator.Index.ContainerFromItem(item); MarkContainerSelected(container, selected); } private void SelectedItemsAdded(IList items) { if (items.Count == 0) { return; } foreach (object item in items) { MarkItemSelected(item, true); } if (SelectedItem == null && !_syncingSelectedItems) { SetAndRaise(SelectedItemProperty, ref _selectedItem, items[0]); } } /// /// Unsubscribes from the CollectionChanged event, if any. /// private void UnsubscribeFromSelectedItems() { if (_selectedItems is INotifyCollectionChanged incc) { incc.CollectionChanged -= SelectedItemsCollectionChanged; } } (bool handled, IInputElement next) ICustomKeyboardNavigation.GetNext(IInputElement element, NavigationDirection direction) { if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous) { if (!this.IsVisualAncestorOf(element)) { IControl result = _selectedItem != null ? ItemContainerGenerator.Index.ContainerFromItem(_selectedItem) : ItemContainerGenerator.ContainerFromIndex(0); return (true, result); } return (true, null); } return (false, null); } /// protected override IItemContainerGenerator CreateItemContainerGenerator() { var result = new TreeItemContainerGenerator( this, TreeViewItem.HeaderProperty, TreeViewItem.ItemTemplateProperty, TreeViewItem.ItemsProperty, TreeViewItem.IsExpandedProperty, new TreeContainerIndex()); result.Index.Materialized += ContainerMaterialized; return result; } /// protected override void OnGotFocus(GotFocusEventArgs e) { if (e.NavigationMethod == NavigationMethod.Directional) { e.Handled = UpdateSelectionFromEventSource( e.Source, true, (e.InputModifiers & InputModifiers.Shift) != 0); } } protected override void OnKeyDown(KeyEventArgs e) { var direction = e.Key.ToNavigationDirection(); if (direction?.IsDirectional() == true && !e.Handled) { if (SelectedItem != null) { var next = GetContainerInDirection( GetContainerFromEventSource(e.Source), direction.Value, true); if (next != null) { FocusManager.Instance.Focus(next, NavigationMethod.Directional); e.Handled = true; } } else { SelectedItem = ElementAt(Items, 0); } } 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)) { SelectAll(); e.Handled = true; } } } private TreeViewItem GetContainerInDirection( TreeViewItem from, NavigationDirection direction, bool intoChildren) { IItemContainerGenerator parentGenerator = GetParentContainerGenerator(from); if (parentGenerator == null) { return null; } var index = parentGenerator.IndexFromContainer(from); var parent = from.Parent as ItemsControl; TreeViewItem result = null; switch (direction) { case NavigationDirection.Up: if (index > 0) { var previous = (TreeViewItem)parentGenerator.ContainerFromIndex(index - 1); result = previous.IsExpanded && previous.ItemCount > 0 ? (TreeViewItem)previous.ItemContainerGenerator.ContainerFromIndex(previous.ItemCount - 1) : previous; } else { result = from.Parent as TreeViewItem; } break; case NavigationDirection.Down: if (from.IsExpanded && intoChildren && from.ItemCount > 0) { result = (TreeViewItem)from.ItemContainerGenerator.ContainerFromIndex(0); } else if (index < parent?.ItemCount - 1) { result = (TreeViewItem)parentGenerator.ContainerFromIndex(index + 1); } else if (parent is TreeViewItem parentItem) { return GetContainerInDirection(parentItem, direction, false); } break; } return result; } /// protected override void OnPointerPressed(PointerPressedEventArgs e) { base.OnPointerPressed(e); if (e.MouseButton == MouseButton.Left || e.MouseButton == MouseButton.Right) { e.Handled = UpdateSelectionFromEventSource( e.Source, true, (e.InputModifiers & InputModifiers.Shift) != 0, (e.InputModifiers & InputModifiers.Control) != 0, e.MouseButton == MouseButton.Right); } } /// /// Updates the selection for an item based on user interaction. /// /// The container. /// 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 UpdateSelectionFromContainer( IControl container, bool select = true, bool rangeModifier = false, bool toggleModifier = false, bool rightButton = false) { var item = ItemContainerGenerator.Index.ItemFromContainer(container); if (item == null) { return; } IControl selectedContainer = null; if (SelectedItem != null) { selectedContainer = ItemContainerGenerator.Index.ContainerFromItem(SelectedItem); } var mode = SelectionMode; var toggle = toggleModifier || (mode & SelectionMode.Toggle) != 0; var multi = (mode & SelectionMode.Multiple) != 0; var range = multi && selectedContainer != null && rangeModifier; if (rightButton) { if (!SelectedItems.Contains(item)) { SelectSingleItem(item); } } else if (!toggle && !range) { SelectSingleItem(item); } else if (multi && range) { SynchronizeItems( SelectedItems, GetItemsInRange(selectedContainer as TreeViewItem, container as TreeViewItem)); } else { var i = SelectedItems.IndexOf(item); if (i != -1) { SelectedItems.Remove(item); } else { if (multi) { SelectedItems.Add(item); } else { SelectedItem = item; } } } } private static IItemContainerGenerator GetParentContainerGenerator(TreeViewItem item) { if (item == null) { return null; } switch (item.Parent) { case TreeView treeView: return treeView.ItemContainerGenerator; case TreeViewItem treeViewItem: return treeViewItem.ItemContainerGenerator; default: return null; } } /// /// Find which node is first in hierarchy. /// /// Search root. /// Nodes to find. /// Node to find. /// Found first node. private static TreeViewItem FindFirstNode(TreeView treeView, TreeViewItem nodeA, TreeViewItem nodeB) { return FindInContainers(treeView.ItemContainerGenerator, nodeA, nodeB); } private static TreeViewItem FindInContainers(ITreeItemContainerGenerator containerGenerator, TreeViewItem nodeA, TreeViewItem nodeB) { IEnumerable containers = containerGenerator.Containers; foreach (ItemContainerInfo container in containers) { TreeViewItem node = FindFirstNode(container.ContainerControl as TreeViewItem, nodeA, nodeB); if (node != null) { return node; } } return null; } private static TreeViewItem FindFirstNode(TreeViewItem node, TreeViewItem nodeA, TreeViewItem nodeB) { if (node == null) { return null; } TreeViewItem match = node == nodeA ? nodeA : node == nodeB ? nodeB : null; if (match != null) { return match; } return FindInContainers(node.ItemContainerGenerator, nodeA, nodeB); } /// /// Returns all items that belong to containers between and . /// The range is inclusive. /// /// From container. /// To container. private List GetItemsInRange(TreeViewItem from, TreeViewItem to) { var items = new List(); if (from == null || to == null) { return items; } TreeViewItem firstItem = FindFirstNode(this, from, to); if (firstItem == null) { return items; } bool wasReversed = false; if (firstItem == to) { var temp = from; from = to; to = temp; wasReversed = true; } TreeViewItem node = from; while (node != to) { var item = ItemContainerGenerator.Index.ItemFromContainer(node); if (item != null) { items.Add(item); } node = GetContainerInDirection(node, NavigationDirection.Down, true); } var toItem = ItemContainerGenerator.Index.ItemFromContainer(to); if (toItem != null) { items.Add(toItem); } if (wasReversed) { items.Reverse(); } return items; } /// /// 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) { UpdateSelectionFromContainer(container, select, rangeModifier, toggleModifier, rightButton); return true; } return false; } /// /// 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 TreeViewItem GetContainerFromEventSource(IInteractive eventSource) { var item = ((IVisual)eventSource).GetSelfAndVisualAncestors() .OfType() .FirstOrDefault(); if (item != null) { if (item.ItemContainerGenerator.Index == ItemContainerGenerator.Index) { return item; } } return null; } /// /// Called when a new item container is materialized, to set its selected state. /// /// The event sender. /// The event args. private void ContainerMaterialized(object sender, ItemContainerEventArgs e) { var selectedItem = SelectedItem; if (selectedItem == null) { return; } foreach (var container in e.Containers) { if (container.Item == selectedItem) { ((TreeViewItem)container.ContainerControl).IsSelected = true; if (AutoScrollToSelectedItem) { Dispatcher.UIThread.Post(container.ContainerControl.BringIntoView); } break; } } } /// /// Sets a container's 'selected' class or . /// /// The container. /// Whether the control is selected private void MarkContainerSelected(IControl container, bool selected) { if (container == null) { return; } if (container is ISelectable selectable) { selectable.IsSelected = selected; } else { container.Classes.Set(":selected", selected); } } /// /// Makes a list of objects equal another (though doesn't preserve order). /// /// The items collection. /// The desired items. private static void SynchronizeItems(IList items, IEnumerable desired) { var list = items.Cast().ToList(); var toRemove = list.Except(desired).ToList(); var toAdd = desired.Except(list).ToList(); foreach (var i in toRemove) { items.Remove(i); } foreach (var i in toAdd) { items.Add(i); } } } }