From ec5131831507b7d8f8b9cd55a218d647caea6378 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 29 May 2021 23:53:09 +0200 Subject: [PATCH] Make custom keyboard navigation work again. --- src/Avalonia.Input/Avalonia.Input.csproj | 3 + .../ICustomKeyboardNavigation.cs | 17 ++- .../KeyboardNavigationHandler.cs | 111 +++++++++++++----- .../Navigation/TabNavigation.cs | 26 ++++ .../KeyboardNavigationTests_Custom.cs | 33 ++++++ tests/Avalonia.UnitTests/TestRoot.cs | 1 + 6 files changed, 157 insertions(+), 34 deletions(-) diff --git a/src/Avalonia.Input/Avalonia.Input.csproj b/src/Avalonia.Input/Avalonia.Input.csproj index c39c81a965..69a80290d1 100644 --- a/src/Avalonia.Input/Avalonia.Input.csproj +++ b/src/Avalonia.Input/Avalonia.Input.csproj @@ -4,6 +4,9 @@ Enable CS8600;CS8602;CS8603 + + + diff --git a/src/Avalonia.Input/ICustomKeyboardNavigation.cs b/src/Avalonia.Input/ICustomKeyboardNavigation.cs index 3d2927c632..357395c42f 100644 --- a/src/Avalonia.Input/ICustomKeyboardNavigation.cs +++ b/src/Avalonia.Input/ICustomKeyboardNavigation.cs @@ -1,4 +1,5 @@ - +#nullable enable + namespace Avalonia.Input { /// @@ -6,6 +7,18 @@ namespace Avalonia.Input /// public interface ICustomKeyboardNavigation { - (bool handled, IInputElement next) GetNext(IInputElement element, NavigationDirection direction); + /// + /// Gets the next element in the specified navigation direction. + /// + /// The element being navigated from. + /// The navigation direction. + /// + /// 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. + /// + (bool handled, IInputElement? next) GetNext(IInputElement element, NavigationDirection direction); } } diff --git a/src/Avalonia.Input/KeyboardNavigationHandler.cs b/src/Avalonia.Input/KeyboardNavigationHandler.cs index 53c9b008ff..6493777105 100644 --- a/src/Avalonia.Input/KeyboardNavigationHandler.cs +++ b/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,43 +49,24 @@ namespace Avalonia.Input { element = element ?? throw new ArgumentNullException(nameof(element)); - var customHandler = element.GetSelfAndVisualAncestors() - .OfType() - .FirstOrDefault(); + // If there's a custom keyboard navigation handler as an ancestor, use that. + var custom = element.FindAncestorOfType(true); + if (custom is object && HandlePreCustomNavigation(custom, element, direction, out var ce)) + return ce; - 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) - { - var e = (IInputElement)customHandler; - return direction switch - { - NavigationDirection.Next => TabNavigation.GetNextTab(e, false), - NavigationDirection.Previous => TabNavigation.GetPrevTab(e, null, false), - _ => throw new NotSupportedException(), - }; - } - else - { - return null; - } - } - } - - return direction switch + var result = direction switch { NavigationDirection.Next => TabNavigation.GetNextTab(element, false), NavigationDirection.Previous => TabNavigation.GetPrevTab(element, null, false), _ => throw new NotSupportedException(), }; + + // 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; + + return result; } /// @@ -94,7 +76,7 @@ namespace Avalonia.Input /// The direction to move. /// Any key modifiers active at the time of focus. public void Move( - IInputElement element, + IInputElement element, NavigationDirection direction, KeyModifiers keyModifiers = KeyModifiers.None) { @@ -128,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(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; + } } } diff --git a/src/Avalonia.Input/Navigation/TabNavigation.cs b/src/Avalonia.Input/Navigation/TabNavigation.cs index d1862c2fa5..12842e4f40 100644 --- a/src/Avalonia.Input/Navigation/TabNavigation.cs +++ b/src/Avalonia.Input/Navigation/TabNavigation.cs @@ -78,6 +78,19 @@ namespace Avalonia.Input.Navigation return null; } + public static IInputElement? GetNextTabOutside(ICustomKeyboardNavigation e) + { + if (e is IInputElement container) + { + var last = GetLastInTree(container); + + if (last is object) + return GetNextTab(last, false); + } + + return null; + } + public static IInputElement? GetPrevTab(IInputElement? e, IInputElement? container, bool goDownOnly) { if (e is null && container is null) @@ -171,6 +184,19 @@ namespace Avalonia.Input.Navigation 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; diff --git a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs index f72d6ba9c9..f9c85ee4ca 100644 --- a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs +++ b/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, diff --git a/tests/Avalonia.UnitTests/TestRoot.cs b/tests/Avalonia.UnitTests/TestRoot.cs index b69bf990d9..4601dd7e5b 100644 --- a/tests/Avalonia.UnitTests/TestRoot.cs +++ b/tests/Avalonia.UnitTests/TestRoot.cs @@ -21,6 +21,7 @@ namespace Avalonia.UnitTests Renderer = Mock.Of(); LayoutManager = new LayoutManager(this); IsVisible = true; + KeyboardNavigation.SetTabNavigation(this, KeyboardNavigationMode.Cycle); } public TestRoot(IControl child)