MVVM模式下的用户数据验证

MVVM(Model-View-ViewModel)模式中,ViewModel通常负责数据的验证。通常,当用户修改数据时,绑定属性的setter会被触发,从而执行验证。然而,如果用户没有修改数据,而是直接点击了按钮,那么绑定属性的setter就不会被执行,这可能会导致验证逻辑失效。例如,如果有一个表单要求用户输入名字,并且名字不能为空,用户直接点击"下一步"按钮而不输入任何内容,那么验证就不会被执行。

为了解决这个问题,可以在用户点击"下一步"按钮时,在ViewModel中重复执行验证。这样做的问题是如何以一种一致的方式向用户显示错误消息(例如,显示在ValidationSummary中,红色工具提示等)。

解决方案

提出的解决方案基于Josh Twist的解决方案(参见他的文章:)。他的解决方案的前提是允许ViewModel指示View刷新其绑定。这告诉View设置任何绑定的属性,这允许验证代码运行,即使用户没有输入数据。采用了Josh的代码,并简化了它,通过将其与验证框架解耦,并消除了向参与验证范围的每个元素添加附加属性的需要。

解决方案围绕一个附加行为,称之为RefreshBindingScope。

public class RefreshBindingScope { private static readonly Dictionary BoundProperties = new Dictionary { { typeof(TextBox), TextBox.TextProperty }, { typeof(ItemsControl), ItemsControl.ItemsSourceProperty }, { typeof(ComboBox), ItemsControl.ItemsSourceProperty }, { typeof(DataGrid), DataGrid.ItemsSourceProperty}, { typeof(AutoCompleteBox), AutoCompleteBox.TextProperty}, { typeof(DatePicker), DatePicker.SelectedDateProperty}, { typeof(ListBox), ItemsControl.ItemsSourceProperty }, { typeof(PasswordBox), PasswordBox.PasswordProperty }, }; public FrameworkElement ScopeElement { get; private set; } public static RefreshBindingScope GetScope(DependencyObject obj) { return (RefreshBindingScope)obj.GetValue(ScopeProperty); } public static void SetScope(DependencyObject obj, RefreshBindingScope value) { obj.SetValue(ScopeProperty, value); } public static readonly DependencyProperty ScopeProperty = DependencyProperty.RegisterAttached("Scope", typeof(RefreshBindingScope), typeof(RefreshBindingScope), new PropertyMetadata(null, ScopeChanged)); private static void ScopeChanged(DependencyObject source, DependencyPropertyChangedEventArgs args) { // clear old scope var oldScope = args.OldValue as RefreshBindingScope; if (oldScope != null) { oldScope.ScopeElement = null; } // assign new scope var scopeElement = source as FrameworkElement; if (scopeElement == null) { throw new ArgumentException(string.Format("'{0}' is not a valid type. Scope attached property can only be specified on types inheriting from FrameworkElement.", source)); } var newScope = (RefreshBindingScope)args.NewValue; newScope.ScopeElement = scopeElement; } public void Scope() { RefreshBinding(ScopeElement); } private static void RefreshBinding(DependencyObject dependencyObject) { Debug.WriteLine(dependencyObject.GetType()); // stop if we've reached a validation summary var validationSummary = dependencyObject as ValidationSummary; if (validationSummary != null) return; // don't do buttons - should be nothing to validate var button = dependencyObject as Button; if (button != null) return; // don't do hyperlink buttons - should be nothing to validate var hyperLinkButton = dependencyObject as HyperlinkButton; if (hyperLinkButton != null) return; foreach (var item in dependencyObject.GetChildren()) { var found = false; // get bound property (use list from BindingHelper, so we don't repeat it in this class) DependencyProperty boundProperty; if (BoundProperties.TryGetValue(item.GetType(), out boundProperty)) { // get BindingExpression and, if exists, force it to refresh var be = ((FrameworkElement)item).GetBindingExpression(boundProperty); if (be != null) be.UpdateSource(); // binding refreshed, so don't look for children found = true; Debug.WriteLine(string.Format("{0} binding refreshed ({1}).", item, item.GetValue(boundProperty))); } // get children recursively if bound property has not already been found if (!found) { RefreshBinding(item); } } } }

BoundProperties是一个可以刷新的控件列表。可以根据需要更改此列表。这个列表消除了向XAML中的每个控件添加opt-in属性的需要。

Scope是一个依赖属性。Scope做的最重要的事情是调用RefreshBindings方法,传入它绑定的UI元素。RefreshBindings方法接受UI元素并遍历视觉树,寻找与BoundProperties列表中匹配的任何控件。当找到一个时,它检查控件是否有绑定表达式,如果有,它执行绑定表达式的UpdateSource方法。这刷新了重新绑定。为了减少任何性能损失,这种视觉树遍历在某些元素(如ValidationSummary)上停止,这些元素通常不会参与验证。一旦找到绑定表达式,它也会停止寻找子元素。

下一步是通过在XAML中附加行为来定义想要刷新的UI元素的范围。RefreshBindingScope可以附加到任何UI元素(例如,包含TextBoxes的Grid)。

<Grid helpers:RefreshBindingScope.Scope="{Binding RefreshBindingScope}"> </Grid>

上面的XAML假设有一个名为helpers的命名空间,它指向RefreshBindingScope命名空间。

xmlns:helpers="clr-namespace:RefreshBindingExample.Helpers"

Scope依赖属性绑定到ViewModel中的一个RefreshBindingScope实例(使用了接口,这样如果使用依赖注入就可以注入)。

public IRefreshBindingScope RefreshBindingScope { get; set; }

当用户执行期望验证数据的命令(例如,点击按钮)时,ViewModel中的RefreshBindingScope可以用来请求View刷新绑定,通过执行Scope方法。

RefreshBindingScope.Scope();

如上所示,这在范围内的元素上执行UpdateSource方法。如果使用IDataErrorInfo或在属性setter中引发异常,刷新绑定会告诉View显示红色边框、错误工具提示以及由于刷新而发生的属性setter中的任何错误。还可以根据实现检查ViewModel中是否存在错误。

在附加的示例中,使用了一个简单的IDataErrorInfo实现,并有一个HasErrors()方法,可以查询以查看命令是否应该继续。

public void OnSave(object parameter) { ClearErrors(); RefreshBindingScope.Scope(); if (!HasErrors()) { // do the Save } }

遍历视觉树

public static class VisualTreeExtensions { public static IEnumerable GetChildren(this DependencyObject depObject) { int count = depObject.GetChildrenCount(); for (int i = 0; i < count; i++) { yield return VisualTreeHelper.GetChild(depObject, i); } } public static DependencyObject GetChild(this DependencyObject depObject, int childIndex) { return VisualTreeHelper.GetChild(depObject, childIndex); } public static DependencyObject GetChild(this DependencyObject depObject, string name) { return depObject.GetChild(name, false); } public static DependencyObject GetChild(this DependencyObject depObject, string name, bool recursive) { foreach (var child in depObject.GetChildren()) { var element = child as FrameworkElement; if (element != null) { // if it's a FrameworkElement check Name if (element.Name == name) return element; // try to get it using FindByName var innerElement = element.FindName(name) as DependencyObject; if (innerElement != null) return innerElement; } // if it's recursive search through its children if (recursive) { var innerChild = child.GetChild(name, true); if (innerChild != null) return innerChild; } } return null; } public static int GetChildrenCount(this DependencyObject depObject) { return VisualTreeHelper.GetChildrenCount(depObject); } public static DependencyObject GetParent(this DependencyObject depObject) { return VisualTreeHelper.GetParent(depObject); } }
沪ICP备2024098111号-1
上海秋旦网络科技中心:上海市奉贤区金大公路8218号1幢 联系电话:17898875485