在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);
}
}