Как по нажатию кнопки Enter переключать фокус на следующий элемент в Avalonia MVVM

У меня есть динамические TextBox, они добавляются по нажатию кнопки. Мне необходимо по нажатию кнопки Enter внутри TextBox переключать фокус на следующий элемент, а если элемента не существует создавать его. Сейчас у меня при создании элемента фокус устанавливается на него.

View:

<Grid RowDefinitions="40,*">
        <Button Grid.Row="0" Content="New row" Command="{Binding AddNewRow}" />
        <ItemsControl Grid.Row="1" ItemsSource="{Binding ElementsList}">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Horizontal" Margin="5">
                        <TextBlock Text="{Binding Id}" Width="20" VerticalAlignment="Center"/>
                        <TextBox Text="{Binding Text}" Loaded="Control_OnLoaded"/>
                    </StackPanel>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>
</Grid>

Аналогия с двумя tb:

<Grid RowDefinitions="40,*">
        <Button Grid.Row="0" Content="New row" Command="{Binding AddNewRow}" />
        <ItemsControl Grid.Row="1" ItemsSource="{Binding ElementsList}">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Horizontal" Margin="5">
                        <TextBlock Text="{Binding Id}" Width="20" VerticalAlignment="Center"/>
                        <TextBox Text="{Binding Text}" Loaded="Control_OnLoaded"/>
                        <TextBox Text="{Binding Text}" Loaded="Control_OnLoaded"/>
                    </StackPanel>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>
