在 DataGrid 最后一行的最后一列按 Tab 键应将焦点设置到新行的第一列

Pressing Tab Key in last column of last row in DataGrid should set focus to first column of new row

我有一个 DataGrid 可以编辑 ObservableCollectionIEditableObject 个对象。 DataGrid 设置为 CanUserAddRows="True" 以便出现用于添加新记录的空白行。一切都完美无缺,只有一个值得注意的例外。

所有包含数据的行的默认 tab 行为是在跳出当前行的最后一列时移动到下一行的第一列,这正是我想要的行为。但是,如果下一行是新行,这不是我得到的行为,该行将包含下一条新记录。该选项卡不会移动到新行中的第一列,而是将焦点移动到 DataGrid 中第一行的第一列。

我目前尝试将行为更改为我想要的样子:

private void ItemsDataGrid_RowEditEnding(object sender, DataGridRowEditEndingEventArgs e)
{
    if (ItemsDataGrid.SelectedIndex == ItemsDataGrid.Items.Count - 2)
    {
        DataGridRow row = ItemsDataGrid
            .ItemContainerGenerator.ContainerFromItem(CollectionView.NewItemPlaceholder) as DataGridRow;

        if (row.Focusable)
            row.Focus();

        DataGridCell cell = ItemsDataGrid.GetCell(row, 0);
        if (cell != null)
        {
            DataGridCellInfo dataGridCellInfo = new DataGridCellInfo(cell);
            if (cell.Focusable)
                cell.Focus();
        }
    }
}

这并没有将焦点设置到我想要的位置,即使 cell.SetFocus() 实际上被调用了。

我目前的工作理论是这样的:row.Focusable returns false,可能是因为行 "quite" 还不存在(我已经知道它不存在' t yet contain data at this point), 所以所需的单元格无法获得焦点,因为该行无法获得焦点。

有什么想法吗?


下面是我能收集到的最接近 MCVE 的内容。 WPF 相当冗长。请注意,我使用 Fody.PropertyChanged 作为我的 INotifyPropertyChanged 实现。

MainWindow.XAML

<Window
    x:Class="WpfApp2.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:local="clr-namespace:WpfApp2"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="MainWindow"
    Width="800"
    Height="450"
    mc:Ignorable="d">

    <Grid>
        <TabControl>
            <TabItem Header="List">
                <DataGrid                     
                    Name="ItemsDataGrid"
                    AutoGenerateColumns="False"
                    CanUserAddRows="True"
                    ItemsSource="{Binding EditableFilterableItems}"
                    KeyboardNavigation.TabNavigation="Cycle"
                    RowEditEnding="ItemsDataGrid_RowEditEnding"
                    RowHeaderWidth="20"
                    SelectedItem="{Binding SelectedItem}"
                    SelectionUnit="FullRow">

                    <DataGrid.Resources>
                        <!--  http://www.thomaslevesque.com/2011/03/21/wpf-how-to-bind-to-data-when-the-datacontext-is-not-inherited/  -->
                        <local:BindingProxy x:Key="proxy" Data="{Binding}" />
                    </DataGrid.Resources>

                    <DataGrid.Columns>
                        <DataGridTextColumn
                            x:Name="QuantityColumn"
                            Width="1*"
                            Binding="{Binding Quantity}"
                            Header="Quantity" />
                        <DataGridComboBoxColumn
                            x:Name="AssetColumn"
                            Width="3*"
                            DisplayMemberPath="Description"
                            Header="Item"
                            ItemsSource="{Binding Data.ItemDescriptions, Source={StaticResource proxy}}"
                            SelectedValueBinding="{Binding ItemDescriptionID}"
                            SelectedValuePath="ItemDescriptionID" />
                        <DataGridTextColumn
                            x:Name="NotesColumn"
                            Width="7*"
                            Binding="{Binding Notes}"
                            Header="Notes" />
                    </DataGrid.Columns>
                </DataGrid>
            </TabItem>
        </TabControl>
    </Grid>

</Window>

MainWindow.xaml.CS

using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;

namespace WpfApp2
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        MainWindowViewModel _viewModel;
        public MainWindow()
        {
            _viewModel = new MainWindowViewModel();
            DataContext = _viewModel;
            InitializeComponent();
        }


        private void ItemsDataGrid_RowEditEnding(object sender, DataGridRowEditEndingEventArgs e)
        {   
            if (ItemsDataGrid.SelectedIndex == ItemsDataGrid.Items.Count - 2)
            {
                DataGridRow row = ItemsDataGrid
                    .ItemContainerGenerator.ContainerFromItem(CollectionView.NewItemPlaceholder) as DataGridRow;

                var rowIndex = row.GetIndex();

                if (row.Focusable)
                    row.Focus();

                DataGridCell cell = ItemsDataGrid.GetCell(row, 0);
                if (cell != null)
                {
                    DataGridCellInfo dataGridCellInfo = new DataGridCellInfo(cell);
                    if (cell.Focusable)
                        cell.Focus();
                }
            }
        }
    }
}

