如何绑定到 DynamicResource 以便可以使用 Converter 或 StringFormat 等? (修订版 4)

How can you bind to a DynamicResource so you can use a Converter or StringFormat, etc.? (Revision 4)

Note: This is a revision of an earlier design that had the limitation of not being usable in a style, negating its effectiveness quite a bit. However, this new version now works with styles, essentially letting you use it anywhere you can use a binding or a dynamic resource and get the expected results, making it immensely more useful.

从技术上讲,这不是问题。 post 展示了一种我发现可以轻松使用以 DynamicResource 作为源的转换器的方法,但为了遵循 s/o 的最佳实践,我 post将其作为 question/answer 对。因此,请查看下面我找到如何执行此操作的方法的答案。希望对您有所帮助!

我一直觉得 WPF 中缺少一些功能:使用动态资源作为绑定源的能力。我从技术上理解这是为什么——为了检测变化,绑定源必须是 DependencyObject 上的 属性 或支持 INotifyPropertyChanged 的 object ,而动态资源实际上是 Microsoft-internal ResourceReferenceExpression,它等于资源的 (即它不是 object 和 属性 绑定到,更不用说带有更改通知的了)——但是,它总是困扰我,因为在 run-time 期间可以更改的东西应该能够根据需要通过转换器推送。

嗯,我相信我终于纠正了这个限制...

输入DynamicResourceBinding!

注意:我称它为 'Binding',但从技术上讲它是 MarkupExtension,我在其上定义了 ConverterConverterParameter 等属性, ConverterCulture, 等等,但它最终在内部使用了一个绑定(实际上是几个!)因此,我根据它的用法而不是它的实际类型来命名它。

但是为什么呢?

那你为什么还要这样做呢?如何根据用户偏好全局缩放字体大小,同时由于 MultiplyByConverter 仍然能够使用相对字体大小?或者如何通过使用 DoubleToThicknessConverter 仅基于 double 资源来定义 app-wide 边距,它不仅可以将其转换为厚度,还可以让您在布局中根据需要屏蔽边缘?或者在资源中定义一个基础 ThemeColor,然后使用转换器使它变亮或变暗,或者根据 ColorShadingConverter?

的使用情况更改其不透明度如何?

更好的是,将上述实现为 MarkupExtensions 并且您的 XAML 也得到了简化!

<!-- Make the font size 85% of what it would normally be here -->
<TextBlock FontSize="{res:FontSize Scale=0.85)" />

<!-- Use the common margin, but suppress the top edge -->
<Border Margin="{res:Margin Mask=1011)" />

简而言之,这有助于整合您的主要资源中的所有 'base values',但能够在使用它们的时间和地点调整它们,而不必向它们塞入 'x' 数量的变体在您的资源中 collection.

魔术酱

DynamicResourceBinding 的实现要归功于 Freezable 数据类型的巧妙技巧。具体...

If you add a Freezable to the Resources collection of a FrameworkElement, any dependency properties on that Freezable object which are set as dynamic resources will resolve those resources relative to that FrameworkElement's position in the Visual Tree.

使用 'magic sauce' 的那个位,诀窍是在代理 Freezable object 的 DependencyProperty 上设置 DynamicResource,添加 Freezable 到目标 FrameworkElement 的资源 collection,然后在两者之间建立绑定,现在允许这样做,因为源现在是 DependencyObject(即 Freezable.)

复杂性在于在 Style 中使用它时获得目标 FrameworkElement,因为 MarkupExtension 在定义它的地方提供它的值,而不是在最终应用它的结果的地方。这意味着当您直接在 FrameworkElement 上使用 MarkupExtension 时,它的目标是您期望的 FrameworkElement。但是,当您在样式中使用 MarkupExtension 时,Style object 是 MarkupExtension 的目标,而不是应用它的 FrameworkElement 的目标。由于使用了第二个内部绑定,我也设法绕过了这个限制。

就是说,这是内联注释的解决方案:

动态资源绑定

