Browse Source

Merge pull request #5996 from AvaloniaUI/feature/tabindex

Port tab navigation algorithm from WPF (including TabIndex support)
release/0.10.7
Dan Walmsley 5 years ago
parent
commit
e2beda1620
  1. 1
      src/Avalonia.Controls/TopLevel.cs
  2. 3
      src/Avalonia.Input/Avalonia.Input.csproj
  3. 13
      src/Avalonia.Input/FocusManager.cs
  4. 17
      src/Avalonia.Input/ICustomKeyboardNavigation.cs
  5. 31
      src/Avalonia.Input/InputElement.cs
  6. 33
      src/Avalonia.Input/KeyboardNavigation.cs
  7. 111
      src/Avalonia.Input/KeyboardNavigationHandler.cs
  8. 7
      src/Avalonia.Input/KeyboardNavigationMode.cs
  9. 734
      src/Avalonia.Input/Navigation/TabNavigation.cs
  10. 33
      tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs
  11. 122
      tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs
  12. 1
      tests/Avalonia.UnitTests/TestRoot.cs

1
src/Avalonia.Controls/TopLevel.cs

@ -83,6 +83,7 @@ namespace Avalonia.Controls
/// </summary>
static TopLevel()
{
KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue<TopLevel>(KeyboardNavigationMode.Cycle);
AffectsMeasure<TopLevel>(ClientSizeProperty);
TransparencyLevelHintProperty.Changed.AddClassHandler<TopLevel>(

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

@ -4,6 +4,9 @@
<Nullable>Enable</Nullable>
<WarningsAsErrors>CS8600;CS8602;CS8603</WarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Avalonia.Base\Metadata\NullableAttributes.cs" Link="NullableAttributes.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Avalonia.Animation\Avalonia.Animation.csproj" />
<ProjectReference Include="..\Avalonia.Base\Avalonia.Base.csproj" />

13
src/Avalonia.Input/FocusManager.cs

@ -92,6 +92,17 @@ namespace Avalonia.Input
}
}
public IInputElement? GetFocusedElement(IInputElement e)
{
if (e is IFocusScope scope)
{
_focusScopes.TryGetValue(scope, out var result);
return result;
}
return null;
}
/// <summary>
/// Sets the currently focused element in the specified scope.
/// </summary>
@ -151,6 +162,8 @@ namespace Avalonia.Input
Focus(e);
}
public static bool GetIsFocusScope(IInputElement e) => e is IFocusScope;
/// <summary>
/// Checks if the specified element can be focused.
/// </summary>

17
src/Avalonia.Input/ICustomKeyboardNavigation.cs

@ -1,4 +1,5 @@

#nullable enable
namespace Avalonia.Input
{
/// <summary>
@ -6,6 +7,18 @@ namespace Avalonia.Input
/// </summary>
public interface ICustomKeyboardNavigation
{
(bool handled, IInputElement next) GetNext(IInputElement element, NavigationDirection direction);
/// <summary>
/// Gets the next element in the specified navigation direction.
/// </summary>
/// <param name="element">The element being navigated from.</param>
/// <param name="direction">The navigation direction.</param>
/// <returns>
/// A tuple consisting of:
/// - A boolean indicating whether the request was handled. If false is returned then
/// custom navigation will be ignored and default navigation will take place.
/// - If handled is true: the next element in the navigation direction, or null if default
/// navigation should continue outside the element.
/// </returns>
(bool handled, IInputElement? next) GetNext(IInputElement element, NavigationDirection direction);
}
}

31
src/Avalonia.Input/InputElement.cs

@ -71,6 +71,12 @@ namespace Avalonia.Input
public static readonly DirectProperty<InputElement, bool> IsPointerOverProperty =
AvaloniaProperty.RegisterDirect<InputElement, bool>(nameof(IsPointerOver), o => o.IsPointerOver);
/// <summary>
/// Defines the <see cref="IsTabStop"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsTabStopProperty =
KeyboardNavigation.IsTabStopProperty.AddOwner<InputElement>();
/// <summary>
/// Defines the <see cref="GotFocus"/> event.
/// </summary>
@ -99,6 +105,12 @@ namespace Avalonia.Input
"KeyUp",
RoutingStrategies.Tunnel | RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="TabIndex"/> property.
/// </summary>
public static readonly StyledProperty<int> TabIndexProperty =
KeyboardNavigation.TabIndexProperty.AddOwner<InputElement>();
/// <summary>
/// Defines the <see cref="TextInput"/> event.
/// </summary>
@ -426,6 +438,15 @@ namespace Avalonia.Input
internal set { SetAndRaise(IsPointerOverProperty, ref _isPointerOver, value); }
}
/// <summary>
/// Gets or sets a value that indicates whether the control is included in tab navigation.
/// </summary>
public bool IsTabStop
{
get => GetValue(IsTabStopProperty);
set => SetValue(IsTabStopProperty, value);
}
/// <inheritdoc/>
public bool IsEffectivelyEnabled
{
@ -437,6 +458,16 @@ namespace Avalonia.Input
}
}
/// <summary>
/// Gets or sets a value that determines the order in which elements receive focus when the
/// user navigates through controls by pressing the Tab key.
/// </summary>
public int TabIndex
{
get => GetValue(TabIndexProperty);
set => SetValue(TabIndexProperty, value);
}
public List<KeyBinding> KeyBindings { get; } = new List<KeyBinding>();
/// <summary>

