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

XY focus #13947

Merged
merged 34 commits into from
Feb 6, 2024
Merged

XY focus #13947

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
7eb8949
Init
maxkatz6 Dec 4, 2023
7be86a6
Remove XY navigation cache as it's no use
maxkatz6 Dec 12, 2023
6355bf2
Use pooled collection for XY navigation
maxkatz6 Dec 12, 2023
6eb271c
Restructure code a bit, fix IScroller handling
maxkatz6 Dec 13, 2023
640189b
Init KeyboardNavigationTests_XY tests
maxkatz6 Dec 13, 2023
d85c97d
Simplify XYFocus.GetNextFocusableElement usage
maxkatz6 Dec 14, 2023
7de2a13
Minor fixes
maxkatz6 Dec 14, 2023
b4600db
Add more tests
maxkatz6 Dec 14, 2023
e484de5
Remove unused NuiKeyboardNavigationHandler
maxkatz6 Dec 14, 2023
11883e2
Finalizing
maxkatz6 Dec 15, 2023
1e4e978
Fix tests
maxkatz6 Dec 15, 2023
24b6d2e
Add TODO12
maxkatz6 Dec 15, 2023
7436ae0
Make XYFocusOptions a class
maxkatz6 Dec 15, 2023
087c8ea
Add TestServices.FocusableWindow and make KeyboardNavigationHandler l…
maxkatz6 Dec 15, 2023
df318d7
Fix KeyboardNavigationHandler events handling, when focus was not act…
maxkatz6 Dec 15, 2023
629a4a3
Add arrow key tests
maxkatz6 Dec 15, 2023
b7759cf
Merge branch 'master' into xy-focus
maxkatz6 Jan 11, 2024
446cea0
Replace XYFocusKeyboardNavigationMode with more flexible XYFocusNavig…
maxkatz6 Jan 11, 2024
6ea95db
Make XY focus navigation less broken, when there is no starting focus…
maxkatz6 Jan 11, 2024
42e3dad
Several Android TV compatibility improvements
maxkatz6 Jan 11, 2024
592d0ac
Remap tizen Back button to Esc
maxkatz6 Jan 11, 2024
48c547a
Merge remote-tracking branch 'origin/master' into xy-focus
maxkatz6 Jan 21, 2024
45b3dba
Merge remote-tracking branch 'origin/master' into xy-focus
maxkatz6 Jan 28, 2024
1737ddf
Merge branch 'master' into xy-focus
maxkatz6 Feb 3, 2024
0e7fd61
Merge branch 'master' into xy-focus
maxkatz6 Feb 3, 2024
d0d3348
Introduce internal XYFocusHelpers
maxkatz6 Feb 3, 2024
514a313
Make ComboBox and AutoCompleteBox handle Key events only when it's ne…
maxkatz6 Feb 3, 2024
27f8681
Make TextBox handle Key events only when it's needed
maxkatz6 Feb 3, 2024
7462c62
Ignore Alt+Down when XY navigation is enabled in CalendarDatePicker a…
maxkatz6 Feb 3, 2024
24d428b
Merge remote-tracking branch 'origin/master' into xy-focus
maxkatz6 Feb 3, 2024
32b2008
Rename IsAllowedXYNavigationMode
maxkatz6 Feb 3, 2024
14037cd
Fix ButtonSpinner with XY navigation
maxkatz6 Feb 3, 2024
758bb46
Implement a very simple focus engagement for GridSplitter and Slider
maxkatz6 Feb 3, 2024
c39a0a8
Merge branch 'master' into xy-focus
maxkatz6 Feb 4, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0-android</TargetFramework>
<TargetFramework>net8.0-android</TargetFramework>
<SupportedOSPlatformVersion>21</SupportedOSPlatformVersion>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
Expand Down
9 changes: 7 additions & 2 deletions samples/ControlCatalog.Android/MainActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@
using Avalonia.Android;
using static Android.Content.Intent;

// leanback and touchscreen are required for the Android TV.
[assembly: UsesFeature("android.software.leanback", Required = false)]
[assembly: UsesFeature("android.hardware.touchscreen", Required = false)]

