diff --git a/src/Avalonia.Controls/Button.cs b/src/Avalonia.Controls/Button.cs index e6866b8142..78bbd836be 100644 --- a/src/Avalonia.Controls/Button.cs +++ b/src/Avalonia.Controls/Button.cs @@ -219,13 +219,16 @@ namespace Avalonia.Controls { base.OnPointerPressed(e); - PseudoClasses.Add(":pressed"); - e.Device.Capture(this); - e.Handled = true; - - if (ClickMode == ClickMode.Press) + if (e.MouseButton == MouseButton.Left) { - RaiseClickEvent(); + PseudoClasses.Add(":pressed"); + e.Device.Capture(this); + e.Handled = true; + + if (ClickMode == ClickMode.Press) + { + RaiseClickEvent(); + } } } @@ -234,13 +237,16 @@ namespace Avalonia.Controls { base.OnPointerReleased(e); - e.Device.Capture(null); - PseudoClasses.Remove(":pressed"); - e.Handled = true; - - if (ClickMode == ClickMode.Release && new Rect(Bounds.Size).Contains(e.GetPosition(this))) + if (e.MouseButton == MouseButton.Left) { - RaiseClickEvent(); + e.Device.Capture(null); + PseudoClasses.Remove(":pressed"); + e.Handled = true; + + if (ClickMode == ClickMode.Release && new Rect(Bounds.Size).Contains(e.GetPosition(this))) + { + RaiseClickEvent(); + } } } diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index b966d09b1f..5d1b9a1462 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -16,7 +16,7 @@ namespace Avalonia.Controls /// /// Displays a hierachical tree of data. /// - public class TreeView : ItemsControl + public class TreeView : ItemsControl, ICustomKeyboardNavigation { /// /// Defines the property. @@ -90,6 +90,26 @@ namespace Avalonia.Controls } } + (bool handled, IInputElement next) ICustomKeyboardNavigation.GetNext(IInputElement element, NavigationDirection direction) + { + if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous) + { + if (!this.IsVisualAncestorOf(element)) + { + IControl result = _selectedItem != null ? + ItemContainerGenerator.Index.ContainerFromItem(_selectedItem) : + ItemContainerGenerator.ContainerFromIndex(0); + return (true, result); + } + else + { + return (true, null); + } + } + + return (false, null); + } + /// protected override IItemContainerGenerator CreateItemContainerGenerator() { diff --git a/src/Avalonia.Input/Avalonia.Input.csproj b/src/Avalonia.Input/Avalonia.Input.csproj index e9e74e24fe..0411cf77a5 100644 --- a/src/Avalonia.Input/Avalonia.Input.csproj +++ b/src/Avalonia.Input/Avalonia.Input.csproj @@ -37,5 +37,8 @@ Properties\SharedAssemblyInfo.cs + + + \ No newline at end of file diff --git a/src/Avalonia.Input/FocusManager.cs b/src/Avalonia.Input/FocusManager.cs index e5cc5a8557..102da6efc4 100644 --- a/src/Avalonia.Input/FocusManager.cs +++ b/src/Avalonia.Input/FocusManager.cs @@ -176,9 +176,10 @@ namespace Avalonia.Input /// The event args. private void OnPreviewPointerPressed(object sender, RoutedEventArgs e) { - if (sender == e.Source) + var ev = (PointerPressedEventArgs)e; + + if (sender == e.Source && ev.MouseButton == MouseButton.Left) { - var ev = (PointerPressedEventArgs)e; var element = (ev.Device?.Captured as IInputElement) ?? (e.Source as IInputElement); if (element == null || !CanFocus(element)) diff --git a/src/Avalonia.Input/ICustomKeyboardNavigation.cs b/src/Avalonia.Input/ICustomKeyboardNavigation.cs new file mode 100644 index 0000000000..de5f98e04b --- /dev/null +++ b/src/Avalonia.Input/ICustomKeyboardNavigation.cs @@ -0,0 +1,15 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; + +namespace Avalonia.Input +{ + /// + /// Designates a control as handling its own keyboard navigation. + /// + public interface ICustomKeyboardNavigation + { + (bool handled, IInputElement next) GetNext(IInputElement element, NavigationDirection direction); + } +} diff --git a/src/Avalonia.Input/KeyboardNavigationHandler.cs b/src/Avalonia.Input/KeyboardNavigationHandler.cs index 57da49fa03..bf2b61d08b 100644 --- a/src/Avalonia.Input/KeyboardNavigationHandler.cs +++ b/src/Avalonia.Input/KeyboardNavigationHandler.cs @@ -2,7 +2,9 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; +using System.Linq; using Avalonia.Input.Navigation; +using Avalonia.VisualTree; namespace Avalonia.Input { @@ -52,6 +54,31 @@ namespace Avalonia.Input { Contract.Requires(element != null); + var customHandler = element.GetSelfAndVisualAncestors() + .OfType() + .FirstOrDefault(); + + 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) + { + return TabNavigation.GetNextInTabOrder((IInputElement)customHandler, direction, true); + } + else + { + return null; + } + } + } + if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous) { return TabNavigation.GetNextInTabOrder(element, direction); diff --git a/src/Avalonia.Input/Navigation/DirectionalNavigation.cs b/src/Avalonia.Input/Navigation/DirectionalNavigation.cs index a88ed1e8aa..75cb3a39e8 100644 --- a/src/Avalonia.Input/Navigation/DirectionalNavigation.cs +++ b/src/Avalonia.Input/Navigation/DirectionalNavigation.cs @@ -41,7 +41,7 @@ namespace Avalonia.Input.Navigation { case KeyboardNavigationMode.Continue: return GetNextInContainer(element, container, direction) ?? - GetFirstInNextContainer(element, direction); + GetFirstInNextContainer(element, element, direction); case KeyboardNavigationMode.Cycle: return GetNextInContainer(element, container, direction) ?? GetFocusableDescendant(container, direction); @@ -173,10 +173,12 @@ namespace Avalonia.Input.Navigation /// /// Gets the first item that should be focused in the next container. /// + /// The element being navigated away from. /// The container. /// The direction of the search. /// The first element, or null if there are no more elements. private static IInputElement GetFirstInNextContainer( + IInputElement element, IInputElement container, NavigationDirection direction) { @@ -200,6 +202,16 @@ namespace Avalonia.Input.Navigation if (sibling != null) { + if (sibling is ICustomKeyboardNavigation custom) + { + var (handled, customNext) = custom.GetNext(element, direction); + + if (handled) + { + return customNext; + } + } + if (sibling.CanFocus()) { next = sibling; @@ -214,7 +226,7 @@ namespace Avalonia.Input.Navigation if (next == null) { - next = GetFirstInNextContainer(parent, direction); + next = GetFirstInNextContainer(element, parent, direction); } } else diff --git a/src/Avalonia.Input/Navigation/TabNavigation.cs b/src/Avalonia.Input/Navigation/TabNavigation.cs index 6ba7ab1a0c..6e077e887f 100644 --- a/src/Avalonia.Input/Navigation/TabNavigation.cs +++ b/src/Avalonia.Input/Navigation/TabNavigation.cs @@ -18,13 +18,17 @@ namespace Avalonia.Input.Navigation /// /// The element. /// The tab direction. Must be Next or Previous. + /// + /// If true will not descend into to find next control. + /// /// /// The next element in the specified direction, or null if /// was the last in the requested direction. /// public static IInputElement GetNextInTabOrder( IInputElement element, - NavigationDirection direction) + NavigationDirection direction, + bool outsideElement = false) { Contract.Requires(element != null); Contract.Requires( @@ -40,20 +44,20 @@ namespace Avalonia.Input.Navigation switch (mode) { case KeyboardNavigationMode.Continue: - return GetNextInContainer(element, container, direction) ?? - GetFirstInNextContainer(element, direction); + return GetNextInContainer(element, container, direction, outsideElement) ?? + GetFirstInNextContainer(element, element, direction); case KeyboardNavigationMode.Cycle: - return GetNextInContainer(element, container, direction) ?? + return GetNextInContainer(element, container, direction, outsideElement) ?? GetFocusableDescendant(container, direction); case KeyboardNavigationMode.Contained: - return GetNextInContainer(element, container, direction); + return GetNextInContainer(element, container, direction, outsideElement); default: - return GetFirstInNextContainer(container, direction); + return GetFirstInNextContainer(element, container, direction); } } else { - return GetFocusableDescendants(element).FirstOrDefault(); + return GetFocusableDescendants(element, direction).FirstOrDefault(); } } @@ -66,16 +70,17 @@ namespace Avalonia.Input.Navigation private static IInputElement GetFocusableDescendant(IInputElement container, NavigationDirection direction) { return direction == NavigationDirection.Next ? - GetFocusableDescendants(container).FirstOrDefault() : - GetFocusableDescendants(container).LastOrDefault(); + GetFocusableDescendants(container, direction).FirstOrDefault() : + GetFocusableDescendants(container, direction).LastOrDefault(); } /// /// Gets the focusable descendants of the specified element. /// /// The element. + /// The tab direction. Must be Next or Previous. /// The element's focusable descendants. - private static IEnumerable GetFocusableDescendants(IInputElement element) + private static IEnumerable GetFocusableDescendants(IInputElement element, NavigationDirection direction) { var mode = KeyboardNavigation.GetTabNavigation((InputElement)element); @@ -103,16 +108,25 @@ namespace Avalonia.Input.Navigation foreach (var child in children) { - if (child.CanFocus()) + var customNext = GetCustomNext(child, direction); + + if (customNext.handled) { - yield return child; + yield return customNext.next; } - - if (child.CanFocusDescendants()) + else { - foreach (var descendant in GetFocusableDescendants(child)) + if (child.CanFocus()) { - yield return descendant; + yield return child; + } + + if (child.CanFocusDescendants()) + { + foreach (var descendant in GetFocusableDescendants(child, direction)) + { + yield return descendant; + } } } } @@ -124,15 +138,19 @@ namespace Avalonia.Input.Navigation /// The starting element/ /// The container. /// The direction. + /// + /// If true will not descend into to find next control. + /// /// The next element, or null if the element is the last. private static IInputElement GetNextInContainer( IInputElement element, IInputElement container, - NavigationDirection direction) + NavigationDirection direction, + bool outsideElement) { - if (direction == NavigationDirection.Next) + if (direction == NavigationDirection.Next && !outsideElement) { - var descendant = GetFocusableDescendants(element).FirstOrDefault(); + var descendant = GetFocusableDescendants(element, direction).FirstOrDefault(); if (descendant != null) { @@ -167,7 +185,7 @@ namespace Avalonia.Input.Navigation if (element != null && direction == NavigationDirection.Previous) { - var descendant = GetFocusableDescendants(element).LastOrDefault(); + var descendant = GetFocusableDescendants(element, direction).LastOrDefault(); if (descendant != null) { @@ -184,10 +202,12 @@ namespace Avalonia.Input.Navigation /// /// Gets the first item that should be focused in the next container. /// + /// The element being navigated away from. /// The container. /// The direction of the search. /// The first element, or null if there are no more elements. private static IInputElement GetFirstInNextContainer( + IInputElement element, IInputElement container, NavigationDirection direction) { @@ -210,6 +230,13 @@ namespace Avalonia.Input.Navigation if (sibling != null) { + var customNext = GetCustomNext(sibling, direction); + + if (customNext.handled) + { + return customNext.next; + } + if (sibling.CanFocus()) { next = sibling; @@ -217,24 +244,34 @@ namespace Avalonia.Input.Navigation else { next = direction == NavigationDirection.Next ? - GetFocusableDescendants(sibling).FirstOrDefault() : - GetFocusableDescendants(sibling).LastOrDefault(); + GetFocusableDescendants(sibling, direction).FirstOrDefault() : + GetFocusableDescendants(sibling, direction).LastOrDefault(); } } if (next == null) { - next = GetFirstInNextContainer(parent, direction); + next = GetFirstInNextContainer(element, parent, direction); } } else { next = direction == NavigationDirection.Next ? - GetFocusableDescendants(container).FirstOrDefault() : - GetFocusableDescendants(container).LastOrDefault(); + GetFocusableDescendants(container, direction).FirstOrDefault() : + GetFocusableDescendants(container, direction).LastOrDefault(); } return next; } + + private static (bool handled, IInputElement next) GetCustomNext(IInputElement element, NavigationDirection direction) + { + if (element is ICustomKeyboardNavigation custom) + { + return custom.GetNext(element, direction); + } + + return (false, null); + } } } diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index 52d36a33fa..44ef7192ff 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -315,6 +315,50 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(new[] { "NewChild1" }, ExtractItemHeader(target, 1)); } + [Fact] + public void Keyboard_Navigation_Should_Move_To_Last_Selected_Node() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var focus = FocusManager.Instance; + var navigation = AvaloniaLocator.Current.GetService(); + var data = CreateTestTreeData(); + + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = data, + DataTemplates = CreateNodeDataTemplate(), + }; + + var button = new Button(); + + var root = new TestRoot + { + Child = new StackPanel + { + Children = { target, button }, + } + }; + + ApplyTemplates(target); + + var item = data[0].Children[0]; + var node = target.ItemContainerGenerator.Index.ContainerFromItem(item); + Assert.NotNull(node); + + target.SelectedItem = item; + node.Focus(); + Assert.Same(node, focus.Current); + + navigation.Move(focus.Current, NavigationDirection.Next); + Assert.Same(button, focus.Current); + + navigation.Move(focus.Current, NavigationDirection.Next); + Assert.Same(node, focus.Current); + } + } + private void ApplyTemplates(TreeView tree) { tree.ApplyTemplate(); diff --git a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs new file mode 100644 index 0000000000..a090dcd18d --- /dev/null +++ b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs @@ -0,0 +1,214 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using Avalonia.Controls; +using Xunit; + +namespace Avalonia.Input.UnitTests +{ + public class KeyboardNavigationTests_Custom + { + [Fact] + public void Tab_Should_Custom_Navigate_Within_Children() + { + Button current; + Button next; + var target = new CustomNavigatingStackPanel + { + Children = + { + (current = new Button { Content = "Button 1" }), + new Button { Content = "Button 2" }, + (next = new Button { Content = "Button 3" }), + }, + NextControl = next, + }; + + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); + + Assert.Same(next, result); + } + + [Fact] + public void Right_Should_Custom_Navigate_Within_Children() + { + Button current; + Button next; + var target = new CustomNavigatingStackPanel + { + Children = + { + (current = new Button { Content = "Button 1" }), + new Button { Content = "Button 2" }, + (next = new Button { Content = "Button 3" }), + }, + NextControl = next, + }; + + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Right); + + Assert.Same(next, result); + } + + [Fact] + public void Tab_Should_Custom_Navigate_From_Outside() + { + Button current; + Button next; + var target = new CustomNavigatingStackPanel + { + Children = + { + new Button { Content = "Button 1" }, + new Button { Content = "Button 2" }, + (next = new Button { Content = "Button 3" }), + }, + NextControl = next, + }; + + var root = new StackPanel + { + Children = + { + (current = new Button { Content = "Outside" }), + target, + } + }; + + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); + + Assert.Same(next, result); + } + + [Fact] + public void Tab_Should_Custom_Navigate_From_Outside_When_Wrapping() + { + Button current; + Button next; + var target = new CustomNavigatingStackPanel + { + Children = + { + new Button { Content = "Button 1" }, + new Button { Content = "Button 2" }, + (next = new Button { Content = "Button 3" }), + }, + NextControl = next, + }; + + var root = new StackPanel + { + Children = + { + target, + (current = new Button { Content = "Outside" }), + } + }; + + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); + + Assert.Same(next, result); + } + + [Fact] + public void ShiftTab_Should_Custom_Navigate_From_Outside() + { + Button current; + Button next; + var target = new CustomNavigatingStackPanel + { + Children = + { + new Button { Content = "Button 1" }, + new Button { Content = "Button 2" }, + (next = new Button { Content = "Button 3" }), + }, + NextControl = next, + }; + + var root = new StackPanel + { + Children = + { + (current = new Button { Content = "Outside" }), + target, + } + }; + + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); + + Assert.Same(next, result); + } + + [Fact] + public void Right_Should_Custom_Navigate_From_Outside() + { + Button current; + Button next; + var target = new CustomNavigatingStackPanel + { + Children = + { + new Button { Content = "Button 1" }, + new Button { Content = "Button 2" }, + (next = new Button { Content = "Button 3" }), + }, + NextControl = next, + }; + + var root = new StackPanel + { + Children = + { + (current = new Button { Content = "Outside" }), + target, + }, + [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, + }; + + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Right); + + Assert.Same(next, result); + } + + [Fact] + public void Tab_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 + { + Children = + { + target, + (next = new Button { Content = "Outside" }), + } + }; + + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); + + Assert.Same(next, result); + } + + private class CustomNavigatingStackPanel : StackPanel, ICustomKeyboardNavigation + { + public bool CustomNavigates { get; set; } = true; + public IInputElement NextControl { get; set; } + + public (bool handled, IInputElement next) GetNext(IInputElement element, NavigationDirection direction) + { + return (CustomNavigates, NextControl); + } + } + } +} diff --git a/tests/Avalonia.UnitTests/TestServices.cs b/tests/Avalonia.UnitTests/TestServices.cs index 0cd8d4295b..f66adec1eb 100644 --- a/tests/Avalonia.UnitTests/TestServices.cs +++ b/tests/Avalonia.UnitTests/TestServices.cs @@ -50,6 +50,7 @@ namespace Avalonia.UnitTests public static readonly TestServices RealFocus = new TestServices( focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(), + keyboardNavigation: new KeyboardNavigationHandler(), inputManager: new InputManager()); public static readonly TestServices RealLayoutManager = new TestServices( @@ -63,6 +64,7 @@ namespace Avalonia.UnitTests IFocusManager focusManager = null, IInputManager inputManager = null, Func keyboardDevice = null, + IKeyboardNavigationHandler keyboardNavigation = null, ILayoutManager layoutManager = null, IRuntimePlatform platform = null, Func renderer = null, @@ -79,6 +81,7 @@ namespace Avalonia.UnitTests FocusManager = focusManager; InputManager = inputManager; KeyboardDevice = keyboardDevice; + KeyboardNavigation = keyboardNavigation; LayoutManager = layoutManager; Platform = platform; Renderer = renderer; @@ -96,6 +99,7 @@ namespace Avalonia.UnitTests public IInputManager InputManager { get; } public IFocusManager FocusManager { get; } public Func KeyboardDevice { get; } + public IKeyboardNavigationHandler KeyboardNavigation { get; } public ILayoutManager LayoutManager { get; } public IRuntimePlatform Platform { get; } public Func Renderer { get; } @@ -113,6 +117,7 @@ namespace Avalonia.UnitTests IFocusManager focusManager = null, IInputManager inputManager = null, Func keyboardDevice = null, + IKeyboardNavigationHandler keyboardNavigation = null, ILayoutManager layoutManager = null, IRuntimePlatform platform = null, Func renderer = null, @@ -131,6 +136,7 @@ namespace Avalonia.UnitTests focusManager: focusManager ?? FocusManager, inputManager: inputManager ?? InputManager, keyboardDevice: keyboardDevice ?? KeyboardDevice, + keyboardNavigation: keyboardNavigation ?? KeyboardNavigation, layoutManager: layoutManager ?? LayoutManager, platform: platform ?? Platform, renderer: renderer ?? Renderer, diff --git a/tests/Avalonia.UnitTests/UnitTestApplication.cs b/tests/Avalonia.UnitTests/UnitTestApplication.cs index c5d533486b..28577e9670 100644 --- a/tests/Avalonia.UnitTests/UnitTestApplication.cs +++ b/tests/Avalonia.UnitTests/UnitTestApplication.cs @@ -49,6 +49,7 @@ namespace Avalonia.UnitTests .BindToSelf(this) .Bind().ToConstant(Services.InputManager) .Bind().ToConstant(Services.KeyboardDevice?.Invoke()) + .Bind().ToConstant(Services.KeyboardNavigation) .Bind().ToConstant(Services.LayoutManager) .Bind().ToConstant(Services.Platform) .Bind().ToConstant(new RendererFactory(Services.Renderer))