33
src/Avalonia.Input/KeyboardNavigation.cs

@ -5,6 +5,15 @@ namespace Avalonia.Input
/// </summary>
public static class KeyboardNavigation
{
/// <summary>
/// Defines the TabIndex attached property.
/// </summary>
public static readonly AttachedProperty<int> TabIndexProperty =
AvaloniaProperty.RegisterAttached<StyledElement, int>(
"TabIndex",
typeof(KeyboardNavigation),
int.MaxValue);
/// <summary>
/// Defines the TabNavigation attached property.
/// </summary>
@ -42,6 +51,26 @@ namespace Avalonia.Input
typeof(KeyboardNavigation),
true);
/// <summary>
/// Gets the <see cref="TabIndexProperty"/> for an element.
/// </summary>
/// <param name="element">The container.</param>
/// <returns>The <see cref="KeyboardNavigationMode"/> for the container.</returns>
public static int GetTabIndex(IInputElement element)
{
return ((IAvaloniaObject)element).GetValue(TabIndexProperty);
}
/// <summary>
/// Sets the <see cref="TabIndexProperty"/> for an element.
/// </summary>
/// <param name="element">The element.</param>
/// <param name="value">The tab index.</param>
public static void SetTabIndex(IInputElement element, int value)
{
((IAvaloniaObject)element).SetValue(TabIndexProperty, value);
}
/// <summary>
/// Gets the <see cref="TabNavigationProperty"/> for a container.
/// </summary>
@ -83,7 +112,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Sets the <see cref="IsTabStopProperty"/> for a container.
/// Sets the <see cref="IsTabStopProperty"/> for an element.
/// </summary>
/// <param name="element">The container.</param>
/// <param name="value">Value indicating whether the container is a tab stop.</param>
@ -93,7 +122,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Gets the <see cref="IsTabStopProperty"/> for a container.
/// Gets the <see cref="IsTabStopProperty"/> for an element.
/// </summary>
/// <param name="element">The container.</param>
/// <returns>Whether the container is a tab stop.</returns>

111
src/Avalonia.Input/KeyboardNavigationHandler.cs

@ -1,4 +1,5 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Avalonia.Input.Navigation;
using Avalonia.VisualTree;
@ -48,39 +49,24 @@ namespace Avalonia.Input
{
element = element ?? throw new ArgumentNullException(nameof(element));
var customHandler = element.GetSelfAndVisualAncestors()
.OfType<ICustomKeyboardNavigation>()
.FirstOrDefault();
// If there's a custom keyboard navigation handler as an ancestor, use that.
var custom = element.FindAncestorOfType<ICustomKeyboardNavigation>(true);
if (custom is object && HandlePreCustomNavigation(custom, element, direction, out var ce))
return ce;
if (customHandler != null)
var result = direction switch
{
var (handled, next) = customHandler.GetNext(element, direction);
NavigationDirection.Next => TabNavigation.GetNextTab(element, false),
NavigationDirection.Previous => TabNavigation.GetPrevTab(element, null, false),
_ => throw new NotSupportedException(),
};
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 there wasn't a custom navigation handler as an ancestor of the current element,
// but there is one as an ancestor of the new element, use that.
if (custom is null && HandlePostCustomNavigation(element, result, direction, out ce))
return ce;
if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous)
{
return TabNavigation.GetNextInTabOrder(element, direction);
}
else
{
throw new NotSupportedException();
}
return result;
}
/// <summary>
@ -90,7 +76,7 @@ namespace Avalonia.Input
/// <param name="direction">The direction to move.</param>
/// <param name="keyModifiers">Any key modifiers active at the time of focus.</param>
public void Move(
IInputElement element,
IInputElement element,
NavigationDirection direction,
KeyModifiers keyModifiers = KeyModifiers.None)
{
@ -124,5 +110,70 @@ namespace Avalonia.Input
e.Handled = true;
}
}
private static bool HandlePreCustomNavigation(
ICustomKeyboardNavigation customHandler,
IInputElement element,
NavigationDirection direction,
[NotNullWhen(true)] out IInputElement? result)
{
if (customHandler != null)
{
var (handled, next) = customHandler.GetNext(element, direction);
if (handled)
{
if (next != null)
{
result = next;
return true;
}
else if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous)
{
var r = direction switch
{
NavigationDirection.Next => TabNavigation.GetNextTabOutside(customHandler),
NavigationDirection.Previous => TabNavigation.GetPrevTabOutside(customHandler),
_ => throw new NotSupportedException(),
};
if (r is object)
{
result = r;
return true;
}
}
}
}
result = null;
return false;
}
private static bool HandlePostCustomNavigation(
IInputElement element,
IInputElement? newElement,
NavigationDirection direction,
[NotNullWhen(true)] out IInputElement? result)
{
if (newElement is object)
{
var customHandler = newElement.FindAncestorOfType<ICustomKeyboardNavigation>(true);
if (customHandler is object)
{
var (handled, next) = customHandler.GetNext(element, direction);
if (handled && next is object)
{
result = next;
return true;
}
}
}
result = null;
return false;
}
}
}