namespace ControlCatalog.Android
{
[Activity(Label = "ControlCatalog.Android", Theme = "@style/MyTheme.NoActionBar", Icon = "@drawable/icon", MainLauncher = true, Exported = true, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
// IntentFilter are here to test IActivatableApplicationLifetime
[IntentFilter(new [] { ActionView }, Categories = new [] { CategoryDefault, CategoryBrowsable }, DataScheme = "avln" )]
// CategoryBrowsable and DataScheme are required for Protocol activation.
// CategoryLeanbackLauncher is required for Android TV.
[IntentFilter(new [] { ActionView }, Categories = new [] { CategoryDefault, CategoryBrowsable, CategoryLeanbackLauncher }, DataScheme = "avln" )]
public class MainActivity : AvaloniaMainActivity<App>
{
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
Expand Down
3 changes: 3 additions & 0 deletions samples/ControlCatalog/MainView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@
<TabItem Header="Flyouts">
<pages:FlyoutsPage />
</TabItem>
<TabItem Header="Focus">
<pages:FocusPage />
</TabItem>
<TabItem Header="Gestures">
<pages:GesturePage />
</TabItem>
Expand Down
4 changes: 2 additions & 2 deletions samples/ControlCatalog/Pages/ComboBoxPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@
</ComboBox>

<ComboBox WrapSelection="{Binding WrapSelection}">
<ComboBox.Items>
<ComboBox.ItemsSource>
<col:ArrayList>
<x:Null />
<sys:String>Hello</sys:String>
<sys:String>World</sys:String>
</col:ArrayList>
</ComboBox.Items>
</ComboBox.ItemsSource>
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="sys:String">
<Panel>
Expand Down
50 changes: 50 additions & 0 deletions samples/ControlCatalog/Pages/FocusPage.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:generic="clr-namespace:System.Collections.Generic;assembly=netstandard"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="ControlCatalog.Pages.FocusPage">
<TabControl>
<TabItem Header="XY Focus">
<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="XYFocusNavigationModes">
<XYFocusNavigationModes>Enabled</XYFocusNavigationModes>
<XYFocusNavigationModes>Disabled</XYFocusNavigationModes>
</generic:List>
</ComboBox.ItemsSource>
</ComboBox>
<ComboBox x:Name="NavigationStrategy" SelectedIndex="0">
<ComboBox.ItemsSource>
<generic:List x:TypeArguments="XYFocusNavigationStrategy">
<XYFocusNavigationStrategy>Projection</XYFocusNavigationStrategy>
<XYFocusNavigationStrategy>NavigationDirectionDistance</XYFocusNavigationStrategy>
<XYFocusNavigationStrategy>RectilinearDistance</XYFocusNavigationStrategy>
</generic:List>
</ComboBox.ItemsSource>
</ComboBox>
</StackPanel>

<Canvas HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Width="500">
<Canvas.Styles>
<Style Selector="Button">
<Setter Property="XYFocus.UpNavigationStrategy" Value="{Binding #NavigationStrategy.SelectedItem}" />
<Setter Property="XYFocus.DownNavigationStrategy" Value="{Binding #NavigationStrategy.SelectedItem}" />
<Setter Property="XYFocus.LeftNavigationStrategy" Value="{Binding #NavigationStrategy.SelectedItem}" />
<Setter Property="XYFocus.RightNavigationStrategy" Value="{Binding #NavigationStrategy.SelectedItem}" />
</Style>
</Canvas.Styles>

<Button Canvas.Top="0" Canvas.Left="50" Width="150" Height="150">A</Button>
<Button Canvas.Top="150" Canvas.Left="400" Width="50" Height="50">C</Button>
<Button Canvas.Top="200" Canvas.Left="0" Width="50" Height="50">B</Button>
<Button Canvas.Top="300" Canvas.Left="100" Width="50" Height="50">D</Button>
</Canvas>
</StackPanel>
</TabItem>
</TabControl>
</UserControl>
14 changes: 14 additions & 0 deletions samples/ControlCatalog/Pages/FocusPage.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;

namespace ControlCatalog.Pages;

public partial class FocusPage : UserControl
{
public FocusPage()
{
AvaloniaXamlLoader.Load(this);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ internal class AndroidKeyboardDevice : KeyboardDevice, IKeyboardDevice
{ Keycode.PageDown, Key.PageDown },
{ Keycode.MoveEnd, Key.End },
{ Keycode.MoveHome, Key.Home },
{ Keycode.DpadLeft, Key.Left },
{ Keycode.DpadUp, Key.Up },
{ Keycode.DpadRight, Key.Right },
{ Keycode.DpadDown, Key.Down },
// { Keycode.ButtonSelect?, Key.Select },
// { Keycode.print?, Key.Print },
//{ Keycode.execute?, Key.Execute },
Expand Down Expand Up @@ -209,7 +205,15 @@ internal class AndroidKeyboardDevice : KeyboardDevice, IKeyboardDevice
//{ Keycode.?, Key.DbeEnterDialogConversionMode }
//{ Keycode.?, Key.OemClear }
//{ Keycode.?, Key.DeadCharProcessed }
{ Keycode.Backslash, Key.OemBackslash }
{ Keycode.Backslash, Key.OemBackslash },

// Loosely mapping DPad keys to Avalonia keys
{ Keycode.Back, Key.Escape },
{ Keycode.DpadCenter, Key.Space },
{ Keycode.DpadLeft, Key.Left },
{ Keycode.DpadUp, Key.Up },
{ Keycode.DpadRight, Key.Right },
{ Keycode.DpadDown, Key.Down }
};

internal static Key ConvertKey(Keycode key)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,20 @@ private static RawInputModifiers GetModifierKeys(KeyEvent e)
private KeyDeviceType GetKeyDeviceType(KeyEvent e)
{
var source = e.Device?.Sources ?? InputSourceType.Unknown;
if (source is InputSourceType.Joystick or
InputSourceType.ClassJoystick or
InputSourceType.Gamepad)
return KeyDeviceType.Gamepad;

if (source == InputSourceType.Dpad && e.Device?.KeyboardType == InputKeyboardType.NonAlphabetic)
// Remote controller reports itself as "DPad | Keyboard", which is confusing,
// so we need to double-check KeyboardType as well.

if (source.HasAnyFlag(InputSourceType.Dpad)
&& e.Device?.KeyboardType == InputKeyboardType.NonAlphabetic)
return KeyDeviceType.Remote;

return KeyDeviceType.Keyboard;
// ReSharper disable BitwiseOperatorOnEnumWithoutFlags - it IS flags enum under the hood.
if (source.HasAnyFlag(InputSourceType.Joystick | InputSourceType.Gamepad))
return KeyDeviceType.Gamepad;
// ReSharper restore BitwiseOperatorOnEnumWithoutFlags

return KeyDeviceType.Keyboard; // fallback to the keyboard, if unknown.
}

public void Dispose()
Expand Down
11 changes: 11 additions & 0 deletions src/Avalonia.Base/Controls/IInternalScroller.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Runtime.CompilerServices;

namespace Avalonia.Controls.Primitives;

// TODO12: Integrate with existing IScrollable interface, breaking change
internal interface IInternalScroller
maxkatz6 marked this conversation as resolved.
Show resolved Hide resolved
{
bool CanHorizontallyScroll { get; }

bool CanVerticallyScroll { get; }
}
2 changes: 1 addition & 1 deletion src/Avalonia.Base/Input/FocusManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ internal bool TryMoveFocus(NavigationDirection direction)
/// </summary>
/// <param name="e">The element.</param>
/// <returns>True if the element can be focused.</returns>
private static bool CanFocus(IInputElement e) => e.Focusable && e.IsEffectivelyEnabled && IsVisible(e);
internal static bool CanFocus(IInputElement e) => e.Focusable && e.IsEffectivelyEnabled && IsVisible(e);

/// <summary>
/// Gets the focus scope of the specified control, traversing popups.
Expand Down
11 changes: 11 additions & 0 deletions src/Avalonia.Base/Input/IPointer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,19 @@ public interface IPointer
/// </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 changes: 20 additions & 7 deletions src/Avalonia.Base/Input/KeyDeviceType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,25 @@
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
}
78 changes: 60 additions & 18 deletions src/Avalonia.Base/Input/KeyboardNavigationHandler.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Input.Navigation;
using Avalonia.Input;
using Avalonia.Metadata;
using Avalonia.VisualTree;

Expand All @@ -16,7 +17,7 @@ public sealed class KeyboardNavigationHandler : IKeyboardNavigationHandler
/// The window to which the handler belongs.
/// </summary>
private IInputRoot? _owner;

/// <summary>
/// Sets the owner of the keyboard navigation handler.
/// </summary>
Expand All @@ -29,7 +30,7 @@ public void SetOwner(IInputRoot owner)
{
if (_owner != null)
{
throw new InvalidOperationException("AccessKeyHandler owner has already been set.");
throw new InvalidOperationException($"{nameof(KeyboardNavigationHandler)} owner has already been set.");
}

_owner = owner ?? throw new ArgumentNullException(nameof(owner));
Expand All @@ -50,22 +51,48 @@ public void SetOwner(IInputRoot owner)
NavigationDirection direction)
{
element = element ?? throw new ArgumentNullException(nameof(element));
return GetNextPrivate(element, null, direction, null);
}

private static IInputElement? GetNextPrivate(
IInputElement? element,
IInputRoot? owner,
NavigationDirection direction,
KeyDeviceType? keyDeviceType)
{
var elementOrOwner = element ?? owner ?? throw new ArgumentNullException(nameof(owner));

// If there's a custom keyboard navigation handler as an ancestor, use that.
var custom = (element as Visual)?.FindAncestorOfType<ICustomKeyboardNavigation>(true);
if (custom is not null && HandlePreCustomNavigation(custom, element, direction, out var ce))
if (custom is not null && HandlePreCustomNavigation(custom, elementOrOwner, direction, out var ce))
return ce;

var result = direction switch
IInputElement? result;
if (direction is NavigationDirection.Next)
{
result = TabNavigation.GetNextTab(elementOrOwner, false);
}
else if (direction is NavigationDirection.Previous)
{
result = TabNavigation.GetPrevTab(elementOrOwner, null, false);
}
else if (direction is NavigationDirection.Up or NavigationDirection.Down
or NavigationDirection.Left or NavigationDirection.Right)
{
// HACK: a window should always have some element focused,
// it seems to be a difference between UWP and Avalonia focus manager implementations.
result = element is null
? TabNavigation.GetNextTab(elementOrOwner, true)
: XYFocus.TryDirectionalFocus(direction, element, owner, null, keyDeviceType);
}
else
{
NavigationDirection.Next => TabNavigation.GetNextTab(element, false),
NavigationDirection.Previous => TabNavigation.GetPrevTab(element, null, false),
_ => throw new NotSupportedException(),
};
throw new ArgumentOutOfRangeException(nameof(direction), direction, 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))
if (custom is null && HandlePostCustomNavigation(elementOrOwner, result, direction, out ce))
return ce;

return result;
Expand All @@ -82,20 +109,23 @@ public void Move(
NavigationDirection direction,
KeyModifiers keyModifiers = KeyModifiers.None)
{
if (element is null && _owner is null)
{
return;
}
MovePrivate(element, direction, keyModifiers, null);
}

var next = GetNext(element ?? _owner!, direction);
// TODO12: remove MovePrivate, and make Move return boolean. Or even remove whole KeyboardNavigationHandler.
private bool MovePrivate(IInputElement? element, NavigationDirection direction, KeyModifiers keyModifiers, KeyDeviceType? deviceType)
{
var next = GetNextPrivate(element, _owner, direction, deviceType);

if (next != null)
{
var method = direction == NavigationDirection.Next ||
direction == NavigationDirection.Previous ?
NavigationMethod.Tab : NavigationMethod.Directional;
next.Focus(method, keyModifiers);
NavigationMethod.Tab : NavigationMethod.Directional;
return next.Focus(method, keyModifiers);
}

return false;
}

/// <summary>
Expand All @@ -110,8 +140,20 @@ void OnKeyDown(object? sender, KeyEventArgs e)
var current = FocusManager.GetFocusManager(e.Source as IInputElement)?.GetFocusedElement();
var direction = (e.KeyModifiers & KeyModifiers.Shift) == 0 ?
NavigationDirection.Next : NavigationDirection.Previous;
Move(current, direction, e.KeyModifiers);
e.Handled = true;
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)
maxkatz6 marked this conversation as resolved.
Show resolved Hide resolved
{
var current = FocusManager.GetFocusManager(e.Source as IInputElement)?.GetFocusedElement();
var direction = e.Key switch
{
Key.Left => NavigationDirection.Left,
Key.Right => NavigationDirection.Right,
Key.Up => NavigationDirection.Up,
Key.Down => NavigationDirection.Down,
_ => throw new ArgumentOutOfRangeException()
};
e.Handled = MovePrivate(current, direction, e.KeyModifiers, e.KeyDeviceType);
}
}

Expand Down
Loading