Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Port tab navigation algorithm from WPF (including TabIndex support) #5996

Merged
merged 11 commits into from
Aug 2, 2021
1 change: 1 addition & 0 deletions src/Avalonia.Controls/TopLevel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ public abstract class TopLevel : ContentControl,
/// </summary>
static TopLevel()
{
KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue<TopLevel>(KeyboardNavigationMode.Cycle);
AffectsMeasure<TopLevel>(ClientSizeProperty);

TransparencyLevelHintProperty.Changed.AddClassHandler<TopLevel>(
Expand Down
3 changes: 3 additions & 0 deletions src/Avalonia.Input/Avalonia.Input.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
13 changes: 13 additions & 0 deletions src/Avalonia.Input/FocusManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@ public void Focus(
}
}

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>
Expand Down Expand Up @@ -151,6 +162,8 @@ public void SetFocusScope(IFocusScope scope)
Focus(e);
}

public static bool GetIsFocusScope(IInputElement e) => e is IFocusScope;

/// <summary>
/// Checks if the specified element can be focused.
/// </summary>
Expand Down
17 changes: 15 additions & 2 deletions src/Avalonia.Input/ICustomKeyboardNavigation.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@

#nullable enable

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);
/// <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 changes: 31 additions & 0 deletions src/Avalonia.Input/InputElement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ public class InputElement : Interactive, IInputElement
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>
Expand Down Expand Up @@ -99,6 +105,12 @@ public class InputElement : Interactive, IInputElement
"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>
Expand Down Expand Up @@ -426,6 +438,15 @@ public bool IsPointerOver
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
{
Expand All @@ -437,6 +458,16 @@ private set
}
}

/// <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>
Expand Down
33 changes: 31 additions & 2 deletions src/Avalonia.Input/KeyboardNavigation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -42,6 +51,26 @@ public static class KeyboardNavigation
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>
Expand Down Expand Up @@ -83,7 +112,7 @@ public static void SetTabOnceActiveElement(InputElement element, IInputElement?
}

/// <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>
Expand All @@ -93,7 +122,7 @@ public static void SetIsTabStop(InputElement element, bool value)
}

/// <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>
Expand Down
111 changes: 81 additions & 30 deletions src/Avalonia.Input/KeyboardNavigationHandler.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Avalonia.Input.Navigation;
using Avalonia.VisualTree;
Expand Down Expand Up @@ -48,39 +49,24 @@ public void SetOwner(IInputRoot owner)
{
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(),
maxkatz6 marked this conversation as resolved.
Show resolved Hide resolved
};

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>
Expand All @@ -90,7 +76,7 @@ public void SetOwner(IInputRoot owner)
/// <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)
{
Expand Down Expand Up @@ -124,5 +110,70 @@ protected virtual void OnKeyDown(object sender, KeyEventArgs e)
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 changes: 6 additions & 1 deletion src/Avalonia.Input/KeyboardNavigationMode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,10 @@ public enum KeyboardNavigationMode
/// 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,
}
}
}
Loading