Search code examples
c#wpfmarkup-extensions

String format with a markup extension


I am trying to make string.Format available as a handy function in WPF, so that the various text parts can be combined in pure XAML, without boilerplate in code-behind. The main problem is support of the cases where the arguments to the function are coming from other, nested markup extensions (such as Binding).

Actually, there is a feature which is quite close to what I need: MultiBinding. Unfortunately it can accept only bindings, but not other dynamic type of content, like DynamicResources.

If all my data sources were bindings, I could use markup like this:

<TextBlock>
    <TextBlock.Text>
        <MultiBinding Converter="{StaticResource StringFormatConverter}">
            <Binding Path="FormatString"/>
            <Binding Path="Arg0"/>
            <Binding Path="Arg1"/>
            <!-- ... -->
        </MultiBinding>
    </TextBlock.Text>
</TextBlock>

with obvious implementation of StringFormatConveter.

I tried to implement a custom markup extension so that the syntax is like that:

<TextBlock>
    <TextBlock.Text>
        <l:StringFormat Format="{Binding FormatString}">
            <DynamicResource ResourceKey="ARG0ID"/>
            <Binding Path="Arg1"/>
            <StaticResource ResourceKey="ARG2ID"/>
        </MultiBinding>
    </TextBlock.Text>
</TextBlock>

or maybe just

<TextBlock Text="{l:StringFormat {Binding FormatString},
                  arg0={DynamicResource ARG0ID},
                  arg1={Binding Arg2},
                  arg2='literal string', ...}"/>

But I am stuck at the implementation of ProvideValue(IServiceProvider serviceProvider) for the case of argument being another markup extension.

Most of the examples in the internet are pretty trivial: they either don't use serviceProvider at all, or query IProvideValueTarget, which (mostly) says what dependency property is the target of the markup extension. In any case, the code knows the value which should be provided at the time of ProvideValue call. However, ProvideValue will be called only once (except for templates, which are a separate story), so another strategy should be used if the actual value is not constant (like it's for Binding etc.).

I looked up the implementation of Binding in Reflector, its ProvideValue method actually returns not the real target object, but an instance of System.Windows.Data.BindingExpression class, which seems to do all the real work. The same is about DynamicResource: it just returns an instance of System.Windows.ResourceReferenceExpression, which is caring about subscribing to (internal) InheritanceContextChanged and invalidating the value when appropriate. What I however couldn't understand from looking through the code is the following:

  1. How does it happen that the object of type BindingExpression / ResourceReferenceExpression is not treated "as is", but is asked for the underlying value?
  2. How does MultiBindingExpression know that the values of the underlying bindings have changed, so it have to invalidate its value as well?

I have actually found a markup extension library implementation which claims to support concatenating the strings (which is perfectly mapping to my use case) (project, code, the concatenation implementation relying on other code), but it seems to support nested extensions only of the library types (i.e., you cannot nest a vanilla Binding inside).

Is there a way to implement the syntax presented at the top of the question? Is it a supported scenario, or one can do this only from inside the WPF framework (because System.Windows.Expression has an internal constructor)?


Actually I have an implementation of the needed semantics using a custom invisible helper UI element:

<l:FormatHelper x:Name="h1" Format="{DynamicResource FORMAT_ID'">
    <l:FormatArgument Value="{Binding Data1}"/>
    <l:FormatArgument Value="{StaticResource Data2}"/>
</l:FormatHelper>
<TextBlock Text="{Binding Value, ElementName=h1}"/>

(where FormatHelper tracks its children and its dependency properties update, and stores the up-to-date result into Value), but this syntax seems to be ugly, and I want to get rid of helper items in visual tree.


The ultimate goal is to facilitate the translation: UI strings like "15 seconds till explosion" are naturally represented as localizable format "{0} till explosion" (which goes into a ResourceDictionary and will be replaced when the language changes) and Binding to the VM dependency property representing the time.


Update report: I tried to implement the markup extension myself with all the information I could find in internet. Full implementation is here ([1], [2], [3]), here is the core part:

var result = new MultiBinding()
{
    Converter = new StringFormatConverter(),
    Mode = BindingMode.OneWay
};

