diff --git a/samples/ControlCatalog/Pages/FocusPage.xaml b/samples/ControlCatalog/Pages/FocusPage.xaml index 2bb17b58e3..f4bad7d138 100644 --- a/samples/ControlCatalog/Pages/FocusPage.xaml +++ b/samples/ControlCatalog/Pages/FocusPage.xaml @@ -7,14 +7,14 @@ x:Class="ControlCatalog.Pages.FocusPage"> - + - - Enabled - Disabled + + Enabled + Disabled diff --git a/src/Avalonia.Base/Input/IPointer.cs b/src/Avalonia.Base/Input/IPointer.cs index 050adbabaa..9071d77997 100644 --- a/src/Avalonia.Base/Input/IPointer.cs +++ b/src/Avalonia.Base/Input/IPointer.cs @@ -54,8 +54,19 @@ namespace Avalonia.Input /// public enum PointerType { + /// + /// The input device is a mouse. + /// Mouse, + + /// + /// The input device is a touch. + /// Touch, + + /// + /// The input device is a pen. + /// Pen } } diff --git a/src/Avalonia.Base/Input/KeyDeviceType.cs b/src/Avalonia.Base/Input/KeyDeviceType.cs index 4e16e787f3..7304432944 100644 --- a/src/Avalonia.Base/Input/KeyDeviceType.cs +++ b/src/Avalonia.Base/Input/KeyDeviceType.cs @@ -4,12 +4,25 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -namespace Avalonia.Input +namespace Avalonia.Input; + +/// +/// Enumerates key device types. +/// +public enum KeyDeviceType { - public enum KeyDeviceType - { - Keyboard, - Gamepad, - Remote - } + /// + /// The input device is a keyboard. + /// + Keyboard, + + /// + /// The input device is a gamepad. + /// + Gamepad, + + /// + /// The input device is a remote control. + /// + Remote } diff --git a/src/Avalonia.Base/Input/KeyboardNavigationHandler.cs b/src/Avalonia.Base/Input/KeyboardNavigationHandler.cs index 470c337b02..bc9b1cde05 100644 --- a/src/Avalonia.Base/Input/KeyboardNavigationHandler.cs +++ b/src/Avalonia.Base/Input/KeyboardNavigationHandler.cs @@ -49,6 +49,14 @@ namespace Avalonia.Input public static IInputElement? GetNext( IInputElement element, NavigationDirection direction) + { + return GetNextPrivate(element, direction, null); + } + + private static IInputElement? GetNextPrivate( + IInputElement element, + NavigationDirection direction, + KeyDeviceType? keyDeviceType) { element = element ?? throw new ArgumentNullException(nameof(element)); @@ -63,7 +71,7 @@ namespace Avalonia.Input NavigationDirection.Previous => TabNavigation.GetPrevTab(element, null, false), NavigationDirection.Up or NavigationDirection.Down or NavigationDirection.Left or NavigationDirection.Right - => XYFocus.TryDirectionalFocus(direction, element, null), + => XYFocus.TryDirectionalFocus(direction, element, null, keyDeviceType), _ => throw new NotSupportedException(), }; @@ -86,17 +94,18 @@ namespace Avalonia.Input NavigationDirection direction, KeyModifiers keyModifiers = KeyModifiers.None) { - MovePrivate(element, direction, keyModifiers); + MovePrivate(element, direction, keyModifiers, null); } - private bool MovePrivate(IInputElement? element, NavigationDirection direction, KeyModifiers keyModifiers) + // TODO12: remove MovePrivate, and make Move return boolean. Or even remove whole KeyboardNavigationHandler. + private bool MovePrivate(IInputElement? element, NavigationDirection direction, KeyModifiers keyModifiers, KeyDeviceType? deviceType) { if (element is null && _owner is null) { return false; } - var next = GetNext(element ?? _owner!, direction); + var next = GetNextPrivate(element ?? _owner!, direction, deviceType); if (next != null) { @@ -121,7 +130,7 @@ namespace Avalonia.Input var current = FocusManager.GetFocusManager(e.Source as IInputElement)?.GetFocusedElement(); var direction = (e.KeyModifiers & KeyModifiers.Shift) == 0 ? NavigationDirection.Next : NavigationDirection.Previous; - e.Handled = MovePrivate(current, direction, e.KeyModifiers); + e.Handled = MovePrivate(current, direction, e.KeyModifiers, e.KeyDeviceType); } else if (e.Key is Key.Left or Key.Right or Key.Up or Key.Down) { @@ -134,7 +143,7 @@ namespace Avalonia.Input Key.Down => NavigationDirection.Down, _ => throw new ArgumentOutOfRangeException() }; - e.Handled = MovePrivate(current, direction, e.KeyModifiers); + e.Handled = MovePrivate(current, direction, e.KeyModifiers, e.KeyDeviceType); } } diff --git a/src/Avalonia.Base/Input/Navigation/XYFocus.FindElements.cs b/src/Avalonia.Base/Input/Navigation/XYFocus.FindElements.cs index 04f509215b..464b978297 100644 --- a/src/Avalonia.Base/Input/Navigation/XYFocus.FindElements.cs +++ b/src/Avalonia.Base/Input/Navigation/XYFocus.FindElements.cs @@ -14,7 +14,7 @@ public partial class XYFocus InputElement? currentElement, InputElement? activeScroller, bool ignoreClipping, - bool shouldConsiderXYFocusKeyboardNavigation) + KeyDeviceType? inputKeyDeviceType) { var isScrolling = (activeScroller != null); var collection = startRoot.VisualChildren; @@ -30,7 +30,8 @@ public partial class XYFocus var isEngagementEnabledButNotEngaged = GetIsFocusEngagementEnabled(child) && !GetIsFocusEngaged(child); - if (child != currentElement && FocusManager.CanFocus(child) + if (child != currentElement + && IsValidCandidate(child, inputKeyDeviceType) && GetBoundsForRanking(child, ignoreClipping) is {} bounds) { if (isScrolling) @@ -48,23 +49,25 @@ public partial class XYFocus } } - if (IsValidFocusSubtree(child, shouldConsiderXYFocusKeyboardNavigation) && !isEngagementEnabledButNotEngaged) + if (IsValidFocusSubtree(child) && !isEngagementEnabledButNotEngaged) { - FindElements(focusList, child, currentElement, activeScroller, ignoreClipping, shouldConsiderXYFocusKeyboardNavigation); + FindElements(focusList, child, currentElement, activeScroller, ignoreClipping, inputKeyDeviceType); } } } - private static bool IsValidFocusSubtree(InputElement element, bool shouldConsiderXYFocusKeyboardNavigation) + private static bool IsValidFocusSubtree(InputElement candidate) { - var isDirectionalRegion = - shouldConsiderXYFocusKeyboardNavigation && - IsDirectionalRegion(element); - // We don't need to check for effective values, as we've already checked parents of this subtree on previous steps. - return element.IsVisible && - element.IsEnabled && - (!shouldConsiderXYFocusKeyboardNavigation || isDirectionalRegion); + return candidate.IsVisible && + candidate.IsEnabled; + } + + private static bool IsValidCandidate(InputElement candidate, KeyDeviceType? inputKeyDeviceType) + { + return candidate.Focusable && candidate.IsEnabled && candidate.IsVisible + // Only allow candidate focus, if original key device type could focus it. + && IsAllowedNavigationMode(GetNavigationModes(candidate), inputKeyDeviceType); } /// Check if candidate's direct scroller is the same as active focused scroller. @@ -99,15 +102,6 @@ public partial class XYFocus } return false; } - - private static bool IsDirectionalRegion(InputElement? element) - { - if (element is null) - return false; - - var mode = GetKeyboardNavigationEnabled(element); - return mode != XYFocusKeyboardNavigationMode.Disabled; - } private static bool IsOccluded(InputElement element, Rect elementBounds) { diff --git a/src/Avalonia.Base/Input/Navigation/XYFocus.Impl.cs b/src/Avalonia.Base/Input/Navigation/XYFocus.Impl.cs index fd83f1f679..7050818341 100644 --- a/src/Avalonia.Base/Input/Navigation/XYFocus.Impl.cs +++ b/src/Avalonia.Base/Input/Navigation/XYFocus.Impl.cs @@ -53,7 +53,8 @@ public partial class XYFocus internal static InputElement? TryDirectionalFocus( NavigationDirection direction, IInputElement? element, - InputElement? engagedControl) + InputElement? engagedControl, + KeyDeviceType? keyDeviceType) { /* * UWP/WinUI Behavior is a bit different with handling of manifolds. @@ -80,10 +81,7 @@ public partial class XYFocus return null; } - // UWP still allows XY navigation via programmatic way or with Gamepad/Controller, when KeyboardNavigationEnabled is false, - // as KeyboardNavigationEnabled should only affect keyboard input. - // TODO: remove this condition after FocusManager refactoring or addition of basic Gamepad input. - if (GetKeyboardNavigationEnabled(inputElement) != XYFocusKeyboardNavigationMode.Enabled) + if (!IsAllowedNavigationMode(GetNavigationModes(inputElement), keyDeviceType)) { return null; } @@ -97,8 +95,8 @@ public partial class XYFocus return _instance.GetNextFocusableElement(direction, inputElement, engagedControl, true, new XYFocusOptions { + KeyDeviceType = keyDeviceType, FocusedElementBounds = bounds, - ShouldConsiderXYFocusKeyboardNavigation = true, UpdateManifold = true, SearchRoot = inputElement.GetVisualRoot() as InputElement }); @@ -156,7 +154,7 @@ public partial class XYFocus { GetAllValidFocusableChildren(candidateList, root, direction, element, engagedControl, xyFocusOptions.SearchRoot, activeScroller, xyFocusOptions.IgnoreClipping, - xyFocusOptions.ShouldConsiderXYFocusKeyboardNavigation); + xyFocusOptions.KeyDeviceType); if (candidateList.Count > 0) { @@ -276,7 +274,7 @@ public partial class XYFocus InputElement? searchScope, InputElement? activeScroller, bool ignoreClipping, - bool shouldConsiderXYFocusKeyboardNavigation) + KeyDeviceType? inputKeyDeviceType) { var rootForTreeWalk = startRoot; @@ -289,14 +287,14 @@ public partial class XYFocus if (engagedControl == null) { FindElements(candidateList, rootForTreeWalk, currentElement, activeScroller, ignoreClipping, - shouldConsiderXYFocusKeyboardNavigation); + inputKeyDeviceType); } else { // Only run through this when you are an engaged element. Being an engaged element means that you should only // look at the children of the engaged element and any children of popups that were opened during engagement FindElements(candidateList, engagedControl, currentElement, activeScroller, ignoreClipping, - shouldConsiderXYFocusKeyboardNavigation); + inputKeyDeviceType); // Iterate through the popups and add their children to the list // TODO: Avalonia, missing Popup API @@ -436,4 +434,16 @@ public partial class XYFocus return null; } + + private static bool IsAllowedNavigationMode(XYFocusNavigationModes modes, KeyDeviceType? keyDeviceType) + { + return keyDeviceType switch + { + null => true, // programmatic input, allow any subtree. + KeyDeviceType.Keyboard => modes.HasFlag(XYFocusNavigationModes.Keyboard), + KeyDeviceType.Gamepad => modes.HasFlag(XYFocusNavigationModes.Gamepad), + KeyDeviceType.Remote => modes.HasFlag(XYFocusNavigationModes.Remote), + _ => throw new ArgumentOutOfRangeException(nameof(keyDeviceType), keyDeviceType, null) + }; + } } diff --git a/src/Avalonia.Base/Input/Navigation/XYFocus.Properties.cs b/src/Avalonia.Base/Input/Navigation/XYFocus.Properties.cs index 0f6b7f0ac6..e33ce3e4c6 100644 --- a/src/Avalonia.Base/Input/Navigation/XYFocus.Properties.cs +++ b/src/Avalonia.Base/Input/Navigation/XYFocus.Properties.cs @@ -71,16 +71,16 @@ public partial class XYFocus public static XYFocusNavigationStrategy GetRightNavigationStrategy(InputElement obj) => obj.GetValue(RightNavigationStrategyProperty); - public static readonly AttachedProperty KeyboardNavigationEnabledProperty = - AvaloniaProperty.RegisterAttached( - "KeyboardNavigation", inherits: true); + public static readonly AttachedProperty NavigationModesProperty = + AvaloniaProperty.RegisterAttached( + "NavigationModes", XYFocusNavigationModes.Gamepad | XYFocusNavigationModes.Remote, inherits: true); - public static void SetKeyboardNavigationEnabled(InputElement obj, XYFocusKeyboardNavigationMode value) => - obj.SetValue(KeyboardNavigationEnabledProperty, value); + public static void SetNavigationModes(InputElement obj, XYFocusNavigationModes value) => + obj.SetValue(NavigationModesProperty, value); + + public static XYFocusNavigationModes GetNavigationModes(InputElement obj) => + obj.GetValue(NavigationModesProperty); - public static XYFocusKeyboardNavigationMode GetKeyboardNavigationEnabled(InputElement obj) => - obj.GetValue(KeyboardNavigationEnabledProperty); - internal static readonly AttachedProperty IsFocusEngagementEnabledProperty = AvaloniaProperty.RegisterAttached("IsFocusEngagementEnabled"); diff --git a/src/Avalonia.Base/Input/Navigation/XYFocusKeyboardNavigationMode.cs b/src/Avalonia.Base/Input/Navigation/XYFocusKeyboardNavigationMode.cs deleted file mode 100644 index 267c7df0e2..0000000000 --- a/src/Avalonia.Base/Input/Navigation/XYFocusKeyboardNavigationMode.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Avalonia.Input; - -/// -/// Specifies the 2D directional navigation behavior when using the keyboard arrow keys. -/// -public enum XYFocusKeyboardNavigationMode -{ - /// - /// Arrow keys can be used for 2D directional navigation. - /// - Enabled = 1, - - /// - /// Arrow keys cannot be used for 2D directional navigation. - /// - Disabled -} diff --git a/src/Avalonia.Base/Input/Navigation/XYFocusNavigationModes.cs b/src/Avalonia.Base/Input/Navigation/XYFocusNavigationModes.cs new file mode 100644 index 0000000000..2105e0f7aa --- /dev/null +++ b/src/Avalonia.Base/Input/Navigation/XYFocusNavigationModes.cs @@ -0,0 +1,38 @@ +using System; + +namespace Avalonia.Input; + +/// +/// Specifies the 2D directional navigation behavior when using different key devices. +/// +/// +/// See . +/// +[Flags] +public enum XYFocusNavigationModes +{ + /// + /// Any key device XY navigation is disabled. + /// + Disabled = 0, + + /// + /// Keyboard arrow keys can be used for 2D directional navigation. + /// + Keyboard = 1, + + /// + /// Gamepad controller DPad keys can be used for 2D directional navigation. + /// + Gamepad = 2, + + /// + /// Remote controller DPad keys can be used for 2D directional navigation. + /// + Remote = 4, + + /// + /// All key device XY navigation is disabled. + /// + Enabled = Gamepad | Remote | Keyboard +} diff --git a/src/Avalonia.Base/Input/Navigation/XYFocusOptions.cs b/src/Avalonia.Base/Input/Navigation/XYFocusOptions.cs index 6060776b28..4bfcb22502 100644 --- a/src/Avalonia.Base/Input/Navigation/XYFocusOptions.cs +++ b/src/Avalonia.Base/Input/Navigation/XYFocusOptions.cs @@ -9,7 +9,7 @@ internal class XYFocusOptions public XYFocusNavigationStrategy? NavigationStrategyOverride { get; set; } public bool IgnoreClipping { get; set; } = true; public bool IgnoreCone { get; set; } - public bool ShouldConsiderXYFocusKeyboardNavigation { get; set; } + public KeyDeviceType? KeyDeviceType { get; set; } public bool ConsiderEngagement { get; set; } = true; public bool UpdateManifold { get; set; } = true; public bool UpdateManifoldsFromFocusHintRect { get; set; } diff --git a/tests/Avalonia.Base.UnitTests/Input/KeyboardNavigationTests_XY.cs b/tests/Avalonia.Base.UnitTests/Input/KeyboardNavigationTests_XY.cs index 908f4f7fe5..8981d35e61 100644 --- a/tests/Avalonia.Base.UnitTests/Input/KeyboardNavigationTests_XY.cs +++ b/tests/Avalonia.Base.UnitTests/Input/KeyboardNavigationTests_XY.cs @@ -74,7 +74,7 @@ public class KeyboardNavigationTests_XY : ScopedTestBase var (canvas, buttons) = CreateXYTestLayout(); var window = new Window { - [XYFocus.KeyboardNavigationEnabledProperty] = XYFocusKeyboardNavigationMode.Enabled, + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, Content = canvas }; window.Show(); @@ -114,7 +114,7 @@ public class KeyboardNavigationTests_XY : ScopedTestBase var (canvas, buttons) = CreateXYTestLayout(); var window = new Window { - [XYFocus.KeyboardNavigationEnabledProperty] = XYFocusKeyboardNavigationMode.Enabled, + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, Content = canvas }; window.Show(); @@ -154,7 +154,7 @@ public class KeyboardNavigationTests_XY : ScopedTestBase var (canvas, buttons) = CreateXYTestLayout(); var window = new Window { - [XYFocus.KeyboardNavigationEnabledProperty] = XYFocusKeyboardNavigationMode.Enabled, + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, Content = canvas }; window.Show(); @@ -188,7 +188,7 @@ public class KeyboardNavigationTests_XY : ScopedTestBase }; var window = new Window { - [XYFocus.KeyboardNavigationEnabledProperty] = XYFocusKeyboardNavigationMode.Enabled, + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, Content = new Canvas { Children = @@ -217,7 +217,7 @@ public class KeyboardNavigationTests_XY : ScopedTestBase }; var window = new Window { - [XYFocus.KeyboardNavigationEnabledProperty] = XYFocusKeyboardNavigationMode.Enabled, + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, Content = center }; window.Show(); @@ -249,7 +249,7 @@ public class KeyboardNavigationTests_XY : ScopedTestBase }; var window = new Window { - [XYFocus.KeyboardNavigationEnabledProperty] = XYFocusKeyboardNavigationMode.Enabled, + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, Content = new StackPanel { Orientation = Orientation.Horizontal, @@ -276,7 +276,7 @@ public class KeyboardNavigationTests_XY : ScopedTestBase }; var window = new Window { - [XYFocus.KeyboardNavigationEnabledProperty] = XYFocusKeyboardNavigationMode.Enabled, + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, Content = parent, Height = 30 }; @@ -300,7 +300,7 @@ public class KeyboardNavigationTests_XY : ScopedTestBase }; var window = new Window { - [XYFocus.KeyboardNavigationEnabledProperty] = XYFocusKeyboardNavigationMode.Enabled, + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, Content = new ScrollViewer { Content = parent @@ -333,7 +333,7 @@ public class KeyboardNavigationTests_XY : ScopedTestBase }] = candidate; var window = new Window { - [XYFocus.KeyboardNavigationEnabledProperty] = XYFocusKeyboardNavigationMode.Enabled, + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, Content = new Canvas { Children = { current, candidate } @@ -361,7 +361,7 @@ public class KeyboardNavigationTests_XY : ScopedTestBase var current = new Button(); var window = new Window { - [XYFocus.KeyboardNavigationEnabledProperty] = XYFocusKeyboardNavigationMode.Enabled, + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, Content = new Canvas { Children = { current }