From 16cd48a941a4fcbcaf1967e79dd95fe3208bbe83 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Sat, 10 Aug 2024 08:23:34 -0700 Subject: [PATCH] Feature Add Wpf Validation Binding (#3874) **What kind of change does this PR introduce?** feature **What is the current behavior?** No code bindings exist supporting Validation **What is the new behavior?** Added Wpf Bind which uses ReactiveUI ReactiveProperty with validation as a validation source **What might this PR break?** New Feature **Please check if the PR fulfills these requirements** - [ ] Tests for the changes have been added (for bug fixes / features) - [ ] Docs have been added / updated (for bug fixes / features) **Other information**: Example ```c# public partial class MainWindow : ReactiveWindow { public MainWindow() { InitializeComponent(); ViewModel = new MainWindowViewModel(); this.WhenActivated(cleanup => { this.BindWithValidation( ViewModel, vm => vm.NoSymbolsTextProperty.Value, view => view.NoSymbolsEntry.Text, .DisposeWith(cleanup); }); } } using System.ComponentModel.DataAnnotations; using ReactiveUI; public class MainWindowViewModel : ReactiveObject { [RegularExpression(@"^[^!@#$%^&*()]*$", ErrorMessage = "Symbols not allowed!")] public ReactiveProperty NoSymbolsTextProperty { get; } public MainWindowViewModel() { NoSymbolsTextProperty = new ReactiveProperty().AddValidation(() => NoSymbolsTextProperty); } } ``` --- .../winforms/DefaultPropertyBindingTests.cs | 6 +- ...piApprovalTests.Wpf.DotNet6_0.verified.txt | 8 +- ...piApprovalTests.Wpf.DotNet8_0.verified.txt | 8 +- ...pfApiApprovalTests.Wpf.Net4_7.verified.txt | 8 +- .../Binding/ValidationBindingMixins.cs | 48 +++++ .../Binding/ValidationBindingWpf.cs | 167 ++++++++++++++++++ 6 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 src/ReactiveUI.Wpf/Binding/ValidationBindingMixins.cs create mode 100644 src/ReactiveUI.Wpf/Binding/ValidationBindingWpf.cs diff --git a/src/ReactiveUI.Tests/Platforms/winforms/DefaultPropertyBindingTests.cs b/src/ReactiveUI.Tests/Platforms/winforms/DefaultPropertyBindingTests.cs index 21c63d146..daaf99b28 100644 --- a/src/ReactiveUI.Tests/Platforms/winforms/DefaultPropertyBindingTests.cs +++ b/src/ReactiveUI.Tests/Platforms/winforms/DefaultPropertyBindingTests.cs @@ -134,14 +134,14 @@ public class DefaultPropertyBindingTests var vm = new FakeWinformViewModel(); var view = new FakeWinformsView { ViewModel = vm }; - var disp = new CompositeDisposable(new[] - { + var disp = new CompositeDisposable( + [ view.Bind(vm, x => x.Property1, x => x.Property1.Text), view.Bind(vm, x => x.Property2, x => x.Property2.Text), view.Bind(vm, x => x.Property3, x => x.Property3.Text), view.Bind(vm, x => x.Property4, x => x.Property4.Text), view.Bind(vm, x => x.BooleanProperty, x => x.BooleanProperty.Checked), - }); + ]); vm.Property1 = "FOOO"; Assert.Equal(vm.Property1, view.Property1.Text); diff --git a/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet6_0.verified.txt b/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet6_0.verified.txt index bd8354359..1163a0a1e 100644 --- a/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet6_0.verified.txt +++ b/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet6_0.verified.txt @@ -116,6 +116,12 @@ namespace ReactiveUI Bounce = 4, } } + public static class ValidationBindingMixins + { + public static ReactiveUI.IReactiveBinding BindWithValidation(this TView view, TViewModel viewModel, System.Linq.Expressions.Expression> viewModelPropertySelector, System.Linq.Expressions.Expression> frameworkElementSelector) + where TViewModel : class + where TView : class, ReactiveUI.IViewFor { } + } public class ViewModelViewHost : ReactiveUI.TransitioningContentControl, ReactiveUI.IActivatableView, ReactiveUI.IViewFor, Splat.IEnableLogger { public static readonly System.Windows.DependencyProperty ContractFallbackByPassProperty; @@ -139,4 +145,4 @@ namespace ReactiveUI.Wpf public Registrations() { } public void Register(System.Action, System.Type> registerFunction) { } } -} +} \ No newline at end of file diff --git a/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet8_0.verified.txt b/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet8_0.verified.txt index 2889a484e..54868b4e3 100644 --- a/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet8_0.verified.txt +++ b/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet8_0.verified.txt @@ -116,6 +116,12 @@ namespace ReactiveUI Bounce = 4, } } + public static class ValidationBindingMixins + { + public static ReactiveUI.IReactiveBinding BindWithValidation(this TView view, TViewModel viewModel, System.Linq.Expressions.Expression> viewModelPropertySelector, System.Linq.Expressions.Expression> frameworkElementSelector) + where TViewModel : class + where TView : class, ReactiveUI.IViewFor { } + } public class ViewModelViewHost : ReactiveUI.TransitioningContentControl, ReactiveUI.IActivatableView, ReactiveUI.IViewFor, Splat.IEnableLogger { public static readonly System.Windows.DependencyProperty ContractFallbackByPassProperty; @@ -139,4 +145,4 @@ namespace ReactiveUI.Wpf public Registrations() { } public void Register(System.Action, System.Type> registerFunction) { } } -} +} \ No newline at end of file diff --git a/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.Net4_7.verified.txt b/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.Net4_7.verified.txt index 5a06cd1f3..5907b295b 100644 --- a/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.Net4_7.verified.txt +++ b/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.Net4_7.verified.txt @@ -114,6 +114,12 @@ namespace ReactiveUI Bounce = 4, } } + public static class ValidationBindingMixins + { + public static ReactiveUI.IReactiveBinding BindWithValidation(this TView view, TViewModel viewModel, System.Linq.Expressions.Expression> viewModelPropertySelector, System.Linq.Expressions.Expression> frameworkElementSelector) + where TViewModel : class + where TView : class, ReactiveUI.IViewFor { } + } public class ViewModelViewHost : ReactiveUI.TransitioningContentControl, ReactiveUI.IActivatableView, ReactiveUI.IViewFor, Splat.IEnableLogger { public static readonly System.Windows.DependencyProperty ContractFallbackByPassProperty; @@ -137,4 +143,4 @@ namespace ReactiveUI.Wpf public Registrations() { } public void Register(System.Action, System.Type> registerFunction) { } } -} +} \ No newline at end of file diff --git a/src/ReactiveUI.Wpf/Binding/ValidationBindingMixins.cs b/src/ReactiveUI.Wpf/Binding/ValidationBindingMixins.cs new file mode 100644 index 000000000..bdee981dc --- /dev/null +++ b/src/ReactiveUI.Wpf/Binding/ValidationBindingMixins.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2024 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Linq.Expressions; +using System.Windows; +using ReactiveUI.Wpf.Binding; + +namespace ReactiveUI; + +/// +/// ValidationBindingMixins. +/// +public static class ValidationBindingMixins +{ + /// + /// Binds the validation. + /// + /// The type of the view model. + /// The type of the view. + /// The type of the v property. + /// The type of the type. + /// The view. + /// The view model. + /// The view model property selector. + /// The framework element selector. + /// + /// An instance of that, when disposed, + /// disconnects the binding. + /// + public static IReactiveBinding BindWithValidation(this TView view, TViewModel viewModel, Expression> viewModelPropertySelector, Expression> frameworkElementSelector) + where TView : class, IViewFor + where TViewModel : class + { + if (viewModelPropertySelector == null) + { + throw new ArgumentNullException(nameof(viewModelPropertySelector)); + } + + if (frameworkElementSelector == null) + { + throw new ArgumentNullException(nameof(frameworkElementSelector)); + } + + return new ValidationBindingWpf(view, viewModel, viewModelPropertySelector, frameworkElementSelector); + } +} diff --git a/src/ReactiveUI.Wpf/Binding/ValidationBindingWpf.cs b/src/ReactiveUI.Wpf/Binding/ValidationBindingWpf.cs new file mode 100644 index 000000000..97989bd1d --- /dev/null +++ b/src/ReactiveUI.Wpf/Binding/ValidationBindingWpf.cs @@ -0,0 +1,167 @@ +// Copyright (c) 2024 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Linq.Expressions; +using System.Text; +using System.Windows; +using System.Windows.Data; +using System.Windows.Markup.Primitives; +using System.Windows.Media; +using DynamicData; + +namespace ReactiveUI.Wpf.Binding; + +internal class ValidationBindingWpf : IReactiveBinding + where TView : class, IViewFor + where TViewModel : class +{ + private const string DotValue = "."; + private readonly FrameworkElement _control; + private readonly DependencyProperty? _dpPropertyName; + private readonly TViewModel _viewModel; + private readonly string? _vmPropertyName; + private IDisposable? _inner; + + public ValidationBindingWpf( + TView view, + TViewModel viewModel, + Expression> vmProperty, + Expression> viewProperty) + { + // Get the ViewModel details + _viewModel = viewModel; + ViewModelExpression = Reflection.Rewrite(vmProperty.Body); + var vmet = ViewModelExpression.GetExpressionChain(); + var vmFullName = vmet.Select(x => x.GetMemberInfo()?.Name).Aggregate(new StringBuilder(), (sb, x) => sb.Append(x).Append('.')).ToString(); + if (vmFullName.EndsWith(DotValue)) + { + vmFullName = vmFullName.Substring(0, vmFullName.Length - 1); + } + + _vmPropertyName = vmFullName; + + // Get the View details + View = view; + ViewExpression = Reflection.Rewrite(viewProperty.Body); + var vet = ViewExpression.GetExpressionChain().ToArray(); + var controlName = string.Empty; + var index = vet.IndexOf(vet.Last()!); + if (vet != null && index > 0) + { + controlName = vet[vet.IndexOf(vet.Last()!) - 1]!.GetMemberInfo()?.Name + ?? throw new ArgumentException($"Control name not found on {typeof(TView).Name}"); + } + + _control = FindControlsByName(view as DependencyObject, controlName).FirstOrDefault()!; + var controlDpPropertyName = vet?.Last().GetMemberInfo()?.Name; + _dpPropertyName = GetDependencyProperty(_control, controlDpPropertyName) ?? throw new ArgumentException($"Dependency property not found on {typeof(TVProp).Name}"); + + var somethingChanged = Reflection.ViewModelWhenAnyValue(viewModel, view, ViewModelExpression).Select(tvm => (TVMProp?)tvm).Merge( + view.WhenAnyDynamic(ViewExpression, x => (TVProp?)x.Value).Select(p => default(TVMProp))); + Changed = somethingChanged; + Direction = BindingDirection.TwoWay; + Bind(); + } + + public System.Linq.Expressions.Expression ViewModelExpression { get; } + + public TView View { get; } + + public System.Linq.Expressions.Expression ViewExpression { get; } + + public IObservable Changed { get; } + + public BindingDirection Direction { get; } + + public IDisposable Bind() + { + _control.SetBinding(_dpPropertyName, new System.Windows.Data.Binding() + { + Source = _viewModel, + Path = new(_vmPropertyName), + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged, + }); + + _inner = Disposable.Create(() => BindingOperations.ClearBinding(_control, _dpPropertyName)); + + return _inner; + } + + public void Dispose() + { + _inner?.Dispose(); + GC.SuppressFinalize(this); + } + + private static IEnumerable EnumerateDependencyProperties(object element) + { + if (element != null) + { + var markupObject = MarkupWriter.GetMarkupObjectFor(element); + if (markupObject != null) + { + foreach (var mp in markupObject.Properties) + { + if (mp.DependencyProperty != null) + { + yield return mp.DependencyProperty; + } + } + } + } + } + + private static IEnumerable EnumerateAttachedProperties(object element) + { + if (element != null) + { + var markupObject = MarkupWriter.GetMarkupObjectFor(element); + if (markupObject != null) + { + foreach (var mp in markupObject.Properties) + { + if (mp.IsAttached) + { + yield return mp.DependencyProperty; + } + } + } + } + } + + private static DependencyProperty? GetDependencyProperty(object element, string? name) => + EnumerateDependencyProperties(element).Concat(EnumerateAttachedProperties(element)).FirstOrDefault(x => x.Name == name); + + private static IEnumerable FindControlsByName(DependencyObject? parent, string? name) + { + if (parent == null) + { + yield break; + } + + if (name == null) + { + yield break; + } + + var childCount = VisualTreeHelper.GetChildrenCount(parent); + + for (var i = 0; i < childCount; i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + + if (child is FrameworkElement element && element.Name == name) + { + yield return element; + } + + foreach (var descendant in FindControlsByName(child, name)) + { + yield return descendant; + } + } + } +}