是否可以在 WPF 中使用 ReactiveUI 绑定来仅通过 INotifyDataErrorInfo 验证用户输入?

Is it possible to use ReactiveUI bindings in WPF for validating user input with only INotifyDataErrorInfo?

我们在 .Net Core WPF 应用程序中使用 ReactiveUI.WPF 11.0.1。我们正在考虑将所有基于 XAML 的绑定替换为基于 ReactiveUI 的绑定。 域类型有一个 ViewModel 实现了 INotifyPropertyChanged 和 INotifyDataErrorInfo:

public class ItemViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
{
    private string Error => string.IsNullOrEmpty(Name) ? "Empty name" : string.Empty;
    private string _name;

    public string Name
    {
        get => _name;
        set
        {
            _name = value;
            OnPropertyChanged();
        }
    }

    public IEnumerable GetErrors(string propertyName)
    {
        if (string.IsNullOrEmpty(Error))
            return Enumerable.Empty<string>();
        return new[] {Error};
    }

    public bool HasErrors => !string.IsNullOrEmpty(Error);
    public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;

    public event PropertyChangedEventHandler PropertyChanged;

    private void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
    }
}

window 有一个 ViewModel:

public class MainWindowViewModel: ReactiveObject
{
    public ItemViewModel ItemA { get; } = new ItemViewModel();
    public ItemViewModel ItemB { get; } = new ItemViewModel();
}

还有一个主窗口:

<reactiveUi:ReactiveWindow
    x:TypeArguments="local:MainWindowViewModel"
    x:Class="WpfApp1.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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:local="clr-namespace:WpfApp1"
    xmlns:reactiveUi="http://reactiveui.net"
    mc:Ignorable="d">
    <StackPanel>
        <TextBox Text="{Binding ItemA.Name}" />
        <TextBox x:Name="ItemBTextBox" />
    </StackPanel>
</reactiveUi:ReactiveWindow>
public partial class MainWindow : ReactiveWindow<MainWindowViewModel>
{
    public MainWindow()
    {
        InitializeComponent();
        ViewModel = new MainWindowViewModel();
        DataContext = ViewModel;
        this.WhenActivated(disposables =>
        {
            this.Bind(ViewModel, x => x.ItemB.Name, x => x.ItemBTextBox.Text);
        });
    }
}

第一个 TextBox 在其文本 属性 为空时显示默认的 WPF ErrorTemplate(红色边框)。但是,第二个(使用基于 ReactiveUI 的绑定)没有。有没有一种方法可以在不更改 ItemViewModel class 的情况下自动使用 ReactiveUI 与 WPF 的 ErrorTemplates 的绑定?

所以,过了一段时间我再次尝试解决这个问题。 ReactiveUI 的绑定不支持 INotifyDataErrorInfo 验证。因此,我必须在绑定值后手动绑定验证错误。这可以像这样简单地完成:

public MainWindow() {
    // some initialization code should be here.

    this.WhenActivated(cleanUp => {
        // binding ItemB's Name property to ItemBTextBox's Text property.
        this.Bind(ViewModel, x => x.ItemB.Name, x => x.ItemBTextBox.Text)
            .DisposeWith(cleanUp);
        // binding ItemB's Name property's validation errrors to ItemBTextBox.
        ViewModel.ItemB.WhenAnyPropertyChanged()
            .StartWith(ViewModel.ItemB)
            .Subscribe(itemB =>
            {
                if (!itemB.HasErrors)
                {
                    ClearValidationErrors(ItemBTextBox);
                    return;
                }

                var errorForName = newEmployee
                    .GetErrors(nameof(newEmployee.Name))
                    .Cast<string>()
                    .FirstOrDefault();
                if (string.IsNullOrEmpty(nameError))
                {
                    ClearValidationErrors(ItemBTextBox);
                    return;
                }
                SetValidationError(ItemBTextBox, errorForName);
            })
            .DisposeWith(cleanUp);
    });
}

但是,下面的问题仍然存在:如何使WPF UI 元素(ItemBTextBox) 显示我们从代码隐藏设置的错误? ClearValidationErrors() 和 SetValidationError() 方法应该如何实现?我能找到的为 UI 元素设置验证错误的唯一方法(因此验证模板会显示它)是使用 WPF 绑定的以下代码:

Validation.ClearInvalid(ItemBTextBox.GetBindingExpression(TextBox.TextProperty));
Validation.MarkInvalid(
    ItemBTextBox.GetBindingExpression(TextBox.TextProperty),
    new ValidationError(new NotifyDataErrorValidationRule(), itemB, errorForName, null));

问题在于整个 WPF 验证机制都是基于 WPF 绑定的。 ReactiveUI 的绑定不依赖于那些。解决方法是创建虚拟 WPF 绑定并使用上面的代码从代码隐藏中清除和设置验证错误。

