Giedrius Giedrius - 1 month ago 9
C# Question

Strange tab behavior in ListBox

I have a simple ListBox:

<Style TargetType="ListBoxItem">
<Setter Property="IsTabStop" Value="False" />
</Style>
<ListBox ItemsSource="{Binding Items}" HorizontalAlignment="Stretch" KeyboardNavigation.TabNavigation="Local">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<RadioButton />
<TextBlock Text="{Binding Name}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>


And when I'm tabbing through it, it has strange (or unwanted) behavior. I have 100 items and they all do not fit on screen, so there is ScrollViewer and VirtualizingStackPanel, and tabbing works ok till it reaches end of list and then it jumps 20 positions back, next time it jumps 21 positions back, next time 22 positions back.

Is there any way I could force it to jump to first item on the list once it reaches the end? I've tryed all possible KeyboardNavigation.TabNavigation values, didn't helped. Shift-Tab works the same way from first item jumps to 20th, next time 21st, etc.

If I disable virtualization with
VirtualizingStackPanel.IsVirtualizing="False"
, tabbing works as expected, but I can't allow it to be disabled, because some list are quite large.

Update:
I'm trying to handle it manually and it still works the same way:

private void ListBox_OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Tab)
{
return;
}

var focusedItem = FindParent<ListBoxItem>(Keyboard.FocusedElement as DependencyObject);

if (focusedItem != null && focusedItem.Content == ListBox.Items[ListBox.Items.Count - 1])
{
ListBox.MoveFocus(new TraversalRequest(FocusNavigationDirection.First));
e.Handled = true;
}
}


Also I've tried to find ScrollViewer within ListBox and scroll to top, and then focus first item, that works unreliably (looks like scrolling is happening asynchronously as sometimes it tabs to the middle of the list).

Answer

I've finally have a working solution, it is not as elegant as I would like it to be, but it looks like it works.

If someone has better/smaller working solution, please post an answer.

public class FixVirtualizedTabbingBehavior : Behavior<ListBox>
{
    protected override void OnAttached()
    {
        AssociatedObject.PreviewKeyDown += AssociatedObjectOnPreviewKeyDown;
        AssociatedObject.GotKeyboardFocus += AssociatedObjectGotKeyboardFocus;
        base.OnAttached();
    }

    void AssociatedObjectGotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
    {
        var listBox = ((ListBox)sender);

        if (e.OldFocus != null && ((DependencyObject)e.OldFocus).FindParent<ListBox>() != listBox)
        {
            var direction = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)
                ? FocusNavigationDirection.Last
                : FocusNavigationDirection.First;
            MoveFocus(listBox, direction);
        }
    }

    private void AssociatedObjectOnPreviewKeyDown(object sender, KeyEventArgs keyEventArgs)
    {
        if (keyEventArgs.Key != Key.Tab)
        {
            return;
        }

        var listBox = ((ListBox)sender);
        int index;
        FocusNavigationDirection direction;

        if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
        {
            index = 0;
            direction = FocusNavigationDirection.Previous;
        }
        else
        {
            index = listBox.Items.Count - 1;
            direction = FocusNavigationDirection.Previous;
        }

        var focusedItem = ((DependencyObject)Keyboard.FocusedElement).FindParent<ListBoxItem>();

        if (focusedItem == null || focusedItem.Content != listBox.Items[index])
        {
            return;
        }

        keyEventArgs.Handled = true;

        MoveFocus(listBox, direction);
    }

    private void MoveFocus(ListBox listBox, FocusNavigationDirection direction)
    {
        var scrollViewer = VisualTreeExtensions.FindVisualChildren<ScrollViewer>(listBox).First();

        if (direction == FocusNavigationDirection.First)
        {
            scrollViewer.ScrollToTop();
        }
        else
        {
            scrollViewer.ScrollToBottom();
        }

        Dispatcher.Invoke(new Action(() => { listBox.MoveFocus(new TraversalRequest(direction)); }),
            DispatcherPriority.ContextIdle, null);
    }

    protected override void OnDetaching()
    {
        AssociatedObject.PreviewKeyDown -= AssociatedObjectOnPreviewKeyDown;
        AssociatedObject.GotKeyboardFocus -= AssociatedObjectGotKeyboardFocus;          

        base.OnDetaching();
    }
}