diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs
index b966d09b1f..5d1b9a1462 100644
--- a/src/Avalonia.Controls/TreeView.cs
+++ b/src/Avalonia.Controls/TreeView.cs
@@ -16,7 +16,7 @@ namespace Avalonia.Controls
///
/// Displays a hierachical tree of data.
///
- public class TreeView : ItemsControl
+ public class TreeView : ItemsControl, ICustomKeyboardNavigation
{
///
/// Defines the 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);
+ }
+
///
protected override IItemContainerGenerator CreateItemContainerGenerator()
{
diff --git a/src/Avalonia.Input/Avalonia.Input.csproj b/src/Avalonia.Input/Avalonia.Input.csproj
index e9e74e24fe..0411cf77a5 100644
--- a/src/Avalonia.Input/Avalonia.Input.csproj
+++ b/src/Avalonia.Input/Avalonia.Input.csproj
@@ -37,5 +37,8 @@
Properties\SharedAssemblyInfo.cs
+
+
+
\ No newline at end of file
diff --git a/src/Avalonia.Input/ICustomKeyboardNavigation.cs b/src/Avalonia.Input/ICustomKeyboardNavigation.cs
new file mode 100644
index 0000000000..de5f98e04b
--- /dev/null
+++ b/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
+{
+ ///
+ /// Designates a control as handling its own keyboard navigation.
+ ///
+ public interface ICustomKeyboardNavigation
+ {
+ (bool handled, IInputElement next) GetNext(IInputElement element, NavigationDirection direction);
+ }
+}
diff --git a/src/Avalonia.Input/KeyboardNavigationHandler.cs b/src/Avalonia.Input/KeyboardNavigationHandler.cs
index 57da49fa03..bf2b61d08b 100644
--- a/src/Avalonia.Input/KeyboardNavigationHandler.cs
+++ b/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(element != null);
+ var customHandler = element.GetSelfAndVisualAncestors()
+ .OfType()
+ .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);
diff --git a/src/Avalonia.Input/Navigation/DirectionalNavigation.cs b/src/Avalonia.Input/Navigation/DirectionalNavigation.cs
index a88ed1e8aa..75cb3a39e8 100644
--- a/src/Avalonia.Input/Navigation/DirectionalNavigation.cs
+++ b/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
///
/// Gets the first item that should be focused in the next container.
///
+ /// The element being navigated away from.
/// The container.
/// The direction of the search.
/// The first element, or null if there are no more elements.
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
diff --git a/src/Avalonia.Input/Navigation/TabNavigation.cs b/src/Avalonia.Input/Navigation/TabNavigation.cs
index 6ba7ab1a0c..bc2b69a785 100644
--- a/src/Avalonia.Input/Navigation/TabNavigation.cs
+++ b/src/Avalonia.Input/Navigation/TabNavigation.cs
@@ -18,13 +18,17 @@ namespace Avalonia.Input.Navigation
///
/// The element.
/// The tab direction. Must be Next or Previous.
+ ///
+ /// If true will not descend into to find next control.
+ ///
///
/// The next element in the specified direction, or null if
/// was the last in the requested direction.
///
public static IInputElement GetNextInTabOrder(
IInputElement element,
- NavigationDirection direction)
+ NavigationDirection direction,
+ bool outsideElement = false)
{
Contract.Requires(element != null);
Contract.Requires(
@@ -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,8 +70,8 @@ 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();
}
///
@@ -75,7 +79,7 @@ namespace Avalonia.Input.Navigation
///
/// The element.
/// The element's focusable descendants.
- private static IEnumerable GetFocusableDescendants(IInputElement element)
+ private static IEnumerable GetFocusableDescendants(IInputElement element, NavigationDirection direction)
{
var mode = KeyboardNavigation.GetTabNavigation((InputElement)element);
@@ -103,16 +107,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 +137,19 @@ namespace Avalonia.Input.Navigation
/// The starting element/
/// The container.
/// The direction.
+ ///
+ /// If true will not descend into to find next control.
+ ///
/// The next element, or null if the element is the last.
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 +184,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 +201,12 @@ namespace Avalonia.Input.Navigation
///
/// Gets the first item that should be focused in the next container.
///
+ /// The element being navigated away from.
/// The container.
/// The direction of the search.
/// The first element, or null if there are no more elements.
private static IInputElement GetFirstInNextContainer(
+ IInputElement element,
IInputElement container,
NavigationDirection direction)
{
@@ -210,6 +229,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 +243,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);
+ }
}
}
diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs
index 5557f616c3..44ef7192ff 100644
--- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs
@@ -347,6 +347,7 @@ namespace Avalonia.Controls.UnitTests
var node = target.ItemContainerGenerator.Index.ContainerFromItem(item);
Assert.NotNull(node);
+ target.SelectedItem = item;
node.Focus();
Assert.Same(node, focus.Current);
diff --git a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs
new file mode 100644
index 0000000000..a090dcd18d
--- /dev/null
+++ b/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);
+ }
+ }
+ }
+}