ItemBTextBox.SetBinding(TextBox.TextProperty, new Binding("Non_existent_property.") 
    { Mode = BindingMode.OneTime }); // invoke this in MainWindow constructor.

这种方法可行,但本质上很难看(我们必须使用虚拟 WPF 绑定才能使其工作,这些虚拟绑定显然会引发绑定错误等)。如果有人知道使用 WPF 的 ValidationTemplates 显示没有 WPF 绑定的 UI 元素的验证错误(可以从代码隐藏设置)的方法,请告诉我。

UPD: 所以我想出了其他方法来操纵 WPF 的 Validation.Errors 属性。它依赖于反射和验证 class 具有内部静态方法 AddValidationError() 和 RemoveValidationError() 的事实。所以我可以声明新的静态 class:

public static class ValidationHelper
{
    private static readonly MethodInfo AddValidationErrorMethod =
        typeof(Validation).GetMethod("AddValidationError", BindingFlags.NonPublic | BindingFlags.Static);

    private static readonly MethodInfo RemoveValidationErrorMethod =
        typeof(Validation).GetMethod("RemoveValidationError", BindingFlags.NonPublic | BindingFlags.Static);

    public static void AddValidationError(
        ValidationError validationError,
        DependencyObject targetElement)
    {
        AddValidationErrorMethod
            .Invoke(null, new object[] {validationError, targetElement, true});
    }

    public static void ClearValidationErrors(DependencyObject targetElement)
    {
        foreach (var error in Validation.GetErrors(targetElement).ToArray())
            RemoveValidationErrorMethod
                .Invoke(null, new object[] { error, targetElement, true });
    }
}

并像这样使用它:

ValidationHelper.ClearValidationErrors(ItemBTextBox);
ValidationHelper.AddValidationError(new ValidationError(new NotifyDataErrorValidationRule(), itemB, errorForName, null),
                            ItemBTextBox);

它远非完美,但它确实有效。而且您不需要使用任何虚拟 WPF 绑定。

UPD2: 这可能与最初的问题不太相关,但我还将添加我的天真的扩展方法,用于将 INotifyDataErrorInfo 错误绑定到 WPF 控件的 ValidationTemplate以防有相同问题的人需要参考。

// just a helper method to extract property name from the expression.
private static string GetPropertyName<T, TProperty>(this Expression<Func<T, TProperty>> property)
    where T : class
{
    if (!(property.Body is MemberExpression member))
        throw new ArgumentException("A method is provided instead of a property.");
    if (!(member.Member is PropertyInfo propertyInfo))
        throw new ArgumentException("A field is provided instead of a property");
    return propertyInfo.Name;
}

public static IDisposable BindValidationError
    <TView, TViewModel, TValidatableObject, TProperty>(
        this TView view,
        TViewModel viewModel,
        Expression<Func<TViewModel, TValidatableObject>> objectToValidateName,
        Expression<Func<TValidatableObject, TProperty>> propertyToValidate,
        Func<TView, DependencyObject> uiElementDelegate)
    where TViewModel : class
    where TView : IViewFor<TViewModel>
    where TValidatableObject : class, INotifyDataErrorInfo
{
    string lastError = null;
    var propertyToValidateName = propertyToValidate.GetPropertyName();
    return viewModel.WhenAnyValue(objectToValidateName)
        .StartWith(objectToValidateName.Compile().Invoke(viewModel))
        .Do(objectToValidate =>
        {
            var uiElement = uiElementDelegate.Invoke(view);
            if (objectToValidate == null)
            {
                ValidationHelper.ClearValidationErrors(uiElement);
                return;
            }

            ValidateProperty(
                objectToValidate,
                propertyToValidateName,
                uiElement,
                ref lastError);
        })
        .Select(objectToValidate => objectToValidate != null
            ? Observable.FromEventPattern<DataErrorsChangedEventArgs>(objectToValidate,
                nameof(objectToValidate.ErrorsChanged))
            : Observable.Empty<EventPattern<DataErrorsChangedEventArgs>>())
        .Switch()
        .Subscribe(eventArgs =>
        {
            if (eventArgs.EventArgs.PropertyName != propertyToValidateName)
                return;
            var objectToValidate = (INotifyDataErrorInfo) eventArgs.Sender;
            var uiElement = uiElementDelegate.Invoke(view);
            ValidateProperty(
                objectToValidate,
                propertyToValidateName,
                uiElement,
                ref lastError);
        });
}

在视图的 WhenActivated 中使用它:

this.Bind(
    ViewModel,
    viewModel => viewModel.ItemB.Name,
    view => view.ItemBTextBox.Text)
    .DisposeWith(cleanUp);
this.BindValidationError(
    ViewModel,
    viewModel => viewModel.ItemB,
    itemB => itemB.Name,
    view => view.NewEmployeeNameTextBox)
    .DisposeWith(cleanUp);