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

62o28rlo  于 2023-05-30  发布在  其他
关注(0)|答案(1)|浏览(445)
  • 注意:这是一个早期设计的修订版,该设计具有无法在一种风格中使用的限制,相当程度上否定了其有效性。然而,这个新版本现在可以使用样式,基本上让你可以在任何你可以使用绑定或动态资源的地方使用它,并获得预期的结果,使它更加有用。

严格来说这不是个问题这篇文章展示了我发现的一种方法,可以轻松地使用DynamicResource作为源代码的转换器,但为了遵循s/o的最佳实践,我将其作为问题/答案对发布。所以看看我下面的答案,我发现了如何做到这一点。希望能帮上忙!

gdrx4gfi

gdrx4gfi1#

WPF中绑定DynamicResource

TL;DR

警告!这是一篇#$% 长文章!*

我写这篇文章的目的是让那些感兴趣的人深入了解使用DynamicResource(或任何MarkupExtension)时到底发生了什么,为什么这可能在第一次似乎是不可能解决的,以及我证明它的创造性方法,最终导致我找到了下面的工作解决方案。
也就是说,如果你只对那个解决方案感兴趣,而不想看到所有的文字碎片,那么你可以自由地向下滚动到标题为“DynamicResourceBinding”的标题,你可以从那里获取相关的代码。
问题
我一直觉得WPF中缺少一些功能:使用DynamicResource作为Binding源的能力。我从技术Angular 理解为什么这是不可能的...在微软的“DynamicResource Markup Extension”documentation的“备注”部分有清楚的解释。上面写着...

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

这就是为什么你不能绑定它。它不是一个物体。这甚至不是属性设置的值!它是一个MarkupExtension,在初始编译期间,它使用给定的资源键预配置了一个Microsoft内部的ResourceReferenceExpression,然后通过它的ProvideValue方法返回该表达式,将其传递给它设置的属性。然后,当有人询问该属性的当前值时,表达式运行,在VisualTree中的该位置查找具有指定键的资源的当前值,这就是属性返回的值。
换句话说,DynamicResource不能告诉您资源已更改。这是必须要问的。
尽管如此,从概念的Angular 来看,它总是困扰着我,作为可以在运行时动态更改的东西,它应该能够通过转换器进行推送。
好吧,我终于找到了解决这个问题的办法…输入DynamicResourceBinding
嗯...但为什么?
乍一看,这似乎是不必要的。毕竟,为什么需要绑定到动态资源?这样做实际上解决了什么?
允许你做一些事情怎么样...

  • 基于用户首选项或存储在资源中的辅助功能全局缩放字体大小,同时仍然能够在UI中使用相对字体大小,这要归功于MultiplyByConverter
  • 仅基于double定义应用范围的边距,然后利用DoubleToThicknessConverter,不仅可以将其转换为厚度,还可以根据需要在布局中屏蔽边缘,从而通过更改应用资源中的单个值来更新整个UI。
  • 在资源中定义一个单独的基础ThemeColor,然后使用转换器将其变亮或变暗,甚至根据ColorShadingConverter的使用情况更改其不透明度。

更妙的是,如果您将这些东西 Package 在特定的自定义标记扩展中,您的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}" />

简而言之,这有助于整合主要资源中的所有“基本值”,但它允许您根据需要“调整”它们,而不必在资源集合中手动填充“x”个变量。

神奇的酱汁...

DynamicResourceBinding之所以能够发挥它的魔力,要归功于Freezable对象特有的一个鲜为人知的特性。具体来说

  • 如果将Freezable对象添加到FrameworkElementResources集合中,则通过DynamicResource设置的该Freezable对象上的任何依赖项属性的值都将相对于该FrameworkElement在可视树中的位置进行解析**。*

如上所述,这是 * Freezable对象所特有的 。对于Resources集合中的所有非Freezable对象 (讽刺的是还包括其他FrameworkElement示例!),任何设置的DynamicResource值都将解析 * 相对于应用程序范围,而不是可视化树中的当前位置 ,这意味着对可视化树中更高位置的资源的任何更改**将基本上被忽略。
利用Freezable的“魔法酱料”,以下是绑定到DynamicResource * 所需的步骤(因此您可以使用转换器,FallbackValue等)
...
1.创建一个新的BindingProxy对象 (这只是一个Freezable子类,带有一个类型为Object的'Value' DependencyProperty
1.将其“Value”属性设置为您希望用作绑定源的DynamicResource
1.将BindingProxy添加到目标FrameworkElementResources集合中
1.在目标DependencyPropertyBindingProxy对象的“Value”属性之间设置绑定。* (由于BindingProxyFreezable,它本身是DependencyObject的子类,因此现在允许这样做。

1.指定转换器、字符串格式化程序、空值等。在新的装订上
而这正是DynamicResourceBinding自动为您做的!

  • 注意:虽然它的名称是'DynamicResourceBinding',但它实际上不是Binding子类。它是一个MarkupExtension,我在上面定义了与Binding相关的属性,如ConverterConverterParameterConverterCulture等。然而,对于大多数语义意图和目的,它在功能上与一个同义,因此被赋予这个名称。(只是不要尝试将它传递给期望真正的Binding的东西!)*

并发症 (也称为有趣的挑战!')

这种方法有一个特别的复杂之处,它真的让我在如何解决这个问题上陷入了一个循环。一致地获取目标FrameworkElement,以便我可以将BindingProxy插入其Resources集合中。当我直接在一个X1 M55 N1 X上使用X1 M54 N1 X时,它工作得很好,但在一种风格中使用时就坏了。
当时我不知道原因,但我后来知道这是因为MarkupExtension提供了它的值***它的定义,而不是它的值最终使用的地方。***我假设MarkupExtension的目标总是FrameworkElement,但在样式中使用它的情况下,目标是Style本身!
由于使用了几个内部的“helper”绑定,我也设法绕过了这个限制。如何在评论中解释。

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

绑定代理

这就是上面提到的Freezable,它允许DynamicResourceBinding工作。

  • 注意:这对于其他一些与绑定代理相关的模式也很有帮助,在这些模式中,你需要跨越视觉树的边界,例如在工具提示或下拉菜单中设置绑定,因此它被分离到自己的对象中以实现可重用性。在这里或在谷歌上搜索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

这个类是一个简单的'helper class',当你不能访问BindingExpression时,它可以手动强制刷新绑定。要做到这一点,您可以将其 Package 为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();
}

用法

就像使用常规绑定一样,下面是您如何使用它(假设您已经定义了一个'double'资源,其键为'MyResourceKey')...

<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,完全支持转换器,字符串格式,空值处理等!
不管怎样,就是这样!我真的希望这能帮助其他开发者,因为它真的简化了我们的控件模板,特别是在常见的边框厚度等方面。
好好享受吧!

相关问题