foreach (var v in values)
{
    if (v is MarkupExtension)
    {
        var b = v as Binding;
        if (b != null)
        {
            result.Bindings.Add(b);
            continue;
        }

        var bb = v as BindingBase;
        if (bb != null)
        {
            targetObjFE.SetBinding(AddBindingTo(targetObjFE, result), bb);
            continue;
        }
    }

    if (v is System.Windows.Expression)
    {
        DynamicResourceExtension mex = null;
        // didn't find other way to check for dynamic resource
        try
        {
            // rrc is a new ResourceReferenceExpressionConverter();
            mex = (MarkupExtension)rrc.ConvertTo(v, typeof(MarkupExtension))
                as DynamicResourceExtension;
        }
        catch (Exception)
        {
        }
        if (mex != null)
        {
            targetObjFE.SetResourceReference(
                    AddBindingTo(targetObjFE, result),
                    mex.ResourceKey);
            continue;
        }
    }

    // fallback
    result.Bindings.Add(
        new Binding() { Mode = BindingMode.OneWay, Source = v });
}

return result.ProvideValue(serviceProvider);

This seems to work with nesting bindings and dynamic resources, but fails miserably on try to nest it in itself, as in this case targetObj obtained from IProvideValueTarget is null. I tried to work around this with merging the nested bindings into the outer one ([1a], [2a]) (added multibinding spill into outer binding), this would perhaps work with the nested multibindings and format extensions, but stills fails with nested dynamic resources.

Interesting enough, when nesting different kinds of markup extensions, I get Bindings and MultiBindings in the outer extension, but ResourceReferenceExpression instead of DynamicResourceExtension. I wonder why is it inconsistent (and how is the Binding reconstructed from BindingExpression).


Update report: unfortunately the ideas given in answers didn't bring the solution of the problem. Perhaps it proves that the markup extensions, while being quite powerful and versatile tool, need more attention from WPF team.

Anyway I thank to anyone who took part in the discussion. The partial solutions which were presented are complicated enough to deserve more upvotes.


Update report: there seems to be no good solution with markup extensions, or at least the level of WPF knowledge needed for creating one is too deep to be practical.

