Browse Source

Add FindNextElementOptions.FocusedElement (#20930)

* Add FocusElement.FindNextElementOptions

* Add unit tests for FindNextElementOptions.FocusedElement

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
pull/20940/head
Julien Lebosquain 5 days ago
committed by GitHub
parent
commit
059bd5f7b9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 42
      src/Avalonia.Base/Input/FindNextElementOptions.cs
  2. 89
      src/Avalonia.Base/Input/FocusManager.cs
  3. 10
      src/Avalonia.Base/Input/IFocusManager.cs
  4. 108
      tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs

42
src/Avalonia.Base/Input/FindNextElementOptions.cs

@ -1,10 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Avalonia.Input
namespace Avalonia.Input
{
/// <summary>
/// Provides options to customize the behavior when identifying the next element to focus
@ -12,14 +6,28 @@ namespace Avalonia.Input
/// </summary>
public sealed class FindNextElementOptions
{
/// <summary>
/// Gets or sets the element that will be treated as the starting point of the search
/// for the next focusable element. This does not need to be the element that is
/// currently focused. If null, <see cref="FocusManager.GetFocusedElement()"/> is used.
/// </summary>
public IInputElement? FocusedElement { get; init; }
/// <summary>
/// Gets or sets the root <see cref="InputElement"/> within which the search for the next
/// focusable element will be conducted.
/// </summary>
/// <remarks>
/// <para>
/// This property defines the boundary for focus navigation operations. It determines the root element
/// in the visual tree under which the focusable item search is performed. If not specified, the search
/// will default to the current scope.
/// </para>
/// <para>
/// This option is only used with <see cref="NavigationDirection.Up"/>, <see cref="NavigationDirection.Down"/>,
/// <see cref="NavigationDirection.Left"/>, and <see cref="NavigationDirection.Right"/>. It is ignored for other
/// directions.
/// </para>
/// </remarks>
public InputElement? SearchRoot { get; init; }
@ -27,6 +35,11 @@ namespace Avalonia.Input
/// Gets or sets the rectangular region within the visual hierarchy that will be excluded
/// from consideration during focus navigation.
/// </summary>
/// <remarks>
/// This option is only used with <see cref="NavigationDirection.Up"/>, <see cref="NavigationDirection.Down"/>,
/// <see cref="NavigationDirection.Left"/>, and <see cref="NavigationDirection.Right"/>. It is ignored for other
/// directions.
/// </remarks>
public Rect ExclusionRect { get; init; }
/// <summary>
@ -35,12 +48,22 @@ namespace Avalonia.Input
/// which can be used as a preferred or prioritized target when navigating focus.
/// It can be null if no specific hint region is provided.
/// </summary>
/// <remarks>
/// This option is only used with <see cref="NavigationDirection.Up"/>, <see cref="NavigationDirection.Down"/>,
/// <see cref="NavigationDirection.Left"/>, and <see cref="NavigationDirection.Right"/>. It is ignored for other
/// directions.
/// </remarks>
public Rect? FocusHintRectangle { get; init; }
/// <summary>
/// Specifies an optional override for the navigation strategy used in XY focus navigation.
/// This property allows customizing the focus movement behavior when navigating between UI elements.
/// </summary>
/// <remarks>
/// This option is only used with <see cref="NavigationDirection.Up"/>, <see cref="NavigationDirection.Down"/>,
/// <see cref="NavigationDirection.Left"/>, and <see cref="NavigationDirection.Right"/>. It is ignored for other
/// directions.
/// </remarks>
public XYFocusNavigationStrategy? NavigationStrategyOverride { get; init; }
/// <summary>
@ -49,6 +72,11 @@ namespace Avalonia.Input
/// the navigation logic disregards obstructions that may block a potential
/// focus target, allowing elements behind such obstructions to be considered.
/// </summary>
/// <remarks>
/// This option is only used with <see cref="NavigationDirection.Up"/>, <see cref="NavigationDirection.Down"/>,
/// <see cref="NavigationDirection.Left"/>, and <see cref="NavigationDirection.Right"/>. It is ignored for other
/// directions.
/// </remarks>
public bool IgnoreOcclusivity { get; init; }
}
}

89
src/Avalonia.Base/Input/FocusManager.cs

@ -172,7 +172,7 @@ namespace Avalonia.Input
ValidateDirection(direction);
var focusOptions = ToFocusOptions(options, true);
var result = FindAndSetNextFocus(direction, focusOptions);
var result = FindAndSetNextFocus(options?.FocusedElement ?? Current, direction, focusOptions);
_reusableFocusOptions = focusOptions;
return result;
}
@ -319,7 +319,7 @@ namespace Avalonia.Input
ValidateDirection(direction);
var focusOptions = ToFocusOptions(options, false);
var result = FindNextFocus(direction, focusOptions);
var result = FindNextFocus(options?.FocusedElement ?? Current, direction, focusOptions);
_reusableFocusOptions = focusOptions;
return result;
}
@ -368,27 +368,29 @@ namespace Avalonia.Input
return focusOptions;
}
internal IInputElement? FindNextFocus(NavigationDirection direction, XYFocusOptions focusOptions, bool updateManifolds = true)
private IInputElement? FindNextFocus(
IInputElement? focusedElement,
NavigationDirection direction,
XYFocusOptions focusOptions,
bool updateManifolds = true)
{
IInputElement? nextFocusedElement = null;
IInputElement? nextFocusedElement;
var currentlyFocusedElement = Current;
if (direction is NavigationDirection.Previous or NavigationDirection.Next || currentlyFocusedElement == null)
if (direction is NavigationDirection.Previous or NavigationDirection.Next || focusedElement == null)
{
var isReverse = direction == NavigationDirection.Previous;
nextFocusedElement = ProcessTabStopInternal(isReverse, true);
nextFocusedElement = ProcessTabStopInternal(focusedElement, isReverse, true);
}
else
{
if (currentlyFocusedElement is InputElement inputElement &&
if (focusedElement is InputElement inputElement &&
XYFocus.GetBoundsForRanking(inputElement, focusOptions.IgnoreClipping) is { } bounds)
{
focusOptions.FocusedElementBounds = bounds;
}
nextFocusedElement = _xyFocus.GetNextFocusableElement(direction,
currentlyFocusedElement as InputElement,
focusedElement as InputElement,
null,
updateManifolds,
focusOptions);
@ -509,14 +511,14 @@ namespace Avalonia.Input
return lastFocus;
}
private IInputElement? ProcessTabStopInternal(bool isReverse, bool queryOnly)
private IInputElement? ProcessTabStopInternal(IInputElement? focusedElement, bool isReverse, bool queryOnly)
{
IInputElement? newTabStop = null;
var defaultCandidateTabStop = GetTabStopCandidateElement(isReverse, queryOnly, out var didCycleFocusAtRootVisualScope);
var defaultCandidateTabStop = GetTabStopCandidateElement(focusedElement, isReverse, queryOnly, out var didCycleFocusAtRootVisualScope);
var isTabStopOverriden = InputElement.ProcessTabStop(_contentRoot,
Current,
focusedElement,
defaultCandidateTabStop,
isReverse,
didCycleFocusAtRootVisualScope,
@ -535,24 +537,27 @@ namespace Avalonia.Input
return newTabStop;
}
private IInputElement? GetTabStopCandidateElement(bool isReverse, bool queryOnly, out bool didCycleFocusAtRootVisualScope)
private IInputElement? GetTabStopCandidateElement(
IInputElement? focusedElement,
bool isReverse,
bool queryOnly,
out bool didCycleFocusAtRootVisualScope)
{
didCycleFocusAtRootVisualScope = false;
var currentFocus = Current;
IInputElement? newTabStop = null;
var root = this._contentRoot as IInputElement;
IInputElement? newTabStop;
var root = _contentRoot;
if (root == null)
return null;
bool internalCycleWorkaround = false;
if (Current != null)
if (focusedElement != null)
{
internalCycleWorkaround = CanProcessTabStop(isReverse);
internalCycleWorkaround = CanProcessTabStop(focusedElement, isReverse);
}
if (currentFocus == null)
if (focusedElement == null)
{
if (!isReverse)
{
@ -567,7 +572,7 @@ namespace Avalonia.Input
}
else if (!isReverse)
{
newTabStop = GetNextTabStop();
newTabStop = GetNextTabStop(focusedElement);
if (newTabStop == null && (internalCycleWorkaround || queryOnly))
{
@ -578,7 +583,7 @@ namespace Avalonia.Input
}
else
{
newTabStop = GetPreviousTabStop();
newTabStop = GetPreviousTabStop(focusedElement);
if (newTabStop == null && (internalCycleWorkaround || queryOnly))
{
@ -590,9 +595,9 @@ namespace Avalonia.Input
return newTabStop;
}
private IInputElement? GetNextTabStop(IInputElement? currentTabStop = null, bool ignoreCurrentTabStop = false)
private IInputElement? GetNextTabStop(IInputElement? currentTabStop, bool ignoreCurrentTabStop = false)
{
var focused = currentTabStop ?? Current;
var focused = currentTabStop;
if (focused == null || _contentRoot == null)
{
return null;
@ -698,9 +703,9 @@ namespace Avalonia.Input
return newTabStop;
}
private IInputElement? GetPreviousTabStop(IInputElement? currentTabStop = null, bool ignoreCurrentTabStop = false)
private IInputElement? GetPreviousTabStop(IInputElement? currentTabStop, bool ignoreCurrentTabStop = false)
{
var focused = currentTabStop ?? Current;
var focused = currentTabStop;
if (focused == null || _contentRoot == null)
{
return null;
@ -930,23 +935,23 @@ namespace Avalonia.Input
return int.MaxValue;
}
private bool CanProcessTabStop(bool isReverse)
private bool CanProcessTabStop(IInputElement? focusedElement, bool isReverse)
{
bool isFocusOnFirst = false;
bool isFocusOnLast = false;
bool canProcessTab = true;
if (IsFocusedElementInPopup())
if (IsFocusedElementInPopup(focusedElement))
{
return true;
}
if (isReverse)
{
isFocusOnFirst = IsFocusOnFirstTabStop();
isFocusOnFirst = IsFocusOnFirstTabStop(focusedElement);
}
else
{
isFocusOnLast = IsFocusOnLastTabStop();
isFocusOnLast = IsFocusOnLastTabStop(focusedElement);
}
if (isFocusOnFirst || isFocusOnLast)
@ -961,7 +966,7 @@ namespace Avalonia.Input
if (edge != null)
{
var edgeParent = GetParentTabStopElement(edge);
if (edgeParent is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Once && edgeParent == GetParentTabStopElement(Current))
if (edgeParent is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Once && edgeParent == GetParentTabStopElement(focusedElement))
{
canProcessTab = false;
}
@ -975,13 +980,13 @@ namespace Avalonia.Input
{
if (isFocusOnLast || isFocusOnFirst)
{
if (Current is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Cycle)
if (focusedElement is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Cycle)
{
canProcessTab = true;
}
else
{
var focusedParent = GetParentTabStopElement(Current);
var focusedParent = GetParentTabStopElement(focusedElement);
while (focusedParent != null)
{
if (focusedParent is InputElement iE && KeyboardNavigation.GetTabNavigation(iE) == KeyboardNavigationMode.Cycle)
@ -1041,9 +1046,9 @@ namespace Avalonia.Input
return null;
}
private bool IsFocusOnLastTabStop()
private bool IsFocusOnLastTabStop(IInputElement? focusedElement)
{
if (Current == null || _contentRoot is not Visual visual)
if (focusedElement == null || _contentRoot is not Visual visual)
return false;
var root = visual.VisualRoot as IInputElement;
@ -1051,12 +1056,12 @@ namespace Avalonia.Input
var lastFocus = GetLastFocusableElement(root, null);
return lastFocus == Current;
return lastFocus == focusedElement;
}
private bool IsFocusOnFirstTabStop()
private bool IsFocusOnFirstTabStop(IInputElement? focusedElement)
{
if (Current == null || _contentRoot is not Visual visual)
if (focusedElement == null || _contentRoot is not Visual visual)
return false;
var root = visual.VisualRoot as IInputElement;
@ -1064,7 +1069,7 @@ namespace Avalonia.Input
var firstFocus = GetFirstFocusableElement(root, null);
return firstFocus == Current;
return firstFocus == focusedElement;
}
private static IInputElement? GetFirstFocusableElement(IInputElement searchStart, IInputElement? firstFocus = null)
@ -1091,7 +1096,7 @@ namespace Avalonia.Input
return lastFocus;
}
private bool IsFocusedElementInPopup() => Current != null && GetRootOfPopupSubTree(Current) != null;
private bool IsFocusedElementInPopup(IInputElement? focusedElement) => focusedElement != null && GetRootOfPopupSubTree(focusedElement) != null;
private Visual? GetRootOfPopupSubTree(IInputElement? current)
{
@ -1099,7 +1104,7 @@ namespace Avalonia.Input
return null;
}
private bool FindAndSetNextFocus(NavigationDirection direction, XYFocusOptions xYFocusOptions)
private bool FindAndSetNextFocus(IInputElement? focusedElement, NavigationDirection direction, XYFocusOptions xYFocusOptions)
{
var focusChanged = false;
if (xYFocusOptions.UpdateManifoldsFromFocusHintRect && xYFocusOptions.FocusHintRectangle != null)
@ -1107,7 +1112,7 @@ namespace Avalonia.Input
_xyFocus.SetManifoldsFromBounds(xYFocusOptions.FocusHintRectangle ?? default);
}
if (FindNextFocus(direction, xYFocusOptions, false) is { } nextFocusedElement)
if (FindNextFocus(focusedElement, direction, xYFocusOptions, false) is { } nextFocusedElement)
{
focusChanged = nextFocusedElement.Focus();

10
src/Avalonia.Base/Input/IFocusManager.cs

@ -41,10 +41,7 @@ namespace Avalonia.Input
/// <see cref="NavigationDirection.Left"/>, <see cref="NavigationDirection.Right"/>,
/// <see cref="NavigationDirection.Up"/> and <see cref="NavigationDirection.Down"/>.
/// </param>
/// <param name="options">
/// The options to help identify the next element to receive focus.
/// They only apply to directional navigation.
/// </param>
/// <param name="options">The options to help identify the next element to receive focus.</param>
/// <returns>true if focus moved; otherwise, false.</returns>
bool TryMoveFocus(NavigationDirection direction, FindNextElementOptions? options = null);
@ -69,10 +66,7 @@ namespace Avalonia.Input
/// <see cref="NavigationDirection.Left"/>, <see cref="NavigationDirection.Right"/>,
/// <see cref="NavigationDirection.Up"/> and <see cref="NavigationDirection.Down"/>.
/// </param>
/// <param name="options">
/// The options to help identify the next element to receive focus.
/// They only apply to directional navigation.
/// </param>
/// <param name="options">The options to help identify the next element to receive focus.</param>
/// <returns>The next element to receive focus, if any.</returns>
IInputElement? FindNextElement(NavigationDirection direction, FindNextElementOptions? options = null);
}

108
tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs

@ -806,7 +806,43 @@ namespace Avalonia.Base.UnitTests.Input
}
[Fact]
public void Can_Get_Next_Element_With_Options()
public void Can_Get_Next_Element_With_FocusedElement_Option()
{
using (UnitTestApplication.Start(TestServices.RealFocus))
{
var target1 = new Button { Focusable = true, Content = "1" };
var target2 = new Button { Focusable = true, Content = "2" };
var target3 = new Button { Focusable = true, Content = "3" };
var target4 = new Button { Focusable = true, Content = "4" };
var container = new StackPanel
{
Children =
{
target1,
target2,
target3,
target4
}
};
var root = new TestRoot
{
Child = container
};
var focusManager = FocusManager.GetFocusManager(container);
Assert.NotNull(focusManager);
Assert.Null(focusManager.GetFocusedElement());
var next = focusManager.FindNextElement(
NavigationDirection.Next,
new FindNextElementOptions { FocusedElement = target1 });
Assert.Equal(next, target2);
}
}
[Fact]
public void Can_Get_Directional_Next_Element_With_Options()
{
using (UnitTestApplication.Start(TestServices.RealFocus))
{
@ -870,6 +906,76 @@ namespace Avalonia.Base.UnitTests.Input
}
}
[Fact]
public void Can_Get_Directional_Next_Element_With_FocusedElement_Option()
{
using (UnitTestApplication.Start(TestServices.RealFocus))
{
var target1 = new Button { Focusable = true, Content = "1" };
var target2 = new Button { Focusable = true, Content = "2" };
var target3 = new Button { Focusable = true, Content = "3" };
var target4 = new Button { Focusable = true, Content = "4" };
var target5 = new Button { Focusable = true, Content = "5" };
var searchStack = new StackPanel()
{
Children =
{
target3,
target4
}
};
var container = new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
Children =
{
target1,
target2,
searchStack,
target5
}
};
var root = new TestRoot
{
Child = container
};
root.InvalidateMeasure();
root.ExecuteInitialLayoutPass();
var focusManager = FocusManager.GetFocusManager(container);
Assert.NotNull(focusManager);
Assert.Null(focusManager.GetFocusedElement());
// Search root is right of the specified focused element, should return the first focusable element in the search root
var next = focusManager.FindNextElement(NavigationDirection.Right, new FindNextElementOptions
{
SearchRoot = searchStack,
FocusedElement = target1
});
Assert.Equal(next, target3);
// Search root is left of the specified focused element, should return the first focusable element in the search root
next = focusManager.FindNextElement(NavigationDirection.Left, new FindNextElementOptions
{
SearchRoot = searchStack,
FocusedElement = target5
});
Assert.Equal(next, target3);
// Search root isn't to the right of the specified focused element, should return null
next = focusManager.FindNextElement(NavigationDirection.Right, new FindNextElementOptions
{
SearchRoot = searchStack,
FocusedElement = target5
});
Assert.Null(next);
}
}
[Fact]
public void Focus_Should_Move_According_To_Direction()
{

Loading…
Cancel
Save