警告!这是一篇非常长的文章!
我写这篇文章的目的是带领那些有兴趣深入了解使用DynamicResource
(或任何MarkupExtension
)时发生了什么,为什么这可能一开始看起来无法解决,以及我证明它可以的创造性方法,最终导致了下面呈现的工作解决方案。
话虽如此,如果你只对该解决方案感兴趣,而不想阅读所有的废话,请随意向下滚动到标题“DynamicResourceBinding”,你可以从那里获取相关代码。
在 WPF 中,我始终感觉缺少一些功能:可以将 DynamicResource
用作 Binding
的源。从技术角度来看,我明白这不可能......这在 Microsoft 的“DynamicResource 标记扩展”Remarks 部分中有清晰的解释documentation。其中它说明了...
DynamicResource
将在初始编译期间创建临时表达式,从而推迟对资源的查找,直到构造对象实际需要请求资源值为止。
这就是为什么你不能将其绑定的原因。它不是一个对象。它甚至不是属性设置的内容!它是一个MarkupExtension
,在其初始编译期间使用给定的资源键预配置了一个Microsoft内部的ResourceReferenceExpression
,然后通过其ProvideValue
方法返回该表达式,并将其传递给设置它的属性。然后,稍后当有人请求该属性的当前值时,该表达式运行,查找在VisualTree
中指定位置的资源的当前值,并且该值由该属性返回。
换句话说,DynamicResource
无法告诉您资源已更改。必须进行询问。
尽管如此,从概念上讲,作为可以在运行时动态更改的东西,它总是困扰着我,因为它应该能够通过转换器进行推送。
好吧,我终于找到了解决这个遗漏的方法... 这就是DynamicResourceBinding
的用途!
乍一看,这似乎是不必要的。毕竟,为什么需要绑定到动态资源?这样做实际上解决了什么问题呢?
比如允许您做以下事情...
MultiplyByConverter
double
定义应用程序范围内的边距,然后利用DoubleToThicknessConverter
将其转换为厚度,但也允许您按需掩盖边缘,在布局中更新整个UI,通过更改应用程序资源中的单个值来更新整个UIThemeColor
,然后使用转换器使其变浅或变暗,甚至根据用途更改其不透明度,感谢ColorShadingConverter
更好的是,如果您将这些内容包装在特定的自定义标记扩展中,您的XAML也会大大简化!下面展示了前两个用例的确切情况,它们都在我的'core.wpf'库中定义,我现在在所有WPF应用程序中都使用它们:
<!-- Have secondary text be 85% the size of whatever it would normally be at this location in the visual tree -->
<TextBlock Text="Some Primary Text" />
<TextBlock Text="Some secondary text useful for details"
Foreground="Gray"
FontSize="{cwpf:RelativeFontSize 0.85}" />
<!-- Use the app's standard margins, but suppress applying it to the top edge -->
<Border Margin="{cwpf:StandardMargin Mask=1011}" />
DynamicResourceBinding
能够发挥其魔力,归功于Freezable
对象的一个鲜为人知的功能。具体来说...
如上所述,这是仅适用于如果将Freezable对象添加到FrameworkElement的Resources集合中,则通过DynamicResource设置的Freezable对象上的任何依赖属性都将相对于Visual Tree中该FrameworkElement的位置解析其值。
Freezable
对象的特性。对于Resources
集合中的所有非Freezable
对象(包括其他FrameworkElement
实例!),任何设置DynamicResource
值将相对于应用程序范围而不是视觉树中的当前位置解析,这意味着在视觉树更高处对该资源进行任何更改基本上都将被忽略。
利用从Freezable
中获得的这一点“魔法酱料”,以下是绑定到DynamicResource
(因此您可以使用转换器、FallbackValue等)所需的步骤...
BindingProxy
对象 (这仅是一个带有单个“Value”属性的Object
类型的DependencyProperty
的Freezable
子类)DynamicResource
BindingProxy
添加到目标FrameworkElement
的Resources
集合中DependencyProperty
和BindingProxy
对象的'Value'属性之间建立绑定关系。(由于BindingProxy
是Freezable
,本身是DependencyObject
的子类,因此现在是被允许的。)而这正是 DynamicResourceBinding
自动为您完成的!
Binding
子类。它是一个MarkupExtension
,我在其中定义了相关的Binding
属性,如Converter
、ConverterParameter
、ConverterCulture
等。然而,对于大多数语义意图和目的而言,它在功能上与Binding
基本相同,因此被赋予了这个名称。(只是不要尝试将其传递给期望真正的Binding
的东西!)FrameworkElement
,以便将BindingProxy
插入到其Resources
集合中。当我直接在FrameworkElement
上使用DynamicResourceBinding
时,它运行良好,但在样式中使用它时就会出现问题。
当时我不知道原因,但后来我了解到这是因为MarkupExtension
在定义它的地方提供其值,而不是最终使用它的地方。我一直以为MarkupExtension
的目标始终是FrameworkElement,但在样式中使用它的情况下,目标实际上是Style
本身!
由于使用了几个内部“辅助”绑定,我成功地克服了这个限制。具体方法在注释中有说明。
详细信息请参见注释。
public class DynamicResourceBindingExtension : MarkupExtension {
public DynamicResourceBindingExtension(){}
public DynamicResourceBindingExtension(object resourceKey)
=> ResourceKey = resourceKey ?? throw new ArgumentNullException(nameof(resourceKey));
public object ResourceKey { get; set; }
public IValueConverter Converter { get; set; }
public object ConverterParameter { get; set; }
public CultureInfo ConverterCulture { get; set; }
public string StringFormat { get; set; }
public object TargetNullValue { get; set; }
private BindingProxy bindingProxy;
private BindingTrigger bindingTrigger;
public override object ProvideValue(IServiceProvider serviceProvider) {
// Create the BindingProxy for the requested dynamic resource
// This will be used as the source of the underlying binding
var dynamicResource = new DynamicResourceExtension(ResourceKey);
bindingProxy = new BindingProxy(dynamicResource.ProvideValue(null)); // Pass 'null' here
// Set up the actual, underlying binding specifying the just-created
// BindingProxy as its source. Note, we don't yet set the Converter,
// ConverterParameter, StringFormat or TargetNullValue (More on why not below)
var dynamicResourceBinding = new Binding() {
Source = bindingProxy,
Path = new PropertyPath(BindingProxy.ValueProperty),
Mode = BindingMode.OneWay
};
// Get the TargetInfo for this markup extension
var targetInfo = (IProvideValueTarget)serviceProvider.GetService(typeof(IProvideValueTarget));
// Check if the target object of this markup extension is a DependencyObject.
// If so, we can set up everything right now and we're done!
if(targetInfo.TargetObject is DependencyObject dependencyObject){
// Ok, since we're being applied directly on a DependencyObject, we can
// go ahead and set all the additional binding-related properties.
dynamicResourceBinding.Converter = Converter;
dynamicResourceBinding.ConverterParameter = ConverterParameter;
dynamicResourceBinding.ConverterCulture = ConverterCulture;
dynamicResourceBinding.StringFormat = StringFormat;
dynamicResourceBinding.TargetNullValue = TargetNullValue;
// If the DependencyObject is a FrameworkElement, then we also add the
// BindingProxy to its Resources collection to ensure proper resource lookup
// We use itself as it's key so we can check for it's existence later
if (dependencyObject is FrameworkElement targetFrameworkElement)
targetFrameworkElement.Resources[bindingProxy] = bindingProxy;
// And now we simply return the same value as the actual, underlying binding,
// making us mimic being a proper binding, hence the markup extension's name
return dynamicResourceBinding.ProvideValue(serviceProvider);
}
// Ok, we're not being set directly on a DependencyObject. Most likely we're being set via
// a style so we need to do some extra work to get the ultimate target of the binding.
//
// We do this by setting up a wrapper MultiBinding, where we add the above binding
// as well as a second child binding with a RelativeResource of 'Self'. During the
// Convert method, we use this to get the ultimate/actual binding target.
//
// Finally, since we have no way of getting the BindingExpression (as there will be a
// separate one for each case where this style is ultimately applied), we create a third
// binding whose only purpose is to manually re-trigger the execution of the 'WrapperConvert'
// method, allowing us to discover the ultimate target via the second child binding above.
// Binding used to find the target this markup extension is ultimately applied to
var findTargetBinding = new Binding(){
RelativeSource = new RelativeSource(RelativeSourceMode.Self)
};
// Binding used to manually 'retrigger' the WrapperConvert method. (See BindingTrigger's implementation)
bindingTrigger = new BindingTrigger();
// Wrapper binding to bring everything together
var wrapperBinding = new MultiBinding(){
Bindings = {
dynamicResourceBinding,
findTargetBinding,
bindingTrigger.Binding
},
Converter = new InlineMultiConverter(WrapperConvert)
};
// Just like above, we return the result of the wrapperBinding's ProvideValue
// call, again making us mimic the behavior of being an actual binding
return wrapperBinding.ProvideValue(serviceProvider);
}
// This gets called on every change of the dynamic resource, for every object this
// markup extension has been applied to, whether applied directly, or via a style
private object WrapperConvert(object[] values, Type targetType, object parameter, CultureInfo culture) {
var dynamicResourceBindingResult = values[0]; // This is the result of the DynamicResourceBinding**
var bindingTargetObject = values[1]; // This is the ultimate target of the binding
// ** Note: This value has not yet been passed through the converter, nor been coalesced
// against TargetNullValue, or, if applicable, formatted, all of which we have to do below.
// We can ignore the third value (i.e. 'values[2]') as that's the result of the bindingTrigger's
// binding, which will always be set to null (See BindingTrigger's implementation for more info)
// Again that binding only exists to re-trigger this WrapperConvert method explicitly when needed.
if (Converter != null)
// We pass in the TargetType we're handed in this method as that's the real binding target.
// Normally, child bindings would been handed 'object' since their target is the MultiBinding.
dynamicResourceBindingResult = Converter.Convert(dynamicResourceBindingResult, targetType, ConverterParameter, ConverterCulture);
// First, check the results for null. If so, set it equal to TargetNullValue and continue
if (dynamicResourceBindingResult == null)
dynamicResourceBindingResult = TargetNullValue;
// It's not null, so check both a) if the target type is a string, and b) that there's a
// StringFormat. If both are true, format the string accordingly.
//
// Note: You can't simply put those properties on the MultiBinding as it handles things
// differently than a regular Binding (e.g. StringFormat is always applied, even when null.)
else if (targetType == typeof(string) && StringFormat != null)
dynamicResourceBindingResult = String.Format(StringFormat, dynamicResourceBindingResult);
// If the binding target object is a FrameworkElement, ensure the binding proxy is added
// to its Resources collection so it will be part of the lookup relative to that element
if (bindingTargetObject is FrameworkElement targetFrameworkElement
&& !targetFrameworkElement.Resources.Contains(bindingProxy)) {
// Add the resource to the target object's Resources collection
targetFrameworkElement.Resources[bindingProxy] = bindingProxy;
// Since we just added the binding proxy to the visual tree, we have to re-evaluate it
// relative to where we now are. However, since there's no way to get a BindingExpression
// to manually refresh it from here, here's where the BindingTrigger created above comes
// into play. By manually forcing a change notification on it's Value property, it will
// retrigger the binding for us, achieving the same thing. However...
//
// Since we're presently executing in the WrapperConvert method from the current binding
// operation, we must retrigger that refresh to occur *after* this execution completes. We
// can do this by putting the refresh code in a closure passed to the 'Post' method on the
// current SynchronizationContext. This schedules that closure to run in the future, as part
// of the normal run-loop cycle. If we didn't schedule this in this way, the results will be
// returned out of order and the UI wouldn't update properly, overwriting the actual values.
// Refresh the binding, but not now, in the future
SynchronizationContext.Current.Post((state) => {
bindingTrigger.Refresh();
}, null);
}
// Return the now-properly-resolved result of the child binding
return dynamicResourceBindingResult;
}
}
这是上面提到的Freezable
,它允许DynamicResourceBinding
正常工作。
注意:这对于一些其他绑定代理相关模式也非常有帮助,例如在工具提示或下拉菜单中设置绑定,因此它被分离成自己的对象以供重用。在此处或Google上搜索
WPF BindingProxy
以获取有关其他用法的更多信息。它非常棒!
public class BindingProxy : Freezable {
public BindingProxy(){}
public BindingProxy(object value)
=> Value = value;
protected override Freezable CreateInstanceCore()
=> new BindingProxy();
#region Value Property
public static readonly DependencyProperty ValueProperty = DependencyProperty.Register(
nameof(Value),
typeof(object),
typeof(BindingProxy),
new FrameworkPropertyMetadata(default));
public object Value {
get => GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
#endregion Value Property
}
这个类是一个简单的“辅助类”,用于在没有访问其BindingExpression
的情况下手动强制刷新绑定。您可以将其作为MultiBinding
的子级与要刷新的实际绑定一起包装,然后通过调用此对象的“Value”属性上的PropertyChanged?.Invoke
触发该刷新。
注意:从技术上讲,您可以使用任何支持更改通知的类,包括您可能已经配置为
MultiBinding
的一部分的类,但我个人更喜欢我的设计明确地说明它们的用途,因此创建了一个专用的BindingTrigger
实例。
public class BindingTrigger : INotifyPropertyChanged {
public BindingTrigger()
=> Binding = new Binding(){
Source = this,
Path = new PropertyPath(nameof(Value))};
public event PropertyChangedEventHandler PropertyChanged;
public Binding Binding { get; }
public void Refresh()
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
public object Value { get; }
}
这使您可以在不必显式创建新类型的情况下,在代码中设置自定义MultiValueConverter
。它通过将相关的Convert
/``ConvertBack`方法指定为其代理属性来实现此操作。
注意:您可以创建表示标准值转换器的对应版本。只需给它一个新名称(例如
InlineConverter
),将接口更改为IValueConverter
,并相应地更新委托方法的签名即可。
public class InlineMultiConverter : IMultiValueConverter {
public delegate object ConvertDelegate (object[] values, Type targetType, object parameter, CultureInfo culture);
public delegate object[] ConvertBackDelegate(object value, Type[] targetTypes, object parameter, CultureInfo culture);
public InlineMultiConverter(ConvertDelegate convert, ConvertBackDelegate convertBack = null){
_convert = convert ?? throw new ArgumentNullException(nameof(convert));
_convertBack = convertBack;
}
private ConvertDelegate _convert { get; }
private ConvertBackDelegate _convertBack { get; }
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
=> _convert(values, targetType, parameter, culture);
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> (_convertBack != null)
? _convertBack(value, targetTypes, parameter, culture)
: throw new NotImplementedException();
}
就像使用常规绑定一样,这是如何使用它的(假设您已经定义了一个键为'MyResourceKey'的'double'资源)...
<TextBlock Text="{drb:DynamicResourceBinding ResourceKey=MyResourceKey, Converter={cv:MultiplyConverter Factor=4}, StringFormat='Four times the resource is {0}'}" />
更简短的是,你可以通过构造函数重载省略 'ResourceKey=',以匹配常规绑定中 'Path' 的工作方式...
<TextBlock Text="{drb:DynamicResourceBinding MyResourceKey, Converter={cv:MultiplyConverter Factor=4}, StringFormat='Four times the resource is {0}'}" />
所以,这就是它!使用完全支持转换器、字符串格式、空值处理等的DynamicResource
进行绑定!
总之,就是这样!我真的希望这能帮助其他开发人员,因为它确实简化了我们的控件模板,特别是在常见边框厚度等方面。
享受吧!
BindingProxy
对象,但我不确定这是否太大的负面影响,特别是考虑到所获得的收益。话虽如此,你能否花点时间查看代码,看看是否有任何问题或可以改进的地方,甚至是否有你自己的方法? - Mark A. Donohoe{Binding Source={DynamicResource...}}
,XAML设计器能够忽略它,但在运行时会抛出异常。 - ClemensShading
枚举,转换器就能轻松应用。 - Mark A. Donohoe