WPF binding with a localizable placeholder

Recently, I faced a following dilemma: I was binding a TextBlock to a string property of a class instance, and when that instance was null, I wanted to show a placeholder that had to be localizable. Given this example:

<TextBlock x:Uid="Track_Title"
TextWrapping="WrapWithOverflow"
FontWeight="Bold"
Text="{Binding Meta.Title}"/>
<!-- what if it's null? -->

What would be the right way to do it?

One option would be to place another TextBlock next to it, bind it to the placeholder value, and only show it when the result of the binding expression was empty:

<TextBlock FontWeight="Bold"
Text="{loc:Loc NO_TRACK}">

<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding Meta.Artist}"
PresentationTraceSources.TraceLevel="High"
Value="">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>

If you are wondering what's the {loc: Loc} expression, have a look at my article about localizing a WPF app on .NET Core from last year.

This technique of using separate placeholder elements has the advantage of being explicit at the cost of cluttering the XAML. I wanted something simpler, perhaps using a custom IValueConverter to provide the placeholder value when the binding source was null:

<TextBlock x:Uid="Track_Title"
TextWrapping="WrapWithOverflow"
FontWeight="Bold"
Text="{Binding Meta.Title,
Converter={StaticResource FallbackStringConverterSimple},
ConverterParameter='NO_TRACK'}"
/>
<!-- null averted -->

The converter would take the result of the binding expression, return it if it's a non-empty string, and when not, look up the fallback value in a dictionary (encapsulated in an abstract class from which it is derived) and provide that instead:

public class FallbackStringConverterSimple : BaseFallbackConverter, IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is string desired && !string.IsNullOrEmpty(desired))
{
return desired;
}

var fallback = parameter as string;
if (string.IsNullOrEmpty(fallback)) return Binding.DoNothing;

var replacement = TranslationSource.Instance[$"{Dictionary}.{fallback}"];
return replacement;
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

This works, and it also has one limitation: were the user to switch languages in the app, e.g. from English to German, the placeholder value would not be updated and would still show the English translation.

The reason for this behavior is that the values returned from an IValueConverter are "final", and since the binding source did not change while the user switched the UI language, there was no impulse to re-evaluate the binding.

The trick I originally wanted to write about today is to use a custom IMultiValueConverter and create a sneaky little binding for the placeholder value.

The IMultiValueConverter interface accepts an array of objects on which it works, and so we can easily pass it the source element itself (the TextBlock) as well as the result of the original binding. A converter parameter can be used to pass in the fallback.

public sealed class FallbackStringConverter: BaseFallbackConverter, IMultiValueConverter
{
public object Convert(object[]? values, Type targetType, object? parameter, CultureInfo culture)
{
if (values == null || values.Length < 2 ||
values[0] is not TextBlock textBlock)
{
return Binding.DoNothing;
}

var desired = values[1] as string;
var fallback = parameter as string;

if (!IsEmptyString(desired) || IsEmptyString(fallback))
{
// return the desired text if not empty or when the fallback is not available
return desired;
}

var binding = new Binding
{
Mode = BindingMode.OneWay,
Source = TranslationSource.Instance,
Path = new PropertyPath($"[{Dictionary}.{fallback}]")
};
BindingOperations.SetBinding(textBlock, TextBlock.TextProperty, binding);

return string.Empty; /** 🤔 **/
}

public object[] ConvertBack(object value, Type[] targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

The converter will again return the binding result if non-empty. When it's time to provide the fallback instead, it creates a new binding treating the fallback passed in as the third method parameter as a key in the translation dictionary and applying the binding to the TextBlock. And, notice the empty return value, which made me scratch my head.

It works - to the extent that when the result of the binding expression is empty, the converter steps in and provides the fallback, and the fallback is localized in real-time, responding to UI language changes. Something is definitely fishy about this trick, though.

It took me about a day to realize where I was wrong: once the binding expression was empty, I replaced the original binding with the new one, binding to the fallback value, which meant the fallback would stay there forever! 🤣

Time to educate myself.

In the end, I learned about PriorityBinding and was able to get rid of the converter altogether. The final version is:

<TextBlock>
<TextBlock.Text>
<PriorityBinding>
<Binding Path="SelectedTrack.Meta.Title"/>
<Binding Source="{x:Static loc:TranslationSource.Instance}"
Path="[Common.i18n.Strings.NO_TRACK]"/>

</PriorityBinding>
</TextBlock.Text>
</TextBlock>

The PriorityBinding will return the value of the first binding expression if not empty, and default to the 2nd otherwise.

Notice two things: the fallback binding uses a static access to the TranslationSource singleton instance, and this is one of a few cases when the singleton pattern helps.

Second, the value of the Path property refers to the entire path, which comprises of a dictionary for the current assembly and the key to the translatable resource. I think having the path specified like this is a definite weakness of this approach, let alone the fact that that it's a string and Intellisense will no longer help me if I change the translation key later or move it to another assembly.

Nevertheless, I think this solution is practical enough to warrant consideration.

What do you think? Sound off in the comments!