MainWindowViewModel.CS

using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows.Data;
using PropertyChanged;

namespace WpfApp2
{
    [AddINotifyPropertyChangedInterface]
    public class MainWindowViewModel
    {
        public MainWindowViewModel()
        {
            Items = new ObservableCollection<Item>(
                new List<Item>
                {
                    new Item {ItemDescriptionID=1, Quantity=1, Notes="Little Red Wagon"},
                    new Item {ItemDescriptionID=2, Quantity=1, Notes="I Want a Pony"},
                }
            );
            FilterableItems = CollectionViewSource.GetDefaultView(Items);
            EditableFilterableItems = FilterableItems as IEditableCollectionView;
        }
        public ObservableCollection<Item> Items { get; set; }
        public ICollectionView FilterableItems { get; set; }
        public IEditableCollectionView EditableFilterableItems { get; set; }

        public Item SelectedItem { get; set; }

        public List<ItemDescription> ItemDescriptions => new List<ItemDescription>
        {
            new ItemDescription { ItemDescriptionID = 1, Description="Wagon" },
            new ItemDescription { ItemDescriptionID = 2, Description="Pony" },
            new ItemDescription { ItemDescriptionID = 3, Description="Train" },
            new ItemDescription { ItemDescriptionID = 4, Description="Dump Truck" },
        };
    }
}

Item.CS,ItemDescription.CS

public class Item : EditableObject<Item>
{
    public int Quantity { get; set; }
    public int ItemDescriptionID { get; set; }
    public string Notes { get; set; }
}

public class ItemDescription
{
    public int ItemDescriptionID { get; set; }
    public string Description { get; set; }
}

BindingProxy.CS

using System.Windows;

namespace WpfApp2
{
    /// <summary>
    /// http://www.thomaslevesque.com/2011/03/21/wpf-how-to-bind-to-data-when-the-datacontext-is-not-inherited/
    /// </summary>
    public class BindingProxy : Freezable
    {
        protected override Freezable CreateInstanceCore()
        {
            return new BindingProxy();
        }

        public object Data
        {
            get { return GetValue(DataProperty); }
            set { SetValue(DataProperty, value); }
        }

        // Using a DependencyProperty as the backing store for Data.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty DataProperty =
            DependencyProperty.Register("Data", typeof(object), typeof(BindingProxy), new UIPropertyMetadata(null));
    }
}

DataGridHelper.CS

using System;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;

