Browse Source

Merge pull request #1048 from AvaloniaUI/fixes/277-treeview-keyboard-navigation

Fixed TreeView Keyboard Navigation
repros/content-presenter-regression
danwalmsley 9 years ago
committed by GitHub
parent
commit
e56c897abb
  1. 22
      src/Avalonia.Controls/TreeView.cs
  2. 3
      src/Avalonia.Input/Avalonia.Input.csproj
  3. 5
      src/Avalonia.Input/FocusManager.cs
  4. 15
      src/Avalonia.Input/ICustomKeyboardNavigation.cs
  5. 27
      src/Avalonia.Input/KeyboardNavigationHandler.cs
  6. 16
      src/Avalonia.Input/Navigation/DirectionalNavigation.cs
  7. 87
      src/Avalonia.Input/Navigation/TabNavigation.cs
  8. 44
      tests/Avalonia.Controls.UnitTests/TreeViewTests.cs
  9. 214
      tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs
  10. 6
      tests/Avalonia.UnitTests/TestServices.cs
  11. 1
      tests/Avalonia.UnitTests/UnitTestApplication.cs

22
src/Avalonia.Controls/TreeView.cs

@ -16,7 +16,7 @@ namespace Avalonia.Controls
/// <summary>
/// Displays a hierachical tree of data.
/// </summary>
public class TreeView : ItemsControl
public class TreeView : ItemsControl, ICustomKeyboardNavigation
{
/// <summary>
/// Defines the <see cref="AutoScrollToSelectedItem"/> property.
@ -90,6 +90,26 @@ namespace Avalonia.Controls
}
}
(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);
}
else
{
return (true, null);
}
}
return (false, null);
}
/// <inheritdoc/>
protected override IItemContainerGenerator CreateItemContainerGenerator()
{

3
src/Avalonia.Input/Avalonia.Input.csproj

@ -37,5 +37,8 @@
<Link>Properties\SharedAssemblyInfo.cs</Link>
</Compile>
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.ValueTuple" Version="4.3.1" />
</ItemGroup>
<Import Project="..\..\build\Rx.props" />
</Project>

5
src/Avalonia.Input/FocusManager.cs

@ -176,9 +176,10 @@ namespace Avalonia.Input
/// <param name="e">The event args.</param>
private void OnPreviewPointerPressed(object sender, RoutedEventArgs e)
{
if (sender == e.Source)
var ev = (PointerPressedEventArgs)e;
if (sender == e.Source && ev.MouseButton == MouseButton.Left)
{
var ev = (PointerPressedEventArgs)e;
var element = (ev.Device?.Captured as IInputElement) ?? (e.Source as IInputElement);
if (element == null || !CanFocus(element))

15
src/Avalonia.Input/ICustomKeyboardNavigation.cs

@ -0,0 +1,15 @@
// 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;
namespace Avalonia.Input
{
/// <summary>
/// Designates a control as handling its own keyboard navigation.
/// </summary>
public interface ICustomKeyboardNavigation
{
(bool handled, IInputElement next) GetNext(IInputElement element, NavigationDirection direction);
}
}

27
src/Avalonia.Input/KeyboardNavigationHandler.cs

@ -2,7 +2,9 @@
// Licensed under the MIT license. See licence.md file in the project root for full license information.
using System;
using System.Linq;
using Avalonia.Input.Navigation;
using Avalonia.VisualTree;
namespace Avalonia.Input
{
@ -52,6 +54,31 @@ namespace Avalonia.Input
{
Contract.Requires<ArgumentNullException>(element != null);
var customHandler = element.GetSelfAndVisualAncestors()
.OfType<ICustomKeyboardNavigation>()
.FirstOrDefault();
if (customHandler != null)
{
var (handled, next) = customHandler.GetNext(element, direction);
if (handled)
{
if (next != null)
{
return next;
}
else if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous)
{
return TabNavigation.GetNextInTabOrder((IInputElement)customHandler, direction, true);
}
else
{
return null;
}
}
}
if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous)
{
return TabNavigation.GetNextInTabOrder(element, direction);

16
src/Avalonia.Input/Navigation/DirectionalNavigation.cs

@ -41,7 +41,7 @@ namespace Avalonia.Input.Navigation
{
case KeyboardNavigationMode.Continue:
return GetNextInContainer(element, container, direction) ??
GetFirstInNextContainer(element, direction);
GetFirstInNextContainer(element, element, direction);
case KeyboardNavigationMode.Cycle:
return GetNextInContainer(element, container, direction) ??
GetFocusableDescendant(container, direction);
@ -173,10 +173,12 @@ namespace Avalonia.Input.Navigation
/// <summary>
/// Gets the first item that should be focused in the next container.
/// </summary>
/// <param name="element">The element being navigated away from.</param>
/// <param name="container">The container.</param>
/// <param name="direction">The direction of the search.</param>
/// <returns>The first element, or null if there are no more elements.</returns>
private static IInputElement GetFirstInNextContainer(
IInputElement element,
IInputElement container,
NavigationDirection direction)
{
@ -200,6 +202,16 @@ namespace Avalonia.Input.Navigation
if (sibling != null)
{
if (sibling is ICustomKeyboardNavigation custom)
{
var (handled, customNext) = custom.GetNext(element, direction);
if (handled)
{
return customNext;
}
}
if (sibling.CanFocus())
{
next = sibling;
@ -214,7 +226,7 @@ namespace Avalonia.Input.Navigation
if (next == null)
{
next = GetFirstInNextContainer(parent, direction);
next = GetFirstInNextContainer(element, parent, direction);
}
}
else

87
src/Avalonia.Input/Navigation/TabNavigation.cs

@ -18,13 +18,17 @@ namespace Avalonia.Input.Navigation
/// </summary>
/// <param name="element">The element.</param>
/// <param name="direction">The tab direction. Must be Next or Previous.</param>
/// <param name="outsideElement">
/// If true will not descend into <paramref name="element"/> to find next control.
/// </param>
/// <returns>
/// The next element in the specified direction, or null if <paramref name="element"/>
/// was the last in the requested direction.
/// </returns>
public static IInputElement GetNextInTabOrder(
IInputElement element,
NavigationDirection direction)
NavigationDirection direction,
bool outsideElement = false)
{
Contract.Requires<ArgumentNullException>(element != null);
Contract.Requires<ArgumentException>(
@ -40,20 +44,20 @@ namespace Avalonia.Input.Navigation
switch (mode)
{
case KeyboardNavigationMode.Continue:
return GetNextInContainer(element, container, direction) ??
GetFirstInNextContainer(element, direction);
return GetNextInContainer(element, container, direction, outsideElement) ??
GetFirstInNextContainer(element, element, direction);
case KeyboardNavigationMode.Cycle:
return GetNextInContainer(element, container, direction) ??
return GetNextInContainer(element, container, direction, outsideElement) ??
GetFocusableDescendant(container, direction);
case KeyboardNavigationMode.Contained:
return GetNextInContainer(element, container, direction);
return GetNextInContainer(element, container, direction, outsideElement);
default:
return GetFirstInNextContainer(container, direction);
return GetFirstInNextContainer(element, container, direction);
}
}
else
{
return GetFocusableDescendants(element).FirstOrDefault();
return GetFocusableDescendants(element, direction).FirstOrDefault();
}
}
@ -66,16 +70,17 @@ namespace Avalonia.Input.Navigation
private static IInputElement GetFocusableDescendant(IInputElement container, NavigationDirection direction)
{
return direction == NavigationDirection.Next ?
GetFocusableDescendants(container).FirstOrDefault() :
GetFocusableDescendants(container).LastOrDefault();
GetFocusableDescendants(container, direction).FirstOrDefault() :
GetFocusableDescendants(container, direction).LastOrDefault();
}
/// <summary>
/// Gets the focusable descendants of the specified element.
/// </summary>
/// <param name="element">The element.</param>
/// <param name="direction">The tab direction. Must be Next or Previous.</param>
/// <returns>The element's focusable descendants.</returns>
private static IEnumerable<IInputElement> GetFocusableDescendants(IInputElement element)
private static IEnumerable<IInputElement> GetFocusableDescendants(IInputElement element, NavigationDirection direction)
{
var mode = KeyboardNavigation.GetTabNavigation((InputElement)element);
@ -103,16 +108,25 @@ namespace Avalonia.Input.Navigation
foreach (var child in children)
{
if (child.CanFocus())
var customNext = GetCustomNext(child, direction);
if (customNext.handled)
{
yield return child;
yield return customNext.next;
}
if (child.CanFocusDescendants())
else
{
foreach (var descendant in GetFocusableDescendants(child))
if (child.CanFocus())
{
yield return descendant;
yield return child;
}
if (child.CanFocusDescendants())
{
foreach (var descendant in GetFocusableDescendants(child, direction))
{
yield return descendant;
}
}
}
}
@ -124,15 +138,19 @@ namespace Avalonia.Input.Navigation
/// <param name="element">The starting element/</param>
/// <param name="container">The container.</param>
/// <param name="direction">The direction.</param>
/// <param name="outsideElement">
/// If true will not descend into <paramref name="element"/> to find next control.
/// </param>
/// <returns>The next element, or null if the element is the last.</returns>
private static IInputElement GetNextInContainer(
IInputElement element,
IInputElement container,
NavigationDirection direction)
NavigationDirection direction,
bool outsideElement)
{
if (direction == NavigationDirection.Next)
if (direction == NavigationDirection.Next && !outsideElement)
{
var descendant = GetFocusableDescendants(element).FirstOrDefault();
var descendant = GetFocusableDescendants(element, direction).FirstOrDefault();
if (descendant != null)
{
@ -167,7 +185,7 @@ namespace Avalonia.Input.Navigation
if (element != null && direction == NavigationDirection.Previous)
{
var descendant = GetFocusableDescendants(element).LastOrDefault();
var descendant = GetFocusableDescendants(element, direction).LastOrDefault();
if (descendant != null)
{
@ -184,10 +202,12 @@ namespace Avalonia.Input.Navigation
/// <summary>
/// Gets the first item that should be focused in the next container.
/// </summary>
/// <param name="element">The element being navigated away from.</param>
/// <param name="container">The container.</param>
/// <param name="direction">The direction of the search.</param>
/// <returns>The first element, or null if there are no more elements.</returns>
private static IInputElement GetFirstInNextContainer(
IInputElement element,
IInputElement container,
NavigationDirection direction)
{
@ -210,6 +230,13 @@ namespace Avalonia.Input.Navigation
if (sibling != null)
{
var customNext = GetCustomNext(sibling, direction);
if (customNext.handled)
{
return customNext.next;
}
if (sibling.CanFocus())
{
next = sibling;
@ -217,24 +244,34 @@ namespace Avalonia.Input.Navigation
else
{
next = direction == NavigationDirection.Next ?
GetFocusableDescendants(sibling).FirstOrDefault() :
GetFocusableDescendants(sibling).LastOrDefault();
GetFocusableDescendants(sibling, direction).FirstOrDefault() :
GetFocusableDescendants(sibling, direction).LastOrDefault();
}
}
if (next == null)
{
next = GetFirstInNextContainer(parent, direction);
next = GetFirstInNextContainer(element, parent, direction);
}
}
else
{
next = direction == NavigationDirection.Next ?
GetFocusableDescendants(container).FirstOrDefault() :
GetFocusableDescendants(container).LastOrDefault();
GetFocusableDescendants(container, direction).FirstOrDefault() :
GetFocusableDescendants(container, direction).LastOrDefault();
}
return next;
}
private static (bool handled, IInputElement next) GetCustomNext(IInputElement element, NavigationDirection direction)
{
if (element is ICustomKeyboardNavigation custom)
{
return custom.GetNext(element, direction);
}
return (false, null);
}
}
}

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

@ -315,6 +315,50 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(new[] { "NewChild1" }, ExtractItemHeader(target, 1));
}
[Fact]
public void Keyboard_Navigation_Should_Move_To_Last_Selected_Node()
{
using (UnitTestApplication.Start(TestServices.RealFocus))
{
var focus = FocusManager.Instance;
var navigation = AvaloniaLocator.Current.GetService<IKeyboardNavigationHandler>();
var data = CreateTestTreeData();
var target = new TreeView
{
Template = CreateTreeViewTemplate(),
Items = data,
DataTemplates = CreateNodeDataTemplate(),
};
var button = new Button();
var root = new TestRoot
{
Child = new StackPanel
{
Children = { target, button },
}
};
ApplyTemplates(target);
var item = data[0].Children[0];
var node = target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(node);
target.SelectedItem = item;
node.Focus();
Assert.Same(node, focus.Current);
navigation.Move(focus.Current, NavigationDirection.Next);
Assert.Same(button, focus.Current);
navigation.Move(focus.Current, NavigationDirection.Next);
Assert.Same(node, focus.Current);
}
}
private void ApplyTemplates(TreeView tree)
{
tree.ApplyTemplate();

214
tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs

@ -0,0 +1,214 @@
// 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 Avalonia.Controls;
using Xunit;
namespace Avalonia.Input.UnitTests
{
public class KeyboardNavigationTests_Custom
{
[Fact]
public void Tab_Should_Custom_Navigate_Within_Children()
{
Button current;
Button next;
var target = new CustomNavigatingStackPanel
{
Children =
{
(current = new Button { Content = "Button 1" }),
new Button { Content = "Button 2" },
(next = new Button { Content = "Button 3" }),
},
NextControl = next,
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next);
Assert.Same(next, result);
}
[Fact]
public void Right_Should_Custom_Navigate_Within_Children()
{
Button current;
Button next;
var target = new CustomNavigatingStackPanel
{
Children =
{
(current = new Button { Content = "Button 1" }),
new Button { Content = "Button 2" },
(next = new Button { Content = "Button 3" }),
},
NextControl = next,
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Right);
Assert.Same(next, result);
}
[Fact]
public void Tab_Should_Custom_Navigate_From_Outside()
{
Button current;
Button next;
var target = new CustomNavigatingStackPanel
{
Children =
{
new Button { Content = "Button 1" },
new Button { Content = "Button 2" },
(next = new Button { Content = "Button 3" }),
},
NextControl = next,
};
var root = new StackPanel
{
Children =
{
(current = new Button { Content = "Outside" }),
target,
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next);
Assert.Same(next, result);
}
[Fact]
public void Tab_Should_Custom_Navigate_From_Outside_When_Wrapping()
{
Button current;
Button next;
var target = new CustomNavigatingStackPanel
{
Children =
{
new Button { Content = "Button 1" },
new Button { Content = "Button 2" },
(next = new Button { Content = "Button 3" }),
},
NextControl = next,
};
var root = new StackPanel
{
Children =
{
target,
(current = new Button { Content = "Outside" }),
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next);
Assert.Same(next, result);
}
[Fact]
public void ShiftTab_Should_Custom_Navigate_From_Outside()
{
Button current;
Button next;
var target = new CustomNavigatingStackPanel
{
Children =
{
new Button { Content = "Button 1" },
new Button { Content = "Button 2" },
(next = new Button { Content = "Button 3" }),
},
NextControl = next,
};
var root = new StackPanel
{
Children =
{
(current = new Button { Content = "Outside" }),
target,
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous);
Assert.Same(next, result);
}
[Fact]
public void Right_Should_Custom_Navigate_From_Outside()
{
Button current;
Button next;
var target = new CustomNavigatingStackPanel
{
Children =
{
new Button { Content = "Button 1" },
new Button { Content = "Button 2" },
(next = new Button { Content = "Button 3" }),
},
NextControl = next,
};
var root = new StackPanel
{
Children =
{
(current = new Button { Content = "Outside" }),
target,
},
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Right);
Assert.Same(next, result);
}
[Fact]
public void Tab_Should_Navigate_Outside_When_Null_Returned_As_Next()
{
Button current;
Button next;
var target = new CustomNavigatingStackPanel
{
Children =
{
new Button { Content = "Button 1" },
(current = new Button { Content = "Button 2" }),
new Button { Content = "Button 3" },
},
};
var root = new StackPanel
{
Children =
{
target,
(next = new Button { Content = "Outside" }),
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next);
Assert.Same(next, result);
}
private class CustomNavigatingStackPanel : StackPanel, ICustomKeyboardNavigation
{
public bool CustomNavigates { get; set; } = true;
public IInputElement NextControl { get; set; }
public (bool handled, IInputElement next) GetNext(IInputElement element, NavigationDirection direction)
{
return (CustomNavigates, NextControl);
}
}
}
}

6
tests/Avalonia.UnitTests/TestServices.cs

@ -50,6 +50,7 @@ namespace Avalonia.UnitTests
public static readonly TestServices RealFocus = new TestServices(
focusManager: new FocusManager(),
keyboardDevice: () => new KeyboardDevice(),
keyboardNavigation: new KeyboardNavigationHandler(),
inputManager: new InputManager());
public static readonly TestServices RealLayoutManager = new TestServices(
@ -63,6 +64,7 @@ namespace Avalonia.UnitTests
IFocusManager focusManager = null,
IInputManager inputManager = null,
Func<IKeyboardDevice> keyboardDevice = null,
IKeyboardNavigationHandler keyboardNavigation = null,
ILayoutManager layoutManager = null,
IRuntimePlatform platform = null,
Func<IRenderRoot, IRenderLoop, IRenderer> renderer = null,
@ -79,6 +81,7 @@ namespace Avalonia.UnitTests
FocusManager = focusManager;
InputManager = inputManager;
KeyboardDevice = keyboardDevice;
KeyboardNavigation = keyboardNavigation;
LayoutManager = layoutManager;
Platform = platform;
Renderer = renderer;
@ -96,6 +99,7 @@ namespace Avalonia.UnitTests
public IInputManager InputManager { get; }
public IFocusManager FocusManager { get; }
public Func<IKeyboardDevice> KeyboardDevice { get; }
public IKeyboardNavigationHandler KeyboardNavigation { get; }
public ILayoutManager LayoutManager { get; }
public IRuntimePlatform Platform { get; }
public Func<IRenderRoot, IRenderLoop, IRenderer> Renderer { get; }
@ -113,6 +117,7 @@ namespace Avalonia.UnitTests
IFocusManager focusManager = null,
IInputManager inputManager = null,
Func<IKeyboardDevice> keyboardDevice = null,
IKeyboardNavigationHandler keyboardNavigation = null,
ILayoutManager layoutManager = null,
IRuntimePlatform platform = null,
Func<IRenderRoot, IRenderLoop, IRenderer> renderer = null,
@ -131,6 +136,7 @@ namespace Avalonia.UnitTests
focusManager: focusManager ?? FocusManager,
inputManager: inputManager ?? InputManager,
keyboardDevice: keyboardDevice ?? KeyboardDevice,
keyboardNavigation: keyboardNavigation ?? KeyboardNavigation,
layoutManager: layoutManager ?? LayoutManager,
platform: platform ?? Platform,
renderer: renderer ?? Renderer,

1
tests/Avalonia.UnitTests/UnitTestApplication.cs

@ -49,6 +49,7 @@ namespace Avalonia.UnitTests
.BindToSelf<IGlobalStyles>(this)
.Bind<IInputManager>().ToConstant(Services.InputManager)
.Bind<IKeyboardDevice>().ToConstant(Services.KeyboardDevice?.Invoke())
.Bind<IKeyboardNavigationHandler>().ToConstant(Services.KeyboardNavigation)
.Bind<ILayoutManager>().ToConstant(Services.LayoutManager)
.Bind<IRuntimePlatform>().ToConstant(Services.Platform)
.Bind<IRendererFactory>().ToConstant(new RendererFactory(Services.Renderer))

Loading…
Cancel
Save