Browse Source

Replace XYFocusKeyboardNavigationMode with more flexible XYFocusNavigationModes, integrate with KeyDeviceType input types

xy-focus-and-tvos
Max Katz 2 years ago
parent
commit
446cea0151
  1. 8
      samples/ControlCatalog/Pages/FocusPage.xaml
  2. 11
      src/Avalonia.Base/Input/IPointer.cs
  3. 27
      src/Avalonia.Base/Input/KeyDeviceType.cs
  4. 21
      src/Avalonia.Base/Input/KeyboardNavigationHandler.cs
  5. 36
      src/Avalonia.Base/Input/Navigation/XYFocus.FindElements.cs
  6. 30
      src/Avalonia.Base/Input/Navigation/XYFocus.Impl.cs
  7. 16
      src/Avalonia.Base/Input/Navigation/XYFocus.Properties.cs
  8. 17
      src/Avalonia.Base/Input/Navigation/XYFocusKeyboardNavigationMode.cs
  9. 38
      src/Avalonia.Base/Input/Navigation/XYFocusNavigationModes.cs
  10. 2
      src/Avalonia.Base/Input/Navigation/XYFocusOptions.cs
  11. 20
      tests/Avalonia.Base.UnitTests/Input/KeyboardNavigationTests_XY.cs

8
samples/ControlCatalog/Pages/FocusPage.xaml

@ -7,14 +7,14 @@
x:Class="ControlCatalog.Pages.FocusPage">
<TabControl>
<TabItem Header="XY Focus">
<StackPanel x:Name="TabRoot" XYFocus.KeyboardNavigationEnabled="{Binding #KeyboardNavigation.SelectedItem}">
<StackPanel x:Name="TabRoot" XYFocus.NavigationModes="{Binding #KeyboardNavigation.SelectedItem}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="Navigation: " />
<ComboBox x:Name="KeyboardNavigation" SelectedIndex="0">
<ComboBox.ItemsSource>
<generic:List x:TypeArguments="XYFocusKeyboardNavigationMode">
<XYFocusKeyboardNavigationMode>Enabled</XYFocusKeyboardNavigationMode>
<XYFocusKeyboardNavigationMode>Disabled</XYFocusKeyboardNavigationMode>
<generic:List x:TypeArguments="XYFocusNavigationModes">
<XYFocusNavigationModes>Enabled</XYFocusNavigationModes>
<XYFocusNavigationModes>Disabled</XYFocusNavigationModes>
</generic:List>
</ComboBox.ItemsSource>
</ComboBox>

11
src/Avalonia.Base/Input/IPointer.cs

@ -54,8 +54,19 @@ namespace Avalonia.Input
/// </summary>
public enum PointerType
{
/// <summary>
/// The input device is a mouse.
/// </summary>
Mouse,
/// <summary>
/// The input device is a touch.
/// </summary>
Touch,
/// <summary>
/// The input device is a pen.
/// </summary>
Pen
}
}

27
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;
/// <summary>
/// Enumerates key device types.
/// </summary>
public enum KeyDeviceType
{
public enum KeyDeviceType
{
Keyboard,
Gamepad,
Remote
}
/// <summary>
/// The input device is a keyboard.
/// </summary>
Keyboard,
/// <summary>
/// The input device is a gamepad.
/// </summary>
Gamepad,
/// <summary>
/// The input device is a remote control.
/// </summary>
Remote
}

21
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);
}
}

36
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)
{

30
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)
};
}
}

16
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<XYFocusKeyboardNavigationMode> KeyboardNavigationEnabledProperty =
AvaloniaProperty.RegisterAttached<XYFocus, InputElement, XYFocusKeyboardNavigationMode>(
"KeyboardNavigation", inherits: true);
public static readonly AttachedProperty<XYFocusNavigationModes> NavigationModesProperty =
AvaloniaProperty.RegisterAttached<XYFocus, InputElement, XYFocusNavigationModes>(
"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<bool> IsFocusEngagementEnabledProperty =
AvaloniaProperty.RegisterAttached<XYFocus, InputElement, bool>("IsFocusEngagementEnabled");

17
src/Avalonia.Base/Input/Navigation/XYFocusKeyboardNavigationMode.cs

@ -1,17 +0,0 @@
namespace Avalonia.Input;
/// <summary>
/// Specifies the 2D directional navigation behavior when using the keyboard arrow keys.
/// </summary>
public enum XYFocusKeyboardNavigationMode
{
/// <summary>
/// Arrow keys can be used for 2D directional navigation.
/// </summary>
Enabled = 1,
/// <summary>
/// Arrow keys cannot be used for 2D directional navigation.
/// </summary>
Disabled
}

38
src/Avalonia.Base/Input/Navigation/XYFocusNavigationModes.cs

@ -0,0 +1,38 @@
using System;
namespace Avalonia.Input;
/// <summary>
/// Specifies the 2D directional navigation behavior when using different key devices.
/// </summary>
/// <remarks>
/// See <see cref="KeyDeviceType"/>.
/// </remarks>
[Flags]
public enum XYFocusNavigationModes
{
/// <summary>
/// Any key device XY navigation is disabled.
/// </summary>
Disabled = 0,
/// <summary>
/// Keyboard arrow keys can be used for 2D directional navigation.
/// </summary>
Keyboard = 1,
/// <summary>
/// Gamepad controller DPad keys can be used for 2D directional navigation.
/// </summary>
Gamepad = 2,
/// <summary>
/// Remote controller DPad keys can be used for 2D directional navigation.
/// </summary>
Remote = 4,
/// <summary>
/// All key device XY navigation is disabled.
/// </summary>
Enabled = Gamepad | Remote | Keyboard
}

2
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; }

20
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 }

Loading…
Cancel
Save