Browse Source

Merge pull request #9598 from Glen-Nicol-Garmin/treeview_double_click

Feature: Double click expand/collapse treeview and numpad keyboard shortcuts
pull/9665/head
Steven Kirk 3 years ago
committed by GitHub
parent
commit
75ae98e442
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 20
      src/Avalonia.Controls/TreeView.cs
  2. 124
      src/Avalonia.Controls/TreeViewItem.cs
  3. 1
      src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml
  4. 1
      src/Avalonia.Themes.Simple/Controls/TreeViewItem.xaml
  5. 599
      tests/Avalonia.Controls.UnitTests/TreeViewTests.cs
  6. 16
      tests/Avalonia.UnitTests/MouseTestHelper.cs

20
src/Avalonia.Controls/TreeView.cs

@ -179,6 +179,26 @@ namespace Avalonia.Controls
}
}
/// <summary>
/// Collapse the specified <see cref="TreeViewItem"/> all descendent <see cref="TreeViewItem"/> s.
/// </summary>
/// <param name="item">The item to collapse.</param>
public void CollapseSubTree(TreeViewItem item)
{
item.IsExpanded = false;
if (item.Presenter?.Panel != null)
{
foreach (var child in item.Presenter.Panel.Children)
{
if (child is TreeViewItem treeViewItem)
{
CollapseSubTree(treeViewItem);
}
}
}
}
/// <summary>
/// Selects all items in the <see cref="TreeView"/>.
/// </summary>

124
src/Avalonia.Controls/TreeViewItem.cs