However, @adabyron had an idea of improvement, which helps to hide the helper elements in the host item (the price of this is however subclassing the host). I'll try to see if it's possible to get rid of subclassing (using a behaviour which hijacks the host's LogicalChildren and adds helper elements to it comes to my mind, inspired by the old version of the same answer).


Solution

  • See if the following works for you. I took the test case you offered in the comment and expanded it slightly to better illustrate the mechanism. I guess the key is to keep flexibility by using DependencyProperties in the nesting container.

    enter image description here enter image description here

    EDIT: I have replaced the blend behavior with a subclass of the TextBlock. This adds easier linkage for DataContext and DynamicResources.

    On a sidenote, the way your project uses DynamicResources to introduce conditions is not something I would recommend. Instead try using the ViewModel to establish the conditions, and/or use Triggers.

    Xaml:

    <UserControl x:Class="WpfApplication1.Controls.ExpiryView" xmlns:system="clr-namespace:System;assembly=mscorlib" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
                     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
                     xmlns:props="clr-namespace:WpfApplication1.Properties" xmlns:models="clr-namespace:WpfApplication1.Models"
                     xmlns:h="clr-namespace:WpfApplication1.Helpers" xmlns:c="clr-namespace:WpfApplication1.CustomControls"
                     Background="#FCF197" FontFamily="Segoe UI"
                     TextOptions.TextFormattingMode="Display">    <!-- please notice the effect of this on font fuzzyness -->
    
        <UserControl.DataContext>
            <models:ExpiryViewModel />
        </UserControl.DataContext>
        <UserControl.Resources>
            <system:String x:Key="ShortOrLongDateFormat">{0:d}</system:String>
        </UserControl.Resources>
        <Grid>
            <StackPanel>
                <c:TextBlockComplex VerticalAlignment="Center" HorizontalAlignment="Center">
                    <c:TextBlockComplex.Content>
                        <h:StringFormatContainer StringFormat="{x:Static props:Resources.ExpiryDate}">
                            <h:StringFormatContainer.Values>
                                <h:StringFormatContainer Value="{Binding ExpiryDate}" StringFormat="{DynamicResource ShortOrLongDateFormat}" />
                                <h:StringFormatContainer Value="{Binding SecondsToExpiry}" />
                            </h:StringFormatContainer.Values>
                        </h:StringFormatContainer>
                    </c:TextBlockComplex.Content>
                </c:TextBlockComplex>
            </StackPanel>
        </Grid>
    </UserControl>
    

    TextBlockComplex:

    using System;
    using System.Collections;
    using System.Collections.Specialized;
    using System.ComponentModel;
    using System.Windows;
    using System.Windows.Controls;
    using WpfApplication1.Helpers;
    
    namespace WpfApplication1.CustomControls
    {
        public class TextBlockComplex : TextBlock
        {
            // Content
            public StringFormatContainer Content { get { return (StringFormatContainer)GetValue(ContentProperty); } set { SetValue(ContentProperty, value); } }
            public static readonly DependencyProperty ContentProperty = DependencyProperty.Register("Content", typeof(StringFormatContainer), typeof(TextBlockComplex), new PropertyMetadata(null));
    
            private static readonly DependencyPropertyDescriptor _dpdValue = DependencyPropertyDescriptor.FromProperty(StringFormatContainer.ValueProperty, typeof(StringFormatContainer));
            private static readonly DependencyPropertyDescriptor _dpdValues = DependencyPropertyDescriptor.FromProperty(StringFormatContainer.ValuesProperty, typeof(StringFormatContainer));
            private static readonly DependencyPropertyDescriptor _dpdStringFormat = DependencyPropertyDescriptor.FromProperty(StringFormatContainer.StringFormatProperty, typeof(StringFormatContainer));
            private static readonly DependencyPropertyDescriptor _dpdContent = DependencyPropertyDescriptor.FromProperty(TextBlockComplex.ContentProperty, typeof(StringFormatContainer));
    
            private EventHandler _valueChangedHandler;
            private NotifyCollectionChangedEventHandler _valuesChangedHandler;
    
            protected override IEnumerator LogicalChildren { get { yield return Content; } }
    
            static TextBlockComplex()
            {
                // take default style from TextBlock
                DefaultStyleKeyProperty.OverrideMetadata(typeof(TextBlockComplex), new FrameworkPropertyMetadata(typeof(TextBlock)));
            }
    
            public TextBlockComplex()
            {
                _valueChangedHandler = delegate { AddListeners(this.Content); UpdateText(); };
                _valuesChangedHandler = delegate { AddListeners(this.Content); UpdateText(); };
    
                this.Loaded += TextBlockComplex_Loaded;
            }
    
            void TextBlockComplex_Loaded(object sender, RoutedEventArgs e)
            {
                OnContentChanged(this, EventArgs.Empty); // initial call
    
                _dpdContent.AddValueChanged(this, _valueChangedHandler);
                this.Unloaded += delegate { _dpdContent.RemoveValueChanged(this, _valueChangedHandler); };
            }
    
            /// <summary>
            /// Reacts to a new topmost StringFormatContainer
            /// </summary>
            private void OnContentChanged(object sender, EventArgs e)
            {
                this.AddLogicalChild(this.Content); // inherits DataContext
                _valueChangedHandler(this, EventArgs.Empty);
            }
    
            /// <summary>
            /// Updates Text to the Content values
            /// </summary>
            private void UpdateText()
            {
                this.Text = Content.GetValue() as string;
            }
    
            /// <summary>
            /// Attaches listeners for changes in the Content tree
            /// </summary>
            private void AddListeners(StringFormatContainer cont)
            {
                // in case they have been added before
                RemoveListeners(cont);
    
                // listen for changes to values collection
                cont.CollectionChanged += _valuesChangedHandler;
    
                // listen for changes in the bindings of the StringFormatContainer
                _dpdValue.AddValueChanged(cont, _valueChangedHandler);
                _dpdValues.AddValueChanged(cont, _valueChangedHandler);
                _dpdStringFormat.AddValueChanged(cont, _valueChangedHandler);
    
                // prevent memory leaks
                cont.Unloaded += delegate { RemoveListeners(cont); };
    
                foreach (var c in cont.Values) AddListeners(c); // recursive
            }
    
            /// <summary>
            /// Detaches listeners
            /// </summary>
            private void RemoveListeners(StringFormatContainer cont)
            {
                cont.CollectionChanged -= _valuesChangedHandler;
    
                _dpdValue.RemoveValueChanged(cont, _valueChangedHandler);
                _dpdValues.RemoveValueChanged(cont, _valueChangedHandler);
                _dpdStringFormat.RemoveValueChanged(cont, _valueChangedHandler);
            }
        }
    }
    

    StringFormatContainer:

    using System.Linq;
    using System.Collections;
    using System.Collections.ObjectModel;
    using System.Windows;
    
    namespace WpfApplication1.Helpers
    {
        public class StringFormatContainer : FrameworkElement
        {
            // Values
            private static readonly DependencyPropertyKey ValuesPropertyKey = DependencyProperty.RegisterReadOnly("Values", typeof(ObservableCollection<StringFormatContainer>), typeof(StringFormatContainer), new FrameworkPropertyMetadata(new ObservableCollection<StringFormatContainer>()));
            public static readonly DependencyProperty ValuesProperty = ValuesPropertyKey.DependencyProperty;
            public ObservableCollection<StringFormatContainer> Values { get { return (ObservableCollection<StringFormatContainer>)GetValue(ValuesProperty); } }
    
            // StringFormat
            public static readonly DependencyProperty StringFormatProperty = DependencyProperty.Register("StringFormat", typeof(string), typeof(StringFormatContainer), new PropertyMetadata(default(string)));
            public string StringFormat { get { return (string)GetValue(StringFormatProperty); } set { SetValue(StringFormatProperty, value); } }
    
            // Value
            public static readonly DependencyProperty ValueProperty = DependencyProperty.Register("Value", typeof(object), typeof(StringFormatContainer), new PropertyMetadata(default(object)));
            public object Value { get { return (object)GetValue(ValueProperty); } set { SetValue(ValueProperty, value); } }
    
            public StringFormatContainer()
                : base()
            {
                SetValue(ValuesPropertyKey, new ObservableCollection<StringFormatContainer>());
                this.Values.CollectionChanged += OnValuesChanged;
            }
    
            /// <summary>
            /// The implementation of LogicalChildren allows for DataContext propagation.
            /// This way, the DataContext needs only be set on the outermost instance of StringFormatContainer.
            /// </summary>
            void OnValuesChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
            {
                if (e.NewItems != null)
                {
                    foreach (var value in e.NewItems)
                        AddLogicalChild(value);
                }
                if (e.OldItems != null)
                {
                    foreach (var value in e.OldItems)
                        RemoveLogicalChild(value);
                }
            }
    
            /// <summary>
            /// Recursive function to piece together the value from the StringFormatContainer hierarchy
            /// </summary>
            public object GetValue()
            {
                object value = null;
                if (this.StringFormat != null)
                {
                    // convention: if StringFormat is set, Values take precedence over Value
                    if (this.Values.Any())
                        value = string.Format(this.StringFormat, this.Values.Select(v => (object)v.GetValue()).ToArray());
                    else if (Value != null)
                        value = string.Format(this.StringFormat, Value);
                }
                else
                {
                    // convention: if StringFormat is not set, Value takes precedence over Values
                    if (Value != null)
                        value = Value;
                    else if (this.Values.Any())
                        value = string.Join(string.Empty, this.Values);
                }
                return value;
            }
    
            protected override IEnumerator LogicalChildren
            {
                get
                {
                    if (Values == null) yield break;
                    foreach (var v in Values) yield return v;
                }
            }
        }
    }
    

    ExpiryViewModel:

    using System;
    using System.ComponentModel;
    
    namespace WpfApplication1.Models
    {
        public class ExpiryViewModel : INotifyPropertyChanged
        {
            public event PropertyChangedEventHandler PropertyChanged;
            protected void OnPropertyChanged(string propertyName)
            {
                if (this.PropertyChanged != null)
                    PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
            }
    
            private DateTime _expiryDate;
            public DateTime ExpiryDate { get { return _expiryDate; } set { _expiryDate = value; OnPropertyChanged("ExpiryDate"); } }
    
            public int SecondsToExpiry { get { return (int)ExpiryDate.Subtract(DateTime.Now).TotalSeconds; } }
    
            public ExpiryViewModel()
            {
                this.ExpiryDate = DateTime.Today.AddDays(2.67);
    
                var timer = new System.Timers.Timer(1000);
                timer.Elapsed += (s, e) => OnPropertyChanged("SecondsToExpiry");
                timer.Start();
            }
        }
    }