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

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

@ -172,7 +172,7 @@ namespace Avalonia.Input
ValidateDirection(direction); ValidateDirection(direction);
var focusOptions = ToFocusOptions(options, true); var focusOptions = ToFocusOptions(options, true);
var result = FindAndSetNextFocus(direction, focusOptions); var result = FindAndSetNextFocus(options?.FocusedElement ?? Current, direction, focusOptions);
_reusableFocusOptions = focusOptions; _reusableFocusOptions = focusOptions;
return result; return result;
} }
@ -319,7 +319,7 @@ namespace Avalonia.Input
ValidateDirection(direction); ValidateDirection(direction);
var focusOptions = ToFocusOptions(options, false); var focusOptions = ToFocusOptions(options, false);
var result = FindNextFocus(direction, focusOptions); var result = FindNextFocus(options?.FocusedElement ?? Current, direction, focusOptions);
_reusableFocusOptions = focusOptions; _reusableFocusOptions = focusOptions;
return result; return result;
} }
@ -368,27 +368,29 @@ namespace Avalonia.Input
return focusOptions; 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 || focusedElement == null)
if (direction is NavigationDirection.Previous or NavigationDirection.Next || currentlyFocusedElement == null)
{ {
var isReverse = direction == NavigationDirection.Previous; var isReverse = direction == NavigationDirection.Previous;
nextFocusedElement = ProcessTabStopInternal(isReverse, true); nextFocusedElement = ProcessTabStopInternal(focusedElement, isReverse, true);
} }
else else
{ {
if (currentlyFocusedElement is InputElement inputElement && if (focusedElement is InputElement inputElement &&
XYFocus.GetBoundsForRanking(inputElement, focusOptions.IgnoreClipping) is { } bounds) XYFocus.GetBoundsForRanking(inputElement, focusOptions.IgnoreClipping) is { } bounds)
{ {
focusOptions.FocusedElementBounds = bounds; focusOptions.FocusedElementBounds = bounds;
} }
nextFocusedElement = _xyFocus.GetNextFocusableElement(direction, nextFocusedElement = _xyFocus.GetNextFocusableElement(direction,
currentlyFocusedElement as InputElement, focusedElement as InputElement,
null, null,
updateManifolds, updateManifolds,
focusOptions); focusOptions);
@ -509,14 +511,14 @@ namespace Avalonia.Input
return lastFocus; return lastFocus;
} }
private IInputElement? ProcessTabStopInternal(bool isReverse, bool queryOnly) private IInputElement? ProcessTabStopInternal(IInputElement? focusedElement, bool isReverse, bool queryOnly)
{ {
IInputElement? newTabStop = null; 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, var isTabStopOverriden = InputElement.ProcessTabStop(_contentRoot,
Current, focusedElement,
defaultCandidateTabStop, defaultCandidateTabStop,
isReverse, isReverse,
didCycleFocusAtRootVisualScope, didCycleFocusAtRootVisualScope,
@ -535,24 +537,27 @@ namespace Avalonia.Input
return newTabStop; 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; didCycleFocusAtRootVisualScope = false;
var currentFocus = Current; IInputElement? newTabStop;
IInputElement? newTabStop = null; var root = _contentRoot;
var root = this._contentRoot as IInputElement;
if (root == null) if (root == null)
return null; return null;
bool internalCycleWorkaround = false; bool internalCycleWorkaround = false;
if (Current != null) if (focusedElement != null)
{ {
internalCycleWorkaround = CanProcessTabStop(isReverse); internalCycleWorkaround = CanProcessTabStop(focusedElement, isReverse);
} }
if (currentFocus == null) if (focusedElement == null)
{ {
if (!isReverse) if (!isReverse)
{ {
@ -567,7 +572,7 @@ namespace Avalonia.Input
} }
else if (!isReverse) else if (!isReverse)
{ {
newTabStop = GetNextTabStop(); newTabStop = GetNextTabStop(focusedElement);
if (newTabStop == null && (internalCycleWorkaround || queryOnly)) if (newTabStop == null && (internalCycleWorkaround || queryOnly))
{ {
@ -578,7 +583,7 @@ namespace Avalonia.Input
} }
else else
{ {
newTabStop = GetPreviousTabStop(); newTabStop = GetPreviousTabStop(focusedElement);
if (newTabStop == null && (internalCycleWorkaround || queryOnly)) if (newTabStop == null && (internalCycleWorkaround || queryOnly))
{ {
@ -590,9 +595,9 @@ namespace Avalonia.Input
return newTabStop; 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) if (focused == null || _contentRoot == null)
{ {
return null; return null;
@ -698,9 +703,9 @@ namespace Avalonia.Input
return newTabStop; 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) if (focused == null || _contentRoot == null)
{ {
return null; return null;
@ -930,23 +935,23 @@ namespace Avalonia.Input
return int.MaxValue; return int.MaxValue;
} }
private bool CanProcessTabStop(bool isReverse) private bool CanProcessTabStop(IInputElement? focusedElement, bool isReverse)
{ {
bool isFocusOnFirst = false; bool isFocusOnFirst = false;
bool isFocusOnLast = false; bool isFocusOnLast = false;
bool canProcessTab = true; bool canProcessTab = true;
if (IsFocusedElementInPopup()) if (IsFocusedElementInPopup(focusedElement))
{ {
return true; return true;
} }
if (isReverse) if (isReverse)
{ {
isFocusOnFirst = IsFocusOnFirstTabStop(); isFocusOnFirst = IsFocusOnFirstTabStop(focusedElement);
} }
else else
{ {
isFocusOnLast = IsFocusOnLastTabStop(); isFocusOnLast = IsFocusOnLastTabStop(focusedElement);
} }
if (isFocusOnFirst || isFocusOnLast) if (isFocusOnFirst || isFocusOnLast)
@ -961,7 +966,7 @@ namespace Avalonia.Input
if (edge != null) if (edge != null)
{ {
var edgeParent = GetParentTabStopElement(edge); 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; canProcessTab = false;
} }
@ -975,13 +980,13 @@ namespace Avalonia.Input
{ {
if (isFocusOnLast || isFocusOnFirst) 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; canProcessTab = true;
} }
else else
{ {
var focusedParent = GetParentTabStopElement(Current); var focusedParent = GetParentTabStopElement(focusedElement);
while (focusedParent != null) while (focusedParent != null)
{ {
if (focusedParent is InputElement iE && KeyboardNavigation.GetTabNavigation(iE) == KeyboardNavigationMode.Cycle) if (focusedParent is InputElement iE && KeyboardNavigation.GetTabNavigation(iE) == KeyboardNavigationMode.Cycle)
@ -1041,9 +1046,9 @@ namespace Avalonia.Input
return null; 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; return false;
var root = visual.VisualRoot as IInputElement; var root = visual.VisualRoot as IInputElement;
@ -1051,12 +1056,12 @@ namespace Avalonia.Input
var lastFocus = GetLastFocusableElement(root, null); 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; return false;
var root = visual.VisualRoot as IInputElement; var root = visual.VisualRoot as IInputElement;
@ -1064,7 +1069,7 @@ namespace Avalonia.Input
var firstFocus = GetFirstFocusableElement(root, null); var firstFocus = GetFirstFocusableElement(root, null);
return firstFocus == Current; return firstFocus == focusedElement;
} }
private static IInputElement? GetFirstFocusableElement(IInputElement searchStart, IInputElement? firstFocus = null) private static IInputElement? GetFirstFocusableElement(IInputElement searchStart, IInputElement? firstFocus = null)
@ -1091,7 +1096,7 @@ namespace Avalonia.Input
return lastFocus; 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) private Visual? GetRootOfPopupSubTree(IInputElement? current)
{ {
@ -1099,7 +1104,7 @@ namespace Avalonia.Input
return null; return null;
} }
private bool FindAndSetNextFocus(NavigationDirection direction, XYFocusOptions xYFocusOptions) private bool FindAndSetNextFocus(IInputElement? focusedElement, NavigationDirection direction, XYFocusOptions xYFocusOptions)
{ {
var focusChanged = false; var focusChanged = false;
if (xYFocusOptions.UpdateManifoldsFromFocusHintRect && xYFocusOptions.FocusHintRectangle != null) if (xYFocusOptions.UpdateManifoldsFromFocusHintRect && xYFocusOptions.FocusHintRectangle != null)
@ -1107,7 +1112,7 @@ namespace Avalonia.Input
_xyFocus.SetManifoldsFromBounds(xYFocusOptions.FocusHintRectangle ?? default); _xyFocus.SetManifoldsFromBounds(xYFocusOptions.FocusHintRectangle ?? default);
} }
if (FindNextFocus(direction, xYFocusOptions, false) is { } nextFocusedElement) if (FindNextFocus(focusedElement, direction, xYFocusOptions, false) is { } nextFocusedElement)
{ {
focusChanged = nextFocusedElement.Focus(); 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.Left"/>, <see cref="NavigationDirection.Right"/>,
/// <see cref="NavigationDirection.Up"/> and <see cref="NavigationDirection.Down"/>. /// <see cref="NavigationDirection.Up"/> and <see cref="NavigationDirection.Down"/>.
/// </param> /// </param>
/// <param name="options"> /// <param name="options">The options to help identify the next element to receive focus.</param>
/// The options to help identify the next element to receive focus.
/// They only apply to directional navigation.
/// </param>
/// <returns>true if focus moved; otherwise, false.</returns> /// <returns>true if focus moved; otherwise, false.</returns>
bool TryMoveFocus(NavigationDirection direction, FindNextElementOptions? options = null); 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.Left"/>, <see cref="NavigationDirection.Right"/>,
/// <see cref="NavigationDirection.Up"/> and <see cref="NavigationDirection.Down"/>. /// <see cref="NavigationDirection.Up"/> and <see cref="NavigationDirection.Down"/>.
/// </param> /// </param>
/// <param name="options"> /// <param name="options">The options to help identify the next element to receive focus.</param>
/// The options to help identify the next element to receive focus.
/// They only apply to directional navigation.
/// </param>
/// <returns>The next element to receive focus, if any.</returns> /// <returns>The next element to receive focus, if any.</returns>
IInputElement? FindNextElement(NavigationDirection direction, FindNextElementOptions? options = null); 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] [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)) 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] [Fact]
public void Focus_Should_Move_According_To_Direction() public void Focus_Should_Move_According_To_Direction()
{ {

Loading…
Cancel
Save