@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls.Generators;
using Avalonia.Controls.Metadata;
@ -166,30 +168,94 @@ namespace Avalonia.Controls
{
if (!e.Handled)
{
switch (e.Key)
Func<TreeViewItem, bool>? handler =
e.Key switch
{
Key.Left => ApplyToItemOrRecursivelyIfCtrl(FocusAwareCollapseItem, e.KeyModifiers),
Key.Right => ApplyToItemOrRecursivelyIfCtrl(ExpandItem, e.KeyModifiers),
Key.Enter or Key.Space => ApplyToItemOrRecursivelyIfCtrl(IsExpanded ? CollapseItem : ExpandItem, e.KeyModifiers),
// do not handle CTRL with numpad keys
Key.Subtract => FocusAwareCollapseItem,
Key.Add => ExpandItem,
Key.Divide => ApplyToSubtree(CollapseItem),
Key.Multiply => ApplyToSubtree(ExpandItem),
_ => null,
};
if (handler is not null)
{
e.Handled = handler(this);
}
// NOTE: these local functions do not use the TreeView.Expand/CollapseSubtree
// function because we want to know if any items were in fact expanded to set the
// event handled status. Also the handling here avoids a potential infinite recursion/stack overflow.
static Func<TreeViewItem, bool> ApplyToSubtree(Func<TreeViewItem, bool> f)
{
// Calling toList enumerates all items before applying functions. This avoids a
// potential infinite loop if there is an infinite tree (the control catalog is
// lazily infinite). But also means a lazily loaded tree will not be expanded completely.
return t => SubTree(t)
.ToList()
.Select(treeViewItem => f(treeViewItem))
.Aggregate(false, (p, c) => p || c);
}
static Func<TreeViewItem, bool> ApplyToItemOrRecursivelyIfCtrl(Func<TreeViewItem,bool> f, KeyModifiers keyModifiers)
{
if (keyModifiers.HasAllFlags(KeyModifiers.Control))
{
return ApplyToSubtree(f);
}
return f;
}
static bool ExpandItem(TreeViewItem treeViewItem)
{
if (treeViewItem.ItemCount > 0 && !treeViewItem.IsExpanded)
{
treeViewItem.IsExpanded = true;
return true;
}
return false;
}
static bool CollapseItem(TreeViewItem treeViewItem)
{
case Key.Right:
if (Items != null && Items.Cast<object>().Any() && !IsExpanded)
if (treeViewItem.ItemCount > 0 && treeViewItem.IsExpanded)
{
treeViewItem.IsExpanded = false;
return true;
}
return false;
}
static bool FocusAwareCollapseItem(TreeViewItem treeViewItem)
{
if (treeViewItem.ItemCount > 0 && treeViewItem.IsExpanded)
{
if (treeViewItem.IsFocused)
{
IsExpanded = true;
e.Handled = true;
treeViewItem.IsExpanded = false;
}
break;
case Key.Left:
if (Items is not null && Items.Cast<object>().Any() && IsExpanded)
else
{
if (IsFocused)
{
IsExpanded = false;
}
else
{
FocusManager.Instance?.Focus(this, NavigationMethod.Directional);
}
e.Handled = true;
FocusManager.Instance?.Focus(treeViewItem, NavigationMethod.Directional);
}
break;
return true;
}
return false;
}
static IEnumerable<TreeViewItem> SubTree(TreeViewItem treeViewItem)
{
return new[] { treeViewItem }.Concat(treeViewItem.LogicalChildren.OfType<TreeViewItem>().SelectMany(child => SubTree(child)));
}
}
@ -198,8 +264,19 @@ namespace Avalonia.Controls
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
if (_header is InputElement previousInputMethod)
{
previousInputMethod.DoubleTapped -= HeaderDoubleTapped;
}
_header = e.NameScope.Find<Control>("PART_Header");
_templateApplied = true;
if (_header is InputElement im)
{
im.DoubleTapped += HeaderDoubleTapped;
}
if (_deferredBringIntoViewFlag)
{
_deferredBringIntoViewFlag = false;
@ -220,6 +297,15 @@ namespace Avalonia.Controls
return logical != null ? result : @default;
}
private void HeaderDoubleTapped(object? sender, TappedEventArgs e)
{
if (ItemCount > 0)
{
IsExpanded = !IsExpanded;
e.Handled = true;
}
}
private void OnParentChanged(AvaloniaPropertyChangedEventArgs e)
{
if (!((ILogical)this).IsAttachedToLogicalTree && e.NewValue is null)

1
src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml

@ -75,6 +75,7 @@
MinHeight="{TemplateBinding MinHeight}"
TemplatedControl.IsTemplateFocusTarget="True">
<Grid Name="PART_Header"
Background="Transparent"
ColumnDefinitions="Auto, *"
Margin="{TemplateBinding Level, Mode=OneWay, Converter={StaticResource TreeViewItemLeftMarginConverter}}">
<Panel Name="PART_ExpandCollapseChevronContainer"

1
src/Avalonia.Themes.Simple/Controls/TreeViewItem.xaml

@ -44,6 +44,7 @@
Focusable="True"
TemplatedControl.IsTemplateFocusTarget="True">
<Grid Name="PART_Header"
Background="Transparent"
Margin="{TemplateBinding Level,
Mode=OneWay,
Converter={StaticResource LeftMarginConverter}}"

599
tests/Avalonia.Controls.UnitTests/TreeViewTests.cs

@ -424,6 +424,587 @@ namespace Avalonia.Controls.UnitTests
}
}
[Fact]
public void Double_Clicking_Item_Header_Should_Expand_It()
{
using (Application())
{
var tree = CreateTestTreeData();
var target = new TreeView
{
Template = CreateTreeViewTemplate(),
Items = tree,
};
var visualRoot = new TestRoot();
visualRoot.Child = target;
CreateNodeDataTemplate(target);
ApplyTemplates(target);
CollapseAll(target);
var item = tree[0].Children[1];
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(container);
Assert.False(container.IsExpanded);
var header = container.Header as Interactive;
Assert.NotNull(header);
_mouse.DoubleClick(header);
Assert.True(container.IsExpanded);
}
}
[Fact]
public void Double_Clicking_Item_Header_With_No_Children_Does_Not_Expand_It()
{
using (Application())
{
var tree = CreateTestTreeData();
var target = new TreeView
{
Template = CreateTreeViewTemplate(),
Items = tree,
};
var visualRoot = new TestRoot();
visualRoot.Child = target;
CreateNodeDataTemplate(target);
ApplyTemplates(target);
CollapseAll(target);
var item = tree[0].Children[1].Children[0];
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(container);
Assert.False(container.IsExpanded);
var header = container.Header as Interactive;
Assert.NotNull(header);
_mouse.DoubleClick(header);
Assert.False(container.IsExpanded);
}
}
[Fact]
public void Double_Clicking_Item_Header_Should_Collapse_It()
{
using (Application())
{
var tree = CreateTestTreeData();
var target = new TreeView
{
Template = CreateTreeViewTemplate(),
Items = tree,
};
var visualRoot = new TestRoot();
visualRoot.Child = target;
CreateNodeDataTemplate(target);
ApplyTemplates(target);
ExpandAll(target);
var item = tree[0].Children[1];
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(container);
Assert.True(container.IsExpanded);
var header = container.Header as Interactive;
Assert.NotNull(header);
_mouse.DoubleClick(header);
Assert.False(container.IsExpanded);
}
}
[Fact]
public void Enter_Key_Should_Collapse_TreeViewItem()
{
using (Application())
{
var tree = CreateTestTreeData();
var target = new TreeView
{
Template = CreateTreeViewTemplate(),
Items = tree,
};
var visualRoot = new TestRoot();
visualRoot.Child = target;
CreateNodeDataTemplate(target);
ApplyTemplates(target);
ExpandAll(target); // NOTE this line
var item = tree[0];
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(container);
Assert.True(container.IsExpanded);
var header = container.Header as Interactive;
Assert.NotNull(header);
container.RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = Key.Enter,
});
Assert.False(container.IsExpanded);
}
}
[Fact]
public void Enter_plus_Ctrl_Key_Should_Collapse_TreeViewItem_Recursively()
{
using (Application())
{
var tree = CreateTestTreeData();
var target = new TreeView
{
Template = CreateTreeViewTemplate(),
Items = tree,
};
var visualRoot = new TestRoot();
visualRoot.Child = target;
CreateNodeDataTemplate(target);
ApplyTemplates(target);
ExpandAll(target); // NOTE this line
var item = tree[0];
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(container);
Assert.True(container.IsExpanded);
var header = container.Header as Interactive;
Assert.NotNull(header);
container.RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = Key.Enter,
KeyModifiers = KeyModifiers.Control,
});
Assert.False(container.IsExpanded);
AssertEachItemWithChildrenIsCollapsed(item);
void AssertEachItemWithChildrenIsCollapsed(Node node)
{
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node);
Assert.NotNull(container);
if (node.Children?.Count > 0)
{
Assert.False(container.IsExpanded);
foreach (var c in node.Children)
{
AssertEachItemWithChildrenIsCollapsed(c);
}
}
else
{
Assert.True(container.IsExpanded);
}
}
}
}
[Fact]
public void Enter_Key_Should_Expand_TreeViewItem()
{
using (Application())
{
var tree = CreateTestTreeData();
var target = new TreeView
{
Template = CreateTreeViewTemplate(),
Items = tree,
};
var visualRoot = new TestRoot();
visualRoot.Child = target;
CreateNodeDataTemplate(target);
ApplyTemplates(target);
CollapseAll(target); // NOTE this line
var item = tree[0];
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(container);
Assert.False(container.IsExpanded);
var header = container.Header as Interactive;
Assert.NotNull(header);
container.RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = Key.Enter,
});
Assert.True(container.IsExpanded);
}
}
[Fact]
public void Enter_plus_Ctrl_Key_Should_Expand_TreeViewItem_Recursively()
{
using (Application())
{
var tree = CreateTestTreeData();
var target = new TreeView
{
Template = CreateTreeViewTemplate(),
Items = tree,
};
var visualRoot = new TestRoot();
visualRoot.Child = target;
CreateNodeDataTemplate(target);
ApplyTemplates(target);
CollapseAll(target); // NOTE this line
var item = tree[0];
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(container);
Assert.False(container.IsExpanded);
var header = container.Header as Interactive;
Assert.NotNull(header);
container.RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = Key.Enter,
KeyModifiers = KeyModifiers.Control,
});
Assert.True(container.IsExpanded);
AssertEachItemWithChildrenIsExpanded(item);
void AssertEachItemWithChildrenIsExpanded(Node node)
{
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node);
Assert.NotNull(container);
if (node.Children?.Count > 0)
{
Assert.True(container.IsExpanded);
foreach (var c in node.Children)
{
AssertEachItemWithChildrenIsExpanded(c);
}
}
else
{
Assert.False(container.IsExpanded);
}
}
}
}
[Fact]
public void Space_Key_Should_Collapse_TreeViewItem()
{
using (Application())
{
var tree = CreateTestTreeData();
var target = new TreeView
{
Template = CreateTreeViewTemplate(),
Items = tree,
};
var visualRoot = new TestRoot();
visualRoot.Child = target;
CreateNodeDataTemplate(target);
ApplyTemplates(target);
ExpandAll(target); // NOTE this line
var item = tree[0];
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(container);
Assert.True(container.IsExpanded);
var header = container.Header as Interactive;
Assert.NotNull(header);
container.RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = Key.Enter,
});
Assert.False(container.IsExpanded);
}
}
[Fact]
public void Space_plus_Ctrl_Key_Should_Collapse_TreeViewItem_Recursively()
{
using (Application())
{
var tree = CreateTestTreeData();
var target = new TreeView
{
Template = CreateTreeViewTemplate(),
Items = tree,
};
var visualRoot = new TestRoot();
visualRoot.Child = target;
CreateNodeDataTemplate(target);
ApplyTemplates(target);
ExpandAll(target); // NOTE this line
var item = tree[0];
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(container);
Assert.True(container.IsExpanded);
var header = container.Header as Interactive;
Assert.NotNull(header);
container.RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = Key.Enter,
KeyModifiers = KeyModifiers.Control,
});
Assert.False(container.IsExpanded);
AssertEachItemWithChildrenIsCollapsed(item);
void AssertEachItemWithChildrenIsCollapsed(Node node)
{
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node);
Assert.NotNull(container);
if (node.Children?.Count > 0)
{
Assert.False(container.IsExpanded);
foreach (var c in node.Children)
{
AssertEachItemWithChildrenIsCollapsed(c);
}
}
else
{
Assert.True(container.IsExpanded);
}
}
}
}
[Fact]
public void Space_Key_Should_Expand_TreeViewItem()
{
using (Application())
{
var tree = CreateTestTreeData();
var target = new TreeView
{
Template = CreateTreeViewTemplate(),
Items = tree,
};
var visualRoot = new TestRoot();
visualRoot.Child = target;
CreateNodeDataTemplate(target);
ApplyTemplates(target);
CollapseAll(target); // NOTE this line
var item = tree[0];
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(container);
Assert.False(container.IsExpanded);
var header = container.Header as Interactive;
Assert.NotNull(header);
container.RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = Key.Enter,
});
Assert.True(container.IsExpanded);
}
}
[Fact]
public void Space_plus_Ctrl_Key_Should_Expand_TreeViewItem_Recursively()
{
using (Application())
{
var tree = CreateTestTreeData();
var target = new TreeView
{
Template = CreateTreeViewTemplate(),
Items = tree,
};
var visualRoot = new TestRoot();
visualRoot.Child = target;
CreateNodeDataTemplate(target);
ApplyTemplates(target);
CollapseAll(target); // NOTE this line
var item = tree[0];
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(container);
Assert.False(container.IsExpanded);
var header = container.Header as Interactive;
Assert.NotNull(header);
container.RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = Key.Enter,
KeyModifiers = KeyModifiers.Control,
});
Assert.True(container.IsExpanded);
AssertEachItemWithChildrenIsExpanded(item);
void AssertEachItemWithChildrenIsExpanded(Node node)
{
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node);
Assert.NotNull(container);
if (node.Children?.Count > 0)
{
Assert.True(container.IsExpanded);
foreach (var c in node.Children)
{
AssertEachItemWithChildrenIsExpanded(c);
}
}
else
{
Assert.False(container.IsExpanded);
}
}
}
}
[Fact]
public void Numpad_Star_Should_Expand_All_Children_Recursively()
{
using (Application())
{
var tree = CreateTestTreeData();
var target = new TreeView
{
Template = CreateTreeViewTemplate(),
Items = tree,
};
var visualRoot = new TestRoot();
visualRoot.Child = target;
CreateNodeDataTemplate(target);
ApplyTemplates(target);
CollapseAll(target);
var item = tree[0];
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(container);
container.RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = Key.Multiply,
});
AssertEachItemWithChildrenIsExpanded(item);
void AssertEachItemWithChildrenIsExpanded(Node node)
{
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node);
Assert.NotNull(container);
if (node.Children?.Count > 0)
{
Assert.True(container.IsExpanded);
foreach (var c in node.Children)
{
AssertEachItemWithChildrenIsExpanded(c);
}
}
else
{
Assert.False(container.IsExpanded);
}
}
}
}
[Fact]
public void Numpad_Slash_Should_Collapse_All_Children_Recursively()
{
using (Application())
{
var tree = CreateTestTreeData();
var target = new TreeView
{
Template = CreateTreeViewTemplate(),
Items = tree,
};
var visualRoot = new TestRoot();
visualRoot.Child = target;
CreateNodeDataTemplate(target);
ApplyTemplates(target);
ExpandAll(target);
var item = tree[0];
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(container);
container.RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = Key.Divide,
});
AssertEachItemWithChildrenIsCollapsed(item);
void AssertEachItemWithChildrenIsCollapsed(Node node)
{
var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node);
Assert.NotNull(container);
if (node.Children?.Count > 0)
{
Assert.False(container.IsExpanded);
foreach (var c in node.Children)
{
AssertEachItemWithChildrenIsCollapsed(c);
}
}
else
{
Assert.True(container.IsExpanded);
}
}
}
}
[Fact]
public void Setting_SelectedItem_Should_Set_Container_Selected()
{
@ -1313,10 +1894,14 @@ namespace Avalonia.Controls.UnitTests
{
Children =
{
new ContentPresenter
new Border
{
Name = "PART_HeaderPresenter",
[~ContentPresenter.ContentProperty] = parent[~TreeViewItem.HeaderProperty],
Name = "PART_Header",
Child = new ContentPresenter
{
Name = "PART_HeaderPresenter",
[~ContentPresenter.ContentProperty] = parent[~TreeViewItem.HeaderProperty],
}.RegisterInNameScope(scope)
}.RegisterInNameScope(scope),
new ItemsPresenter
{
@ -1335,6 +1920,14 @@ namespace Avalonia.Controls.UnitTests
}
}
private void CollapseAll(TreeView tree)
{
foreach (var i in tree.ItemContainerGenerator.Containers)
{
tree.CollapseSubTree((TreeViewItem)i.ContainerControl);
}
}
private List<string> ExtractItemHeader(TreeView tree, int level)
{
return ExtractItemContent(tree.Presenter.Panel, 0, level)

16
tests/Avalonia.UnitTests/MouseTestHelper.cs

@ -65,6 +65,7 @@ namespace Avalonia.UnitTests
}
public void Move(Interactive target, in Point position, KeyModifiers modifiers = default) => Move(target, target, position, modifiers);
public void Move(Interactive target, Interactive source, in Point position, KeyModifiers modifiers = default)
{
target.RaiseEvent(new PointerEventArgs(InputElement.PointerMovedEvent, source, _pointer, (Visual)target, position,
@ -98,13 +99,26 @@ namespace Avalonia.UnitTests
public void Click(Interactive target, MouseButton button = MouseButton.Left, Point position = default,
KeyModifiers modifiers = default)
=> Click(target, target, button, position, modifiers);
public void Click(Interactive target, Interactive source, MouseButton button = MouseButton.Left,
Point position = default, KeyModifiers modifiers = default)
{
Down(target, source, button, position, modifiers);
Up(target, source, button, position, modifiers);
}
public void DoubleClick(Interactive target, MouseButton button = MouseButton.Left, Point position = default,
KeyModifiers modifiers = default)
=> DoubleClick(target, target, button, position, modifiers);
public void DoubleClick(Interactive target, Interactive source, MouseButton button = MouseButton.Left,
Point position = default, KeyModifiers modifiers = default)
{
Down(target, source, button, position, modifiers, clickCount: 1);
Up(target, source, button, position, modifiers);
Down(target, source, button, position, modifiers, clickCount: 2);
}
public void Enter(Interactive target)
{
target.RaiseEvent(new PointerEventArgs(InputElement.PointerEnteredEvent, target, _pointer, (Visual)target, default,

Loading…
Cancel
Save