7
src/Avalonia.Input/KeyboardNavigationMode.cs

@ -36,5 +36,10 @@ namespace Avalonia.Input
/// The container's children will not be focused when using the tab key.
/// </summary>
None,
/// <summary>
/// TabIndexes are considered on local subtree only inside this container
/// </summary>
Local,
}
}
}

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

@ -10,277 +10,663 @@ namespace Avalonia.Input.Navigation
/// </summary>
internal static class TabNavigation
{
/// <summary>
/// Gets the next control in the specified tab direction.
/// </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,
bool outsideElement = false)
public static IInputElement? GetNextTab(IInputElement e, bool goDownOnly)
{
element = element ?? throw new ArgumentNullException(nameof(element));
if (direction != NavigationDirection.Next && direction != NavigationDirection.Previous)
{
throw new ArgumentException("Invalid direction: must be Next or Previous.");
}
return GetNextTab(e, GetGroupParent(e), goDownOnly);
}
var container = element.GetVisualParent<IInputElement>();
public static IInputElement? GetNextTab(IInputElement? e, IInputElement container, bool goDownOnly)
{
var tabbingType = GetKeyNavigationMode(container);
if (container != null)
if (e == null)
{
var mode = KeyboardNavigation.GetTabNavigation((InputElement)container);
if (IsTabStop(container))
return container;
switch (mode)
// Using ActiveElement if set
var activeElement = GetActiveElement(container);
if (activeElement != null)
return GetNextTab(null, activeElement, true);
}
else
{
if (tabbingType == KeyboardNavigationMode.Once || tabbingType == KeyboardNavigationMode.None)
{
case KeyboardNavigationMode.Continue:
return GetNextInContainer(element, container, direction, outsideElement) ??
GetFirstInNextContainer(element, element, direction);
case KeyboardNavigationMode.Cycle:
return GetNextInContainer(element, container, direction, outsideElement) ??
GetFocusableDescendant(container, direction);
case KeyboardNavigationMode.Contained:
return GetNextInContainer(element, container, direction, outsideElement);
default:
return GetFirstInNextContainer(element, container, direction);
if (container != e)
{
if (goDownOnly)
return null;
var parentContainer = GetGroupParent(container);
return GetNextTab(container, parentContainer, goDownOnly);
}
}
}
else
// All groups
IInputElement? loopStartElement = null;
var nextTabElement = e;
var currentTabbingType = tabbingType;
// Search down inside the container
while ((nextTabElement = GetNextTabInGroup(nextTabElement, container, currentTabbingType)) != null)
{
return GetFocusableDescendants(element, direction).FirstOrDefault();
// Avoid the endless loop here for Cycle groups
if (loopStartElement == nextTabElement)
break;
if (loopStartElement == null)
loopStartElement = nextTabElement;
var firstTabElementInside = GetNextTab(null, nextTabElement, true);
if (firstTabElementInside != null)
return firstTabElementInside;
// If we want to continue searching inside the Once groups, we should change the navigation mode
if (currentTabbingType == KeyboardNavigationMode.Once)
currentTabbingType = KeyboardNavigationMode.Contained;
}
// If there is no next element in the group (nextTabElement == null)
// Search up in the tree if allowed
// consider: Use original tabbingType instead of currentTabbingType
if (!goDownOnly && currentTabbingType != KeyboardNavigationMode.Contained && GetParent(container) != null)
{
return GetNextTab(container, GetGroupParent(container), false);
}
return null;
}
/// <summary>
/// Gets the first or last focusable descendant of the specified element.
/// </summary>
/// <param name="container">The element.</param>
/// <param name="direction">The direction to search.</param>
/// <returns>The element or null if not found.##</returns>
private static IInputElement GetFocusableDescendant(IInputElement container, NavigationDirection direction)
public static IInputElement? GetNextTabOutside(ICustomKeyboardNavigation e)
{
return direction == NavigationDirection.Next ?
GetFocusableDescendants(container, direction).FirstOrDefault() :
GetFocusableDescendants(container, direction).LastOrDefault();
if (e is IInputElement container)
{
var last = GetLastInTree(container);
if (last is object)
return GetNextTab(last, false);
}
return null;
}
/// <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,
NavigationDirection direction)
public static IInputElement? GetPrevTab(IInputElement? e, IInputElement? container, bool goDownOnly)
{
var mode = KeyboardNavigation.GetTabNavigation((InputElement)element);
if (e is null && container is null)
throw new InvalidOperationException("Either 'e' or 'container' must be non-null.");
if (mode == KeyboardNavigationMode.None)
{
yield break;
}
if (container is null)
container = GetGroupParent(e!);
var children = element.GetVisualChildren().OfType<IInputElement>();
KeyboardNavigationMode tabbingType = GetKeyNavigationMode(container);
if (mode == KeyboardNavigationMode.Once)
if (e == null)
{
var active = KeyboardNavigation.GetTabOnceActiveElement((InputElement)element);
if (active != null)
// Using ActiveElement if set
var activeElement = GetActiveElement(container);
if (activeElement != null)
return GetPrevTab(null, activeElement, true);
else
{
yield return active;
yield break;
// If we Shift+Tab on a container with KeyboardNavigationMode=Once, and ActiveElement is null
// then we want to go to the first item (not last) within the container
if (tabbingType == KeyboardNavigationMode.Once)
{
var firstTabElement = GetNextTabInGroup(null, container, tabbingType);
if (firstTabElement == null)
{
if (IsTabStop(container))
return container;
if (goDownOnly)
return null;
return GetPrevTab(container, null, false);
}
else
{
return GetPrevTab(null, firstTabElement, true);
}
}
}
else
}
else
{
if (tabbingType == KeyboardNavigationMode.Once || tabbingType == KeyboardNavigationMode.None)
{
children = children.Take(1);
if (goDownOnly || container == e)
return null;
// FocusedElement should not be e otherwise we will delegate focus to the same element
if (IsTabStop(container))
return container;
return GetPrevTab(container, null, false);
}
}
foreach (var child in children)
// All groups (except Once) - continue
IInputElement? loopStartElement = null;
IInputElement? nextTabElement = e;
// Look for element with the same TabIndex before the current element
while ((nextTabElement = GetPrevTabInGroup(nextTabElement, container, tabbingType)) != null)
{
var customNext = GetCustomNext(child, direction);
if (nextTabElement == container && tabbingType == KeyboardNavigationMode.Local)
break;
// At this point nextTabElement is TabStop or TabGroup
// In case it is a TabStop only return the element
if (IsTabStop(nextTabElement) && !IsGroup(nextTabElement))
return nextTabElement;
// Avoid the endless loop here
if (loopStartElement == nextTabElement)
break;
if (loopStartElement == null)
loopStartElement = nextTabElement;
// At this point nextTabElement is TabGroup
var lastTabElementInside = GetPrevTab(null, nextTabElement, true);
if (lastTabElementInside != null)
return lastTabElementInside;
}
if (customNext.handled)
{
yield return customNext.next!;
}
else
if (tabbingType == KeyboardNavigationMode.Contained)
return null;
if (e != container && IsTabStop(container))
return container;
// If end of the subtree is reached or there no other elements above
if (!goDownOnly && GetParent(container) != null)
{
return GetPrevTab(container, null, false);
}
return null;
}
public static IInputElement? GetPrevTabOutside(ICustomKeyboardNavigation e)
{
if (e is IInputElement container)
{
var first = GetFirstChild(container);
if (first is object)
return GetPrevTab(first, null, false);
}
return null;
}
private static IInputElement? FocusedElement(IInputElement e)
{
var iie = e;
// Focus delegation is enabled only if keyboard focus is outside the container
if (iie != null && !iie.IsKeyboardFocusWithin)
{
var focusedElement = (FocusManager.Instance as FocusManager)?.GetFocusedElement(e);
if (focusedElement != null)
{
if (child.CanFocus() && KeyboardNavigation.GetIsTabStop((InputElement)child))
if (!IsFocusScope(e))
{
yield return child;
// Verify if focusedElement is a visual descendant of e
if (focusedElement is IVisual visualFocusedElement &&
visualFocusedElement != e &&
e.IsVisualAncestorOf(visualFocusedElement))
{
return focusedElement;
}
}
}
}
return null;
}
private static IInputElement? GetFirstChild(IInputElement e)
{
// If the element has a FocusedElement it should be its first child
if (FocusedElement(e) is IInputElement focusedElement)
return focusedElement;
if (child.CanFocusDescendants())
// Return the first visible element.
var uiElement = e as InputElement;
if (uiElement is null || uiElement.IsVisible)
{
if (e is IVisual elementAsVisual)
{
var children = elementAsVisual.VisualChildren;
var count = children.Count;
for (int i = 0; i < count; i++)
{
foreach (var descendant in GetFocusableDescendants(child, direction))
if (children[i] is InputElement ie)
{
if (KeyboardNavigation.GetIsTabStop((InputElement)descendant))
if (ie.IsVisible)
return ie;
else
{
yield return descendant;
var firstChild = GetFirstChild(ie);
if (firstChild != null)
return firstChild;
}
}
}
}
}
return null;
}
/// <summary>
/// Gets the next item that should be focused in the specified container.
/// </summary>
/// <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,
bool outsideElement)
private static IInputElement? GetLastChild(IInputElement e)
{
IInputElement? e = element;
// If the element has a FocusedElement it should be its last child
if (FocusedElement(e) is IInputElement focusedElement)
return focusedElement;
if (direction == NavigationDirection.Next && !outsideElement)
{
var descendant = GetFocusableDescendants(element, direction).FirstOrDefault();
// Return the last visible element.
var uiElement = e as InputElement;
if (descendant != null)
{
return descendant;
}
}
if (container != null)
if (uiElement == null || uiElement.IsVisible)
{
var navigable = container as INavigableContainer;
var elementAsVisual = e as IVisual;
// TODO: Do a spatial search here if the container doesn't implement
// INavigableContainer.
if (navigable != null)
if (elementAsVisual != null)
{
while (e != null)
{
e = navigable.GetControl(direction, e, false);
var children = elementAsVisual.VisualChildren;
var count = children.Count;
if (e != null &&
e.CanFocus() &&
KeyboardNavigation.GetIsTabStop((InputElement)e))
for (int i = count - 1; i >= 0; i--)
{
if (children[i] is InputElement ie)
{
break;
if (ie.IsVisible)
return ie;
else
{
var lastChild = GetLastChild(ie);
if (lastChild != null)
return lastChild;
}
}
}
}
else
}
return null;
}
private static IInputElement? GetFirstTabInGroup(IInputElement container)
{
IInputElement? firstTabElement = null;
int minIndexFirstTab = int.MinValue;
var currElement = container;
while ((currElement = GetNextInTree(currElement, container)) != null)
{
if (IsTabStopOrGroup(currElement))
{
// TODO: Do a spatial search here if the container doesn't implement
// INavigableContainer.
e = null;
int currPriority = KeyboardNavigation.GetTabIndex(currElement);
if (currPriority < minIndexFirstTab || firstTabElement == null)
{
minIndexFirstTab = currPriority;
firstTabElement = currElement;
}
}
}
return firstTabElement;
}
private static IInputElement? GetLastInTree(IInputElement container)
{
IInputElement? result;
IInputElement? c = container;
do
{
result = c;
c = GetLastChild(c);
} while (c != null && !IsGroup(c));
if (c != null)
return c;
return result;
}
if (e != null && direction == NavigationDirection.Previous)
private static IInputElement? GetLastTabInGroup(IInputElement container)
{
IInputElement? lastTabElement = null;
int maxIndexFirstTab = int.MaxValue;
var currElement = GetLastInTree(container);
while (currElement != null && currElement != container)
{
if (IsTabStopOrGroup(currElement))
{
var descendant = GetFocusableDescendants(e, direction).LastOrDefault();
int currPriority = KeyboardNavigation.GetTabIndex(currElement);
if (descendant != null)
if (currPriority > maxIndexFirstTab || lastTabElement == null)
{
return descendant;
maxIndexFirstTab = currPriority;
lastTabElement = currElement;
}
}
currElement = GetPreviousInTree(currElement, container);
}
return lastTabElement;
}
private static IInputElement? GetNextInTree(IInputElement e, IInputElement container)
{
IInputElement? result = null;
if (e == container || !IsGroup(e))
result = GetFirstChild(e);
if (result != null || e == container)
return result;
IInputElement? parent = e;
do
{
var sibling = GetNextSibling(parent);
if (sibling != null)
return sibling;
return e;
parent = GetParent(parent);
} while (parent != null && parent != container);
return null;
}
private static IInputElement? GetNextSibling(IInputElement e)
{
if (GetParent(e) is IVisual parentAsVisual && e is IVisual elementAsVisual)
{
var children = parentAsVisual.VisualChildren;
var count = children.Count;
var i = 0;
//go till itself
for (; i < count; i++)
{
var vchild = children[i];
if (vchild == elementAsVisual)
break;
}
i++;
//search ahead
for (; i < count; i++)
{
var visual = children[i];
if (visual is IInputElement ie)
return ie;
}
}
return null;
}
/// <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)
private static IInputElement? GetNextTabInGroup(IInputElement? e, IInputElement container, KeyboardNavigationMode tabbingType)
{
var parent = container.GetVisualParent<IInputElement>();
IInputElement? next = null;
// None groups: Tab navigation is not supported
if (tabbingType == KeyboardNavigationMode.None)
return null;
if (parent != null)
// e == null or e == container -> return the first TabStopOrGroup
if (e == null || e == container)
{
if (direction == NavigationDirection.Previous &&
parent.CanFocus() &&
KeyboardNavigation.GetIsTabStop((InputElement) parent))
return GetFirstTabInGroup(container);
}
if (tabbingType == KeyboardNavigationMode.Once)
return null;
var nextTabElement = GetNextTabWithSameIndex(e, container);
if (nextTabElement != null)
return nextTabElement;
return GetNextTabWithNextIndex(e, container, tabbingType);
}
private static IInputElement? GetNextTabWithSameIndex(IInputElement e, IInputElement container)
{
var elementTabPriority = KeyboardNavigation.GetTabIndex(e);
var currElement = e;
while ((currElement = GetNextInTree(currElement, container)) != null)
{
if (IsTabStopOrGroup(currElement) && KeyboardNavigation.GetTabIndex(currElement) == elementTabPriority)
{
return parent;
return currElement;
}
}
var allSiblings = parent.GetVisualChildren()
.OfType<IInputElement>()
.Where(FocusExtensions.CanFocusDescendants);
var siblings = direction == NavigationDirection.Next ?
allSiblings.SkipWhile(x => x != container).Skip(1) :
allSiblings.TakeWhile(x => x != container).Reverse();
return null;
}
foreach (var sibling in siblings)
private static IInputElement? GetNextTabWithNextIndex(IInputElement e, IInputElement container, KeyboardNavigationMode tabbingType)
{
// Find the next min index in the tree
// min (index>currentTabIndex)
IInputElement? nextTabElement = null;
IInputElement? firstTabElement = null;
int minIndexFirstTab = int.MinValue;
int minIndex = int.MinValue;
int elementTabPriority = KeyboardNavigation.GetTabIndex(e);
IInputElement? currElement = container;
while ((currElement = GetNextInTree(currElement, container)) != null)
{
if (IsTabStopOrGroup(currElement))
{
var customNext = GetCustomNext(sibling, direction);
if (customNext.handled)
int currPriority = KeyboardNavigation.GetTabIndex(currElement);
if (currPriority > elementTabPriority)
{
return customNext.next;
if (currPriority < minIndex || nextTabElement == null)
{
minIndex = currPriority;
nextTabElement = currElement;
}
}
if (sibling.CanFocus() && KeyboardNavigation.GetIsTabStop((InputElement) sibling))
if (currPriority < minIndexFirstTab || firstTabElement == null)
{
return sibling;
minIndexFirstTab = currPriority;
firstTabElement = currElement;
}
}
}
next = direction == NavigationDirection.Next ?
GetFocusableDescendants(sibling, direction).FirstOrDefault() :
GetFocusableDescendants(sibling, direction).LastOrDefault();
// Cycle groups: if not found - return first element
if (tabbingType == KeyboardNavigationMode.Cycle && nextTabElement == null)
nextTabElement = firstTabElement;
return nextTabElement;
}
if (next != null)
private static IInputElement? GetPrevTabInGroup(IInputElement? e, IInputElement container, KeyboardNavigationMode tabbingType)
{
// None groups: Tab navigation is not supported
if (tabbingType == KeyboardNavigationMode.None)
return null;
// Search the last index inside the group
if (e == null)
{
return GetLastTabInGroup(container);
}
if (tabbingType == KeyboardNavigationMode.Once)
return null;
if (e == container)
return null;
var nextTabElement = GetPrevTabWithSameIndex(e, container);
if (nextTabElement != null)
return nextTabElement;
return GetPrevTabWithPrevIndex(e, container, tabbingType);
}
private static IInputElement? GetPrevTabWithSameIndex(IInputElement e, IInputElement container)
{
int elementTabPriority = KeyboardNavigation.GetTabIndex(e);
var currElement = GetPreviousInTree(e, container);
while (currElement != null)
{
if (IsTabStopOrGroup(currElement) && KeyboardNavigation.GetTabIndex(currElement) == elementTabPriority && currElement != container)
{
return currElement;
}
currElement = GetPreviousInTree(currElement, container);
}
return null;
}
private static IInputElement? GetPrevTabWithPrevIndex(IInputElement e, IInputElement container, KeyboardNavigationMode tabbingType)
{
// Find the next max index in the tree
// max (index<currentTabIndex)
IInputElement? lastTabElement = null;
IInputElement? nextTabElement = null;
int elementTabPriority = KeyboardNavigation.GetTabIndex(e);
int maxIndexFirstTab = Int32.MaxValue;
int maxIndex = Int32.MaxValue;
var currElement = GetLastInTree(container);
while (currElement != null)
{
if (IsTabStopOrGroup(currElement) && currElement != container)
{
int currPriority = KeyboardNavigation.GetTabIndex(currElement);
if (currPriority < elementTabPriority)
{
if (currPriority > maxIndex || nextTabElement == null)
{
maxIndex = currPriority;
nextTabElement = currElement;
}
}
if (currPriority > maxIndexFirstTab || lastTabElement == null)
{
return next;
maxIndexFirstTab = currPriority;
lastTabElement = currElement;
}
}
next = GetFirstInNextContainer(element, parent, direction);
currElement = GetPreviousInTree(currElement, container);
}
// Cycle groups: if not found - return first element
if (tabbingType == KeyboardNavigationMode.Cycle && nextTabElement == null)
nextTabElement = lastTabElement;
return nextTabElement;
}
private static IInputElement? GetPreviousInTree(IInputElement e, IInputElement container)
{
if (e == container)
return null;
var result = GetPreviousSibling(e);
if (result != null)
{
if (IsGroup(result))
return result;
else
return GetLastInTree(result);
}
else
return GetParent(e);
}
private static IInputElement? GetPreviousSibling(IInputElement e)
{
if (GetParent(e) is IVisual parentAsVisual && e is IVisual elementAsVisual)
{
next = direction == NavigationDirection.Next ?
GetFocusableDescendants(container, direction).FirstOrDefault() :
GetFocusableDescendants(container, direction).LastOrDefault();
var children = parentAsVisual.VisualChildren;
var count = children.Count;
IInputElement? prev = null;
for (int i = 0; i < count; i++)
{
var vchild = children[i];
if (vchild == elementAsVisual)
break;
if (vchild.IsVisible == true && vchild is IInputElement ie)
prev = ie;
}
return prev;
}
return null;
}
return next;
private static IInputElement? GetActiveElement(IInputElement e)
{
return ((IAvaloniaObject)e).GetValue(KeyboardNavigation.TabOnceActiveElementProperty);
}
private static (bool handled, IInputElement? next) GetCustomNext(IInputElement element,
NavigationDirection direction)
private static IInputElement GetGroupParent(IInputElement e) => GetGroupParent(e, false);
private static IInputElement GetGroupParent(IInputElement element, bool includeCurrent)
{
if (element is ICustomKeyboardNavigation custom)
var result = element; // Keep the last non null element
var e = element;
// If we don't want to include the current element,
// start at the parent of the element. If the element
// is the root, then just return it as the group parent.
if (!includeCurrent)
{
result = e;
e = GetParent(e);
if (e == null)
return result;
}
while (e != null)
{
return custom.GetNext(element, direction);
if (IsGroup(e))
return e;
result = e;
e = GetParent(e);
}
return (false, null);
return result;
}
private static IInputElement? GetParent(IInputElement e)
{
// For Visual - go up the visual parent chain until we find Visual.
if (e is IVisual v)
return v.FindAncestorOfType<IInputElement>();
// This will need to be implemented when we have non-visual input elements.
throw new NotSupportedException();
}
private static KeyboardNavigationMode GetKeyNavigationMode(IInputElement e)
{
return ((IAvaloniaObject)e).GetValue(KeyboardNavigation.TabNavigationProperty);
}
private static bool IsFocusScope(IInputElement e) => FocusManager.GetIsFocusScope(e) || GetParent(e) == null;
private static bool IsGroup(IInputElement e) => GetKeyNavigationMode(e) != KeyboardNavigationMode.Continue;
private static bool IsTabStop(IInputElement e)
{
if (e is InputElement ie)
return ie.Focusable && KeyboardNavigation.GetIsTabStop(ie) && ie.IsVisible && ie.IsEnabled;
return false;
}
private static bool IsTabStopOrGroup(IInputElement e) => IsTabStop(e) || IsGroup(e);
}
}

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