namespace WpfApp2
{
    public static class DataGridHelper
    {
        public static T GetVisualChild<T>(Visual parent) where T : Visual
        {
            T child = default(T);
            int numVisuals = VisualTreeHelper.GetChildrenCount(parent);
            for (int i = 0; i < numVisuals; i++)
            {
                Visual v = (Visual)VisualTreeHelper.GetChild(parent, i);
                child = v as T;
                if (child == null)
                {
                    child = GetVisualChild<T>(v);
                }
                if (child != null)
                {
                    break;
                }
            }
            return child;
        }
        public static DataGridCell GetCell(this DataGrid grid, DataGridRow row, int column)
        {
            if (row != null)
            {
                DataGridCellsPresenter presenter = GetVisualChild<DataGridCellsPresenter>(row);

                if (presenter == null)
                {
                    grid.ScrollIntoView(row, grid.Columns[column]);
                    presenter = GetVisualChild<DataGridCellsPresenter>(row);
                }

                DataGridCell cell = (DataGridCell)presenter.ItemContainerGenerator.ContainerFromIndex(column);
                return cell;
            }
            return null;
        }
        public static DataGridCell GetCell(this DataGrid grid, int row, int column)
        {
            DataGridRow rowContainer = grid.GetRow(row);
            return grid.GetCell(rowContainer, column);
        }
    }
}

EditableObject.CS

using System;
using System.ComponentModel;

namespace WpfApp2
{
    public abstract class EditableObject<T> : IEditableObject
    {
        private T Cache { get; set; }

        private object CurrentModel
        {
            get { return this; }
        }

        public RelayCommand CancelEditCommand
        {
            get { return new RelayCommand(CancelEdit); }
        }

        #region IEditableObject Members
        public void BeginEdit()
        {
            Cache = Activator.CreateInstance<T>();

            //Set Properties of Cache
            foreach (var info in CurrentModel.GetType().GetProperties())
            {
                if (!info.CanRead || !info.CanWrite) continue;
                var oldValue = info.GetValue(CurrentModel, null);
                Cache.GetType().GetProperty(info.Name).SetValue(Cache, oldValue, null);
            }
        }

        public virtual void EndEdit()
        {
            Cache = default(T);
        }


        public void CancelEdit()
        {
            foreach (var info in CurrentModel.GetType().GetProperties())
            {
                if (!info.CanRead || !info.CanWrite) continue;
                var oldValue = info.GetValue(Cache, null);
                CurrentModel.GetType().GetProperty(info.Name).SetValue(CurrentModel, oldValue, null);
            }
        }
        #endregion
    }
}

RelayCommand.CS

using System;
using System.Windows.Input;

namespace WpfApp2
{
    /// <summary>
    /// A command whose sole purpose is to relay its functionality to other objects by invoking delegates. 
    /// The default return value for the CanExecute method is 'true'.
    /// <see cref="RaiseCanExecuteChanged"/> needs to be called whenever
    /// <see cref="CanExecute"/> is expected to return a different value.
    /// </summary>
    public class RelayCommand : ICommand
    {
        #region Private members
        /// <summary>
        /// Creates a new command that can always execute.
        /// </summary>
        private readonly Action execute;

        /// <summary>
        /// True if command is executing, false otherwise
        /// </summary>
        private readonly Func<bool> canExecute;
        #endregion

        /// <summary>
        /// Initializes a new instance of <see cref="RelayCommand"/> that can always execute.
        /// </summary>
        /// <param name="execute">The execution logic.</param>
        public RelayCommand(Action execute) : this(execute, canExecute: null) { }

        /// <summary>
        /// Initializes a new instance of <see cref="RelayCommand"/>.
        /// </summary>
        /// <param name="execute">The execution logic.</param>
        /// <param name="canExecute">The execution status logic.</param>
        public RelayCommand(Action execute, Func<bool> canExecute)
        {
            this.execute = execute ?? throw new ArgumentNullException("execute");
            this.canExecute = canExecute;
        }

        /// <summary>
        /// Raised when RaiseCanExecuteChanged is called.
        /// </summary>
        public event EventHandler CanExecuteChanged;

        /// <summary>
        /// Determines whether this <see cref="RelayCommand"/> can execute in its current state.
        /// </summary>
        /// <param name="parameter">
        /// Data used by the command. If the command does not require data to be passed, this object can be set to null.
        /// </param>
        /// <returns>True if this command can be executed; otherwise, false.</returns>
        public bool CanExecute(object parameter) => canExecute == null ? true : canExecute();

