如何绑定到动态资源以便使用转换器或字符串格式化等功能?(修订版4)

22
注意:这是早期设计的修订版,早期版本的局限是无法在样式中使用,这使其效果大打折扣。然而,这个新版本现在可以与样式一起使用,实质上让你可以在任何可以使用绑定或动态资源的地方获得预期的结果,使其变得非常有用。技术上说,这不是一个问题。这是一篇介绍我发现如何轻松使用转换器作为源的动态资源的文章。但为了遵循S/O的最佳实践,我将其发布为一个问题/答案对。请查看我的下面的答案,了解我发现如何做到这一点的方法。希望能对你有所帮助!

2
很酷,但我认为更好的做法是先提问然后自己回答。 - B. Clay Shannon-B. Crow Raven
已按要求完成。 - Mark A. Donohoe
1个回答

18

在WPF中绑定DynamicResource

简而言之

警告!这是一篇非常长的文章!

我写这篇文章的目的是带领那些有兴趣深入了解使用DynamicResource(或任何MarkupExtension)时发生了什么,为什么这可能一开始看起来无法解决,以及我证明它可以的创造性方法,最终导致了下面呈现的工作解决方案。

话虽如此,如果你只对该解决方案感兴趣,而不想阅读所有的废话,请随意向下滚动到标题“DynamicResourceBinding”,你可以从那里获取相关代码。

问题

在 WPF 中,我始终感觉缺少一些功能:可以将 DynamicResource 用作 Binding 的源。从技术角度来看,我明白这不可能......这在 Microsoft 的“DynamicResource 标记扩展”Remarks 部分中有清晰的解释documentation。其中它说明了...

DynamicResource 将在初始编译期间创建临时表达式,从而推迟对资源的查找,直到构造对象实际需要请求资源值为止。

这就是为什么你不能将其绑定的原因。它不是一个对象。它甚至不是属性设置的内容!它是一个MarkupExtension,在其初始编译期间使用给定的资源键预配置了一个Microsoft内部的ResourceReferenceExpression,然后通过其ProvideValue方法返回该表达式,并将其传递给设置它的属性。然后,稍后当有人请求该属性的当前值时,该表达式运行,查找在VisualTree中指定位置的资源的当前值,并且该值由该属性返回。

换句话说,DynamicResource无法告诉您资源已更改。必须进行询问。

尽管如此,从概念上讲,作为可以在运行时动态更改的东西,它总是困扰着我,因为它应该能够通过转换器进行推送。

好吧,我终于找到了解决这个遗漏的方法... 这就是DynamicResourceBinding的用途!

嗯...但为什么呢?

乍一看,这似乎是不必要的。毕竟,为什么需要绑定到动态资源?这样做实际上解决了什么问题呢?

比如允许您做以下事情...

  • 根据用户偏好或存储在资源中的可访问性功能全局缩放字体大小,同时仍能利用相对字体大小调整界面,感谢MultiplyByConverter
  • 仅基于double定义应用程序范围内的边距,然后利用DoubleToThicknessConverter将其转换为厚度,但也允许您按需掩盖边缘,在布局中更新整个UI,通过更改应用程序资源中的单个值来更新整个UI
  • 在资源中定义单个基本ThemeColor,然后使用转换器使其变浅或变暗,甚至根据用途更改其不透明度,感谢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等)所需的步骤...

  1. 创建一个新的BindingProxy对象 (这仅是一个带有单个“Value”属性的Object类型的DependencyPropertyFreezable子类)
  2. 将其'Value'属性设置为您希望用作绑定源的DynamicResource
  3. BindingProxy添加到目标FrameworkElementResources集合中
  4. 在目标DependencyProperty BindingProxy对象的'Value'属性之间建立绑定关系。(由于BindingProxyFreezable,本身是DependencyObject的子类,因此现在是被允许的。)
  5. 在新绑定上指定转换器、字符串格式化程序、空值等

而这正是 DynamicResourceBinding 自动为您完成的!

注意:虽然它的名称为“DynamicResourceBinding”,但它实际上不是一个Binding子类。它是一个MarkupExtension,我在其中定义了相关的Binding属性,如ConverterConverterParameterConverterCulture等。然而,对于大多数语义意图和目的而言,它在功能上与Binding基本相同,因此被赋予了这个名称。(只是不要尝试将其传递给期望真正的Binding的东西!)
复杂性(也称为“有趣的挑战!”)
这种方法有一个特别的复杂性,让我陷入了困境,即如何始终获取目标FrameworkElement,以便将BindingProxy插入到其Resources集合中。当我直接在FrameworkElement上使用DynamicResourceBinding时,它运行良好,但在样式中使用它时就会出现问题。

当时我不知道原因,但后来我了解到这是因为MarkupExtension在定义它的地方提供其值,而不是最终使用它的地方。我一直以为MarkupExtension的目标始终是FrameworkElement,但在样式中使用它的情况下,目标实际上是Style本身!

由于使用了几个内部“辅助”绑定,我成功地克服了这个限制。具体方法在注释中有说明。

DynamicResourceBinding

详细信息请参见注释。

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

BindingProxy

这是上面提到的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
}

BindingTrigger

这个类是一个简单的“辅助类”,用于在没有访问其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; }
}

InlineMultiConverter

这使您可以在不必显式创建新类型的情况下,在代码中设置自定义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进行绑定!

总之,就是这样!我真的希望这能帮助其他开发人员,因为它确实简化了我们的控件模板,特别是在常见边框厚度等方面。

享受吧!


1
确实看起来更好。 - Clemens
2
我知道你是这里的C#/WPF/XAML高手之一,因为你经常回答我的问题。上面提到的问题是我过去几年一直试图解决的问题,我昨天终于想出了解决方法。目前唯一的负面影响是每次使用它时,都会创建一个BindingProxy对象,但我不确定这是否太大的负面影响,特别是考虑到所获得的收益。话虽如此,你能否花点时间查看代码,看看是否有任何问题或可以改进的地方,甚至是否有你自己的方法? - Mark A. Donohoe
2
在我看来,无法覆盖Binding类的sealed ProvideValue方法很麻烦。如果从Binding而不是MarkupExtension派生会大大简化它。除此之外,我还没有仔细研究过。我从来没有需要将Converter或StringFormat应用于DynamicResource。总是可以直接使用它们。另外奇怪的是,如果写{Binding Source={DynamicResource...}},XAML设计器能够忽略它,但在运行时会抛出异常。 - Clemens
1
我来给你举两个例子:第一个是,我定义了一些全局的边距和填充,通常会应用到四个方向。通过创建这个类的子类,不仅可以更容易地应用它们而无需记住资源的名称,还可以添加“边缘掩码”,以便在某些情况下关闭填充的某些边缘。另一个情况是,当我将颜色定义为资源并且需要为UI中的某些内容获取其“阴影”时。同样,使用包装MarkupExtension的Shading枚举,转换器就能轻松应用。 - Mark A. Donohoe
1
另一个是定义应用程序范围的字体大小,然后能够相对于它们在UI中定义字体。例如,在列表中我们有一个标题和一个副标题。通过将副标题设置为要用于标题的资源大小的百分比,任何对标题的更改都会流到副标题。这使得用户可以通过简单地更改基本大小来更改整个应用程序的字体大小 - 这是我们的用户在平板电脑上户外使用应用程序时请求的功能。像这样的东西真的有助于实时更改和用户自定义“外观”,而不会弄乱布局。 - Mark A. Donohoe
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接