@ -95,6 +95,7 @@ namespace Avalonia.Input.UnitTests
var root = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
target,
@ -125,6 +126,7 @@ namespace Avalonia.Input.UnitTests
var root = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
(current = new Button { Content = "Outside" }),
@ -137,6 +139,36 @@ namespace Avalonia.Input.UnitTests
Assert.Same(next, result);
}
[Fact]
public void ShiftTab_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
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
target,
(next = new Button { Content = "Outside" }),
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous);
Assert.Same(next, result);
}
[Fact]
public void Tab_Should_Navigate_Outside_When_Null_Returned_As_Next()
{
@ -154,6 +186,7 @@ namespace Avalonia.Input.UnitTests
var root = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
target,

122
tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs

@ -1,3 +1,4 @@
using System.Collections.Generic;
using Avalonia.Controls;
using Xunit;
@ -13,6 +14,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -49,6 +51,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -85,6 +88,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -122,6 +126,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -165,6 +170,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -193,6 +199,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -222,6 +229,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -263,6 +271,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
(next = new Button { Name = "Button1" }),
@ -282,6 +291,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -324,6 +334,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -361,6 +372,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -398,6 +410,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -434,6 +447,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -471,6 +485,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -509,6 +524,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
(container = new StackPanel
@ -548,6 +564,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -586,6 +603,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
(container = new StackPanel
@ -625,6 +643,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -661,6 +680,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -697,6 +717,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -725,6 +746,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -767,6 +789,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -828,6 +851,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -865,6 +889,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -902,6 +927,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -938,6 +964,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -975,6 +1002,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -1013,6 +1041,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
(container = new StackPanel
@ -1052,6 +1081,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -1103,5 +1133,97 @@ namespace Avalonia.Input.UnitTests
Assert.Null(result);
}
[Fact]
public void Respects_TabIndex_Moving_Forwards()
{
Button start;
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
{
Children =
{
new Button { Name = "Button1", TabIndex = 5 },
(start = new Button { Name = "Button2", TabIndex = 2 }),
new Button { Name = "Button3", TabIndex = 1 },
}
},
new StackPanel
{
Children =
{
new Button { Name = "Button4", TabIndex = 3 },
new Button { Name = "Button5", TabIndex = 6 },
new Button { Name = "Button6", TabIndex = 4 },
}
},
}
};
var result = new List<string>();
var current = (IInputElement)start;
do
{
result.Add(((IControl)current).Name);
current = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next);
} while (current is object && current != start);
Assert.Equal(new[]
{
"Button2", "Button4", "Button6", "Button1", "Button5", "Button3"
}, result);
}
[Fact]
public void Respects_TabIndex_Moving_Backwards()
{
Button start;
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
{
Children =
{
new Button { Name = "Button1", TabIndex = 5 },
(start = new Button { Name = "Button2", TabIndex = 2 }),
new Button { Name = "Button3", TabIndex = 1 },
}
},
new StackPanel
{
Children =
{
new Button { Name = "Button4", TabIndex = 3 },
new Button { Name = "Button5", TabIndex = 6 },
new Button { Name = "Button6", TabIndex = 4 },
}
},
}
};
var result = new List<string>();
var current = (IInputElement)start;
do
{
result.Add(((IControl)current).Name);
current = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous);
} while (current is object && current != start);
Assert.Equal(new[]
{
"Button2", "Button3", "Button5", "Button1", "Button6", "Button4"
}, result);
}
}
}

1
tests/Avalonia.UnitTests/TestRoot.cs

@ -21,6 +21,7 @@ namespace Avalonia.UnitTests
Renderer = Mock.Of<IRenderer>();
LayoutManager = new LayoutManager(this);
IsVisible = true;
KeyboardNavigation.SetTabNavigation(this, KeyboardNavigationMode.Cycle);
}
public TestRoot(IControl child)

Loading…
Cancel
Save