'Magic Sauce!' 阅读内联评论以了解正在发生的事情

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   bindingSource;
    private BindingTrigger bindingTrigger;

    public override object ProvideValue(IServiceProvider serviceProvider) {

        // Get the binding source for all targets affected by this MarkupExtension
        // whether set directly on an element or object, or when applied via a style
        var dynamicResource = new DynamicResourceExtension(ResourceKey);
        bindingSource = new BindingProxy(dynamicResource.ProvideValue(null)); // Pass 'null' here

        // Set up the binding using the just-created source
        // Note, we don't yet set the Converter, ConverterParameter, StringFormat
        // or TargetNullValue (More on that below)
        var dynamicResourceBinding = new Binding() {
            Source = bindingSource,
            Path   = new PropertyPath(BindingProxy.ValueProperty),
            Mode   = BindingMode.OneWay
        };

        // Get the TargetInfo for this markup extension
        var targetInfo = (IProvideValueTarget)serviceProvider.GetService(typeof(IProvideValueTarget));

        // Check if this is a DependencyObject. If so, we can set up everything right here.
        if(targetInfo.TargetObject is DependencyObject dependencyObject){

            // Ok, since we're being applied directly on a DependencyObject, we can
            // go ahead and set all those missing properties on the binding now.
            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
            // bindingSource to its Resources collection to ensure proper resource lookup
            if (dependencyObject is FrameworkElement targetFrameworkElement)
                targetFrameworkElement.Resources.Add(bindingSource, bindingSource);

            // And now we simply return the same value as if we were a true binding ourselves
            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 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 binding which we create using a RelativeResource of 'Self' to get the target,
        // and finally, since we have no way of getting the BindingExpressions (as there will be one wherever
        // the style is applied), we create a third child binding which is a convenience object on which we
        // trigger a change notification, thus refreshing the binding.
        var findTargetBinding = new Binding(){
            RelativeSource = new RelativeSource(RelativeSourceMode.Self)
        };

        bindingTrigger = new BindingTrigger();

        var wrapperBinding = new MultiBinding(){
            Bindings = {
                dynamicResourceBinding,
                findTargetBinding,
                bindingTrigger.Binding
            },
            Converter = new InlineMultiConverter(WrapperConvert)
        };

        return wrapperBinding.ProvideValue(serviceProvider);
    }

    // This gets called on every change of the dynamic resource, for every object it's been applied to
    // either when 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]; // The ultimate target of the binding
        // We can ignore the bogus third value (in 'values[2]') as that's the dummy result
        // of the BindingTrigger's value which will always be 'null'

        // ** Note: This value has not yet been passed through the converter, nor been coalesced
        // against TargetNullValue, or, if applicable, formatted, both of which we have to do here.
        if (Converter != null)
            // We pass in the TargetType we're handed here as that's the real target. Child bindings
            // would've normally been handed 'object' since their target is the MultiBinding.
            dynamicResourceBindingResult = Converter.Convert(dynamicResourceBindingResult, targetType, ConverterParameter, ConverterCulture);

        // Check the results for null. If so, assign it to TargetNullValue
        // Otherwise, check if the target type is a string, and that there's a StringFormat
        // if so, format the string.
        // Note: You can't simply put those properties on the MultiBinding as it handles things differently
        // than a single binding (i.e. StringFormat is always applied, even when null.
        if (dynamicResourceBindingResult == null)
            dynamicResourceBindingResult = TargetNullValue;
        else if (targetType == typeof(string) && StringFormat != null)
            dynamicResourceBindingResult = String.Format(StringFormat, dynamicResourceBindingResult);

        // If the binding target object is a FrameworkElement, ensure the BindingSource is added
        // to its Resources collection so it will be part of the lookup relative to the FrameworkElement
        if (bindingTargetObject is FrameworkElement targetFrameworkElement
        && !targetFrameworkElement.Resources.Contains(bindingSource)) {

            // Add the resource to the target object's Resources collection
            targetFrameworkElement.Resources[bindingSource] = bindingSource;

            // Since we just added the source to the visual tree, we have to re-evaluate the value
            // relative to where we are.  However, since there's no way to get a binding expression,
            // to trigger the binding refresh, here's where we use that BindingTrigger created above
            // to trigger a change notification, thus having it refresh the binding with the (possibly)
            // new value.
            // Note: since we're currently in the Convert method from the current operation,
            // we must make the change via a 'Post' call or else we will get results returned
            // out of order and the UI won't refresh properly.
            SynchronizationContext.Current.Post((state) => {

                bindingTrigger.Refresh();

            }, null);
        }

        // Return the now-properly-resolved result of the child binding
        return dynamicResourceBindingResult;
    }
}

绑定代理

这就是上面提到的 Freezable,但它也有助于其他需要跨越可视化树边界的绑定 proxy-related 模式。在此处或在 Google 上搜索 '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
}

注意:同样,您必须使用 Freezable 才能工作。将任何其他类型的 DependencyObject 插入到目标 FrameworkElement 的资源中——具有讽刺意味的是,即使是另一个 FrameworkElement——也会解析相对于 Application 的 DynamicResources,而不是关联的 FrameworkElement,因为 non-Freezables 在 Resources collection 中不参与本地化资源查找。因此,您将丢失可能在可视化树中定义的任何资源。

绑定触发器

此class用于强制MultiBinding刷新,因为我们无法访问终极BindingExpression(从技术上讲,您可以使用任何支持更改通知的 class,但我个人希望我的设计明确说明它们的用法。)

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

这使您可以在 code-behind 中轻松设置转换器,只需提供用于转换的方法即可。 (我有一个类似的 InlineConverter)

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,完全支持转换器、字符串格式、空值处理等!

总之,就是这样!我真的希望这对其他开发者有帮助,因为它 确实 简化了我们的控制模板,尤其是圆形公共边框厚度等。

尽情享受吧!