Browse Source

Fixed TreeView navigation.

Fixes #277.
pull/1048/head
Steven Kirk 9 years ago
parent
commit
c4aa2197b2
  1. 22
      src/Avalonia.Controls/TreeView.cs
  2. 3
      src/Avalonia.Input/Avalonia.Input.csproj
  3. 15
      src/Avalonia.Input/ICustomKeyboardNavigation.cs
  4. 27
      src/Avalonia.Input/KeyboardNavigationHandler.cs
  5. 16
      src/Avalonia.Input/Navigation/DirectionalNavigation.cs
  6. 86
      src/Avalonia.Input/Navigation/TabNavigation.cs
  7. 1
      tests/Avalonia.Controls.UnitTests/TreeViewTests.cs
  8. 214
      tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs

22
src/Avalonia.Controls/TreeView.cs

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

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

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

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. // Licensed under the MIT license. See licence.md file in the project root for full license information.
using System; using System;
using System.Linq;
using Avalonia.Input.Navigation; using Avalonia.Input.Navigation;
using Avalonia.VisualTree;
namespace Avalonia.Input namespace Avalonia.Input
{ {
@ -52,6 +54,31 @@ namespace Avalonia.Input
{ {
Contract.Requires<ArgumentNullException>(element != null); 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) if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous)
{ {
return TabNavigation.GetNextInTabOrder(element, direction); return TabNavigation.GetNextInTabOrder(element, direction);

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

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

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

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

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

@ -347,6 +347,7 @@ namespace Avalonia.Controls.UnitTests
var node = target.ItemContainerGenerator.Index.ContainerFromItem(item); var node = target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(node); Assert.NotNull(node);
target.SelectedItem = item;
node.Focus(); node.Focus();
Assert.Same(node, focus.Current); Assert.Same(node, focus.Current);

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);
}
}
}
}
Loading…
Cancel
Save