</Grid>
```

View.CS:

    private void Control_OnLoaded(object? sender, RoutedEventArgs e)
    {
            var tb = sender as TextBox;
            tb.Focus();
    }
    

models:

    public class Elements: ViewModelBase
    {
        private int _id;
        private string _text;
    
        public int Id
        {
            get => _id;
            set => this.RaiseAndSetIfChanged(ref _id, value);
        }
    
        public string Text
        {
            get => _text;
            set => this.RaiseAndSetIfChanged(ref _text, value);
        }
    }
    

VM:

    public class MainWindowViewModel : ViewModelBase
    {
        public ReactiveCommand<Unit, Unit> AddNewRow { get; set; }
        private ObservableCollection<Elements>? _elementsList;
    
        public ObservableCollection<Elements>? ElementsList
        {
            get => _elementsList;
            set => this.RaiseAndSetIfChanged(ref _elementsList, value);
        }
        public MainWindowViewModel()
        {
            ElementsList = new ObservableCollection<Elements>(new List<Elements>());
            ElementsList.Add(new ObservableCollection<Elements>
            {
                new()
                {
                    Id = ElementsList.Count + 1, Text = ""
                }
            });
            
            AddNewRow = ReactiveCommand.Create(() =>
            {
                ElementsList.Add(new ObservableCollection<Elements>
                {
                    new()
                    {
                        Id = ElementsList.Count + 1, Text = ""
                    }
                });
            });
        }

Ответы (3 шт):

Автор решения: Cost

UPD: нашёл более простое решение https://ru.stackoverflow.com/a/1557250/248343

Как заставить добавлять новые TextBox по нажатию Enter:
Простейшим способом будет установить кнопке атрибут IsDefault = true:

<Button Grid.Row="0" Content="New row" Command="{Binding AddNewRow}"
        IsDefault="True"/>

В этом случае команда будет срабатывать всегда, даже если в фокусе будет не TextBox.

Чтобы этого избежать можно добавить к TextBox обработчик события нажатия клавиши, например KeyDown:

<TextBox Text="{Binding Text}"               
         Loaded="Control_OnLoaded" 
         KeyDown="Control_KeyDown"/>

axaml.cs:

private void Control_KeyDown(object? sender, KeyEventArgs e)
{
    if (e.Key == Key.Enter)
    {            
        var dc = DataContext as MainWindowViewModel;
        dc.AddNewRow.Execute();
    }
}

Как менять фокус на следующий элемент, если не надо добавлять новый:
Вот тут нормального решения не нашёл. Как по мне так не слой VM должен заниматься переключением фокуса на View. Но как сейчас в Авалонии правильно работать с фокусом не знаю.

А если через VM, то

  1. В Elements добавляем свойство Focused
  2. Изменяем команду AddNewRow так, чтобы она принимала параметр типа Elements
public ReactiveCommand<Elements, Unit> AddNewRow { get; set; }

...

AddNewRow = ReactiveCommand.Create<Elements>((el) =>
{
    if (el == ElementsList.Last())
    {
        ElementsList.Where(x => x.Focused).Single().Focused = false;
        ElementsList.Add(
            new()
            {
                Id = ElementsList.Count + 1,
                Text = "",
                Focused = true
            }
        );

    }
    else
    {
        ElementsList.Where(x => x.Focused).Single().Focused = false;
        var index = ElementsList.IndexOf(el);
        ElementsList[index + 1].Focused = true;
    }
});
  1. Подписываемся на событие изменения свойства Focused
private void Control_OnLoaded(object? sender, RoutedEventArgs e)
{
    var tb = sender as TextBox;
    var dc = tb.DataContext as Elements;

    dc.PropertyChanged += (s, e) =>
    {
        if (e.PropertyName == "Focused")
        {
            if (dc.Focused)
                tb.Focus();
        }
    };
    tb.Focus(); //установка фокуса для нового элемента
}
  1. Изменяем вызов команды при нажатии Enter в TextBox, чтобы передать какой конкретно TextBox был нажат
private void Control_KeyDown(object? sender, KeyEventArgs e)
{
    if (e.Key == Key.Enter)
    {
        var tb = sender as TextBox;
        var dc = DataContext as MainViewModel;
        dc.AddNewRow.Execute(tb.DataContext as Elements).Subscribe();
    }
}
→ Ссылка
Автор решения: Cost

Нашёл лучшее решение, в котором для переключения фокуса не используется слой VM, и которое работает с двумя TextBox-ами.

Для обработки нажатия клавиши Enter используем событие KeyDown. Плюс помечаем интересующие нас TextBox-ы, чтобы при переключении фокуса не переключиться на другие элементы, находящиеся на этой же форме (Используем для этого аттрибут Tag):

<TextBox Text="{Binding Text}" Loaded="Control_OnLoaded" 
  KeyDown="Control_KeyDown"              
  Tag="First"/>
<TextBox Text="{Binding Text1}" Loaded="Control_OnLoaded"
  KeyDown="Control_KeyDown"
  Tag="Second"/>

Если нужно переключаться змейкой:

Code-behind будет выглядеть следующим образом:

private void Control_OnLoaded(object? sender, RoutedEventArgs e)
{
    var tb = sender as TextBox;
    // поскольку разом добавляется два TextBox-а, то фокус нужно поставить на первый из них
    if (tb.Tag == "First")
    {
        tb.Focus();
    }
}

private void Control_KeyDown(object? sender, KeyEventArgs e)
{
    if (e.Key == Key.Enter)
    {
        var tb = sender as TextBox;
        if (IsLastTextBox(tb))  // если Enter нажат в последнем TextBox-е, то нужно создать новую строку
        {
            CreateNewRow(tb);
        }
        else // иначе фокус должен переключиться на уже существующий TextBox
        {
            SwitchFocusToNextRow(tb);
        }
    }
}

private bool IsLastTextBox(TextBox tb)
{
    var nextElement = KeyboardNavigationHandler.GetNext(tb, NavigationDirection.Next);
    if (IsOurTextbox(nextElement)) // если tb - последний созданный TextBox, то за ним будет следовать не "наш" элемент
    {
        return false;
    }
    return true;
}

// "нашим" является TextBox, помеченный либо First, либо Second
private bool IsOurTextbox(IInputElement element)
{
    if (element is TextBox nextTb)
    {
        if (nextTb.Tag == "First" || nextTb.Tag == "Second")
        {
            return true;
        }
    }
    return false;
}

private void SwitchFocusToNextRow(TextBox tb)
{
    var nextElement = KeyboardNavigationHandler.GetNext(tb, NavigationDirection.Next);

    nextElement.Focus();
}

private void CreateNewRow(TextBox? tb)
{
    var dc = DataContext as MainViewModel;
    dc.AddNewRow.Execute(tb.DataContext as Elements).Subscribe();
}

Если нужно переключаться независимо в каждом из столбцов:

Code-behind будет выглядеть следующим образом:

TextBox previousFocused;  // предыдущий элемент с фокусом
private void Control_OnLoaded(object? sender, RoutedEventArgs e)
{
    var tb = sender as TextBox;
    // поскольку разом добавляется два TextBox-а, то фокус нужно поставить не на тот,
    // который был последним, а на тот, которых находится в столбце,
    // в котором был нажат Enter
    if (previousFocused == null || tb.Tag == previousFocused.Tag)
    {
        tb.Focus();
        previousFocused = tb;
    }
    // нужно при переключении фокуса на уже созданный ранее элемент 
    tb.GotFocus += Tb_GotFocus; 
}

private void Tb_GotFocus(object? sender, GotFocusEventArgs e)
{
    previousFocused = sender as TextBox;
}


private void Control_KeyDown(object? sender, KeyEventArgs e)
{
    if (e.Key == Key.Enter)
    {
        var tb = sender as TextBox;
        // если Enter нажат в последнем ряду, то нужно создать новую строку      
        if (InTheLastRow(tb))  
        {
            CreateNewRow(tb);
        }
        else // иначе фокус должен переключиться на уже существующий TextBox
        {
            SwitchFocusToNextRow(tb);
        }
    }
}

private bool InTheLastRow(TextBox tb)
{
    var nextElement = KeyboardNavigationHandler.GetNext(tb, NavigationDirection.Next);
    // если tb - последний созданный TextBox, то за ним будет следовать не "наш" элемент
    if (IsOurTextbox(nextElement)) 
    {
        // если tb - предпоследний созданный TextBox, 
        // то за ним будет следовать не "наш" элемент
        nextElement = KeyboardNavigationHandler.GetNext(nextElement, 
                                           NavigationDirection.Next); 
        if (IsOurTextbox(nextElement))
        {
            // если два следующих элемента "наши", то мы не в последней строке
            return false;
        }
    }
    return true;
}

// "нашим" является TextBox, помеченный либо First, либо Second
private bool IsOurTextbox(IInputElement element)
{
    if (element is TextBox nextTb)
    {
        if (nextTb.Tag == "First" || nextTb.Tag == "Second")
        {
            return true;
        }
    }
    return false;
}

private void SwitchFocusToNextRow(TextBox tb)
{
    var nextElement = KeyboardNavigationHandler.GetNext(tb, NavigationDirection.Next);
    nextElement = KeyboardNavigationHandler.GetNext(nextElement, NavigationDirection.Next);

    nextElement.Focus();
    previousFocused = nextElement as TextBox;
}

private void CreateNewRow(TextBox? tb)
{
    var dc = DataContext as MainViewModel;
    dc.AddNewRow.Execute(tb.DataContext as Elements).Subscribe();
}

Таким образом, теги нам нужны при проверке, что мы не вышли за границы создаваемой таблицы, и для того, чтобы правильно назначить фокус при добавлении новой строки

→ Ссылка
Автор решения: Bob

При нажатии enter вызывается next(1); ну или если находитесь в группе радиокнопки то при нажатии стрелок вверх и вниз .Вероятно недоработано для DatePicker и может еще чего. В работе не тестировано ,но вдруг поможет .

private async void next(int napr, int keys = 0)
{
    string? gruname = "";
    IInputElement? a = FocusManager?.GetFocusedElement();
    if (a?.GetType() == typeof(RadioButton))
    { RadioButton rr = (RadioButton)a; if (!string.IsNullOrEmpty(rr.GroupName)) { gruname = rr.GroupName; } }
    if (napr == 1)
        await Dispatcher.UIThread.InvokeAsync(() => a = KeyboardNavigationHandler.GetNext(a, NavigationDirection.Next));
    else
        await Dispatcher.UIThread.InvokeAsync(() => a = KeyboardNavigationHandler.GetNext(a, NavigationDirection.Previous));
    await Dispatcher.UIThread.InvokeAsync(() => a?.Focus());
    if (a?.GetType() == typeof(RadioButton))
    {
        RadioButton r = (RadioButton)a;
        if (!string.IsNullOrEmpty(r.GroupName) && r.IsChecked == false)
        {
            while (0 == 0)
            {
                if (napr == 1)
                    await Dispatcher.UIThread.InvokeAsync(() => a = KeyboardNavigationHandler.GetNext(a, NavigationDirection.Next));
                else
                    await Dispatcher.UIThread.InvokeAsync(() => a = KeyboardNavigationHandler.GetNext(a, NavigationDirection.Previous));
                await Dispatcher.UIThread.InvokeAsync(() => a?.Focus());

                if (a?.GetType() != typeof(RadioButton))
                        break;
                RadioButton r1 = (RadioButton)a;
                if (!string.IsNullOrEmpty(gruname) && gruname != r1.GroupName)
                {
                    if (napr == 1)
                        await Dispatcher.UIThread.InvokeAsync(() => a = KeyboardNavigationHandler.GetNext(a, NavigationDirection.Previous));
                    else
                        await Dispatcher.UIThread.InvokeAsync(() => a = KeyboardNavigationHandler.GetNext(a, NavigationDirection.Next));
                    await Dispatcher.UIThread.InvokeAsync(() => a?.Focus());
                    return;
                }
                if (keys > 0) { break; };
                if (r1.IsChecked == true && keys == 0) { r = r1; break; }
            }
        }
        r.Focus();
    }
    else if (a?.GetType() == typeof(TextBox))
    {
        TextBox t = (TextBox)a;
        inputs = 0;
        t.SelectAll();
    }
    else if (a?.GetType() == typeof(CheckBox))
    {
        CheckBox t = (CheckBox)a;
        t.BorderBrush = Avalonia.Media.Brush.Parse("Black");
        t.BorderThickness = Avalonia.Thickness.Parse("0.5");
    }
}
→ Ссылка