        /// <summary>
        /// Executes the <see cref="RelayCommand"/> on the current command target.
        /// </summary>
        /// <param name="parameter">
        /// Data used by the command. If the command does not require data to be passed, this object can be set to null.
        /// </param>
        public void Execute(object parameter)
        {
            execute();
        }

        /// <summary>
        /// Method used to raise the <see cref="CanExecuteChanged"/> event
        /// to indicate that the return value of the <see cref="CanExecute"/>
        /// method has changed.
        /// </summary>
        public void RaiseCanExecuteChanged()
        {
            CanExecuteChanged?.Invoke(this, EventArgs.Empty);
        }
    }
}

你见过这里描述的方法吗: https://peplowdown.wordpress.com/2012/07/19/wpf-datagrid-moves-input-focus-and-selection-to-the-wrong-cell-when-pressing-tab/

根据我的经验,一旦您开始更改行编辑和制表符等的行为,您就会发现一个接一个的边缘案例。 祝你好运。

这是完整的解决方案。

using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interactivity;

namespace MyNamespace
{
    /// <summary>
    /// Creates the correct behavior when tabbing out of a new row in a DataGrid.
    /// https://peplowdown.wordpress.com/2012/07/19/wpf-datagrid-moves-input-focus-and-selection-to-the-wrong-cell-when-pressing-tab/
    /// </summary><remarks>
    /// You’d expect that when you hit tab in the last cell the WPF data grid it would create a new row and put your focus in the first cell of that row. 
    /// It doesn’t; depending on how you have KeboardNavigation.TabNavigation set it’ll jump off somewhere you don’t expect, like the next control 
    /// or back to the first item in the grid.  This behavior class solves that problem.
    /// </remarks>
    public class NewLineOnTabBehavior : Behavior<DataGrid>
    {
        private bool _monitorForTab;

        protected override void OnAttached()
        {
            base.OnAttached();
            AssociatedObject.BeginningEdit += _EditStarting;
            AssociatedObject.CellEditEnding += _CellEnitEnding;
            AssociatedObject.PreviewKeyDown += _KeyDown;
        }

        private void _EditStarting(object sender, DataGridBeginningEditEventArgs e)
        {
            if (e.Column.DisplayIndex == AssociatedObject.Columns.Count - 1)
                _monitorForTab = true;
        }

        private void _CellEnitEnding(object sender, DataGridCellEditEndingEventArgs e)
        {
            _monitorForTab = false;
        }

        private void _KeyDown(object sender, KeyEventArgs e)
        {
            if (_monitorForTab && e.Key == Key.Tab)
            {
                AssociatedObject.CommitEdit(DataGridEditingUnit.Row, false);
            }
        }

        protected override void OnDetaching()
        {
            base.OnDetaching();
            AssociatedObject.BeginningEdit -= _EditStarting;
            AssociatedObject.CellEditEnding -= _CellEnitEnding;
            AssociatedObject.PreviewKeyDown -= _KeyDown;
            _monitorForTab = false;
        }
    }
}

并且在 XAML 中用于 DataGrid:

<i:Interaction.Behaviors>
    <local:NewLineOnTabBehavior />
</i:Interaction.Behaviors>

将以下命名空间添加到顶级 XAML 属性:

xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:local="clr-namespace:MyNamespace"

这个解决方案不能很好地使用通常的验证技术,所以我使用 RowValidator 来验证每一行。

using System.Windows.Controls;
using System.Windows.Data;
using System.Globalization;

namespace MyNamespace
{

    public class RowValidationRule : ValidationRule
    {
        public override ValidationResult Validate(object value, CultureInfo cultureInfo)
        {
            T_Asset item = (value as BindingGroup).Items[0] as T_Asset;
            item.ValidateModel();

            if (!item.HasErrors) return ValidationResult.ValidResult;

            return new ValidationResult(false, item.ErrorString);
        }
    }
}

T_Asset 实现了 INotifyDataErrorInfo 接口。

然后在 DataGrid 的 XAML 中:

<DataGrid.RowValidationRules>
    <local:RowValidationRule ValidationStep="CommittedValue" />
</DataGrid.RowValidationRules>