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

K. *_*cov 5 validation wpf fluentvalidation reactiveui

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

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));
    }
}
Run Code Online (Sandbox Code Playgroud)

有一个用于窗口的 ViewModel:

public class MainWindowViewModel: ReactiveObject
{
    public ItemViewModel ItemA { get; } = new ItemViewModel();
    public ItemViewModel ItemB { get; } = new ItemViewModel();
}
Run Code Online (Sandbox Code Playgroud)

还有一个主窗口:

<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>
Run Code Online (Sandbox Code Playgroud)
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);
        });
    }
}
Run Code Online (Sandbox Code Playgroud)

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

K. *_*cov 6

所以,过了一段时间我再次尝试解决这个问题。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);
    });
}
Run Code Online (Sandbox Code Playgroud)

但是,仍然存在以下问题:如何使 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));
Run Code Online (Sandbox Code Playgroud)

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

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

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

UPD:所以我找到了操作 WPF 的 Validation.Errors 属性的其他方法。它依赖于反射以及 Validation 类具有内部静态方法 AddValidationError() 和 RemoveValidationError() 的事实。所以我可以声明新的静态类:

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 });
    }
}
Run Code Online (Sandbox Code Playgroud)

并像这样使用它:

ValidationHelper.ClearValidationErrors(ItemBTextBox);
ValidationHelper.AddValidationError(new ValidationError(new NotifyDataErrorValidationRule(), itemB, errorForName, null),
                            ItemBTextBox);
Run Code Online (Sandbox Code Playgroud)

它远非完美,但它确实有效。并且您不需要使用任何虚拟 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);
        });
}

Run Code Online (Sandbox Code Playgroud)

在视图的 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);
Run Code Online (Sandbox Code Playgroud)