Simplifying Converters for WPF

Total: 1 Average: 3

I have been working with WPF for about a year and some things annoying me very much. One of such things is converters. Why do I need to declare the implementation of a dubiously looking interface somewhere at the deep of a project and then search for it through CTRL + F by name when it is needed?

Well, it’s time to make a little easier on the routine of creating and using the converters.

I would like to note that I do not mind using converters such as BooleanToVisibilityConverter. We can keep them in mind and use in different areas. However, often it happens that it is required to use a particular converter and you do not want to make the whole component of it because it takes much time, as well as clogs the global scope. Thus, it is difficult to search the required information from all the garbage.

Converters are used when working with bindings. They allow converting values using one-way or two-way binding modes. There are one and multi-value converters: IValueConverter and IMultiValueConverter.

With a single value, we use standard bindings via the BindingBase markup extension available in XAML:

<TextBlock Text="{Binding IntProp, Converter={StaticResource conv:IntToStringConverter}, ConverterParameter=plusOne}" />

With multiple values, the following humongous construction is used:

<TextBlock>
	<TextBlock.Text>
		<MultiBinding
			Converter="{StaticResource conv:IntToStringConverter}"
			ConverterParameter="plusOne">
			<Binding Path="IntProp" />
			<Binding Path="StringProp" />
		</MultiBinding>
	</TextBlock.Text>
</TextBlock>

The converters themselves will look like this. In this code, we have two converters in one class. However, we can use them separately:

public class IntToStringConverter : IValueConverter, IMultiValueConverter
{
	public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
	  => (string)parameter == "plusOne" ? ((int)value + 1).ToString() : value.ToString();

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

	public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
	 => $"{Convert(values[0], targetType, parameter, culture) as string} {values[1]}";

	public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
	  => throw new NotImplementedException();
}

The code is short, as the example is synthetic. However, it is difficult to understand what are these arrays, casting, targetType, and culture? What does ConvertBack without implementation mean?

Simplification methods

I have several ideas how we can simplify the process:

  1. Converters as C# snippets in XAML for simple calculations. See an example.
  2. Converters as references in code-behind methods for cases with particular conversions.
  3. Converters are used in their standard implementation.mine a conversion method in code-behind. I think it should look like this:
private string ConvertIntToString(int intValue, string options)
  => options == "plusOne" ? (intValue + 1).ToString() : intValue.ToString();

private string ConvertIntAndStringToString(int intValue, string stringValue, string options)
  => $"{ConvertIntToString(intValue, options)} {stringValue}";

Compare his implementation with the previous one. The less code is, the more clarity there is. The variant with a separate re-used converter may look similar:

public static class ConvertLib
{
  public static string IntToString(int intValue, string options)
	=> options == "plusOne" ? (intValue + 1).ToString() : intValue.ToString();

  public static string IntAndStringToString(int intValue, string stringValue, string options)
	=> $"{IntToString(intValue, options)} {stringValue}";
}

Not bad at all, is it? So, how can we work with XAML if it understands only standard interfaces of converters? Surely, we can wrap each class in IValueConverter/IMultiValueConverter, which will use beautiful methods. However, then there is no sense in declaring each readable converter by a wrapper. One of the solutions is to make the wrapper unique, such as:

public class GenericConverter : IValueConverter, IMultiValueConverter
{
	public GenericConverter(/* Description of conversion methods as delegates */)
	{
		// Store conversion methods 
	}

	public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
	{
		// Call conversion by a delegate converting input parameters 
	}

	public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
	{
		// Call conversion by a delegate converting input parameters
	}

	public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
	{
		// Call conversion by a delegate converting input parameters
	}

	public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
	{
		// Call conversion by a delegate converting input parameters
	}
}

This all is a theory. How can we pass delegates to converters in practical terms and how can we take them with XAML?

To do this, we can use the MarkupExtension mechanism. Just inherit the MarkupExtension class and override the ProvideValue method. Later in the XAML, you can write binding-similar expressions in figure brackets, but with their own working mechanisms.

The simplest solution to pass a reference to conversion methods using markup extensions is to use their string names. Let’s agree that in code-behind we will simply define the method name, while in external libraries we will have static methods of the ClrNamespace.ClassName.MethodName type. You can distinguish them by a dot of the latter one.

We have already analyzed how it is possible to identify methods. So, how can we get them in the markup extension as delegates to pass to the converter?

MarkupExtension has the ProvideValue method to override, which is as follows:

public class GenericConvExtension : MarkupExtension
{
    public override object ProvideValue(IServiceProvider serviceProvider)
    {
		// A code
    }
}

The overridden method must return what is eventually assigned to the property. The value of this property in XAML markup defines this markup extension.

The method can return any value, but because we are going to pass this markup extension to the Сonverter property, the return value must be a converter that is an instance of the IValueConverter/IMultiValueConverter type.

There is no sense in creating different converters. Instead, we can create one class and implement both interfaces for the converter to be suitable for one-value and multi-value binding.

To pass a string to the markup extension that specifies the function name from the code-behind or static library to be called by the converter, you must define the public string property in the MarkupExtension instance:

public string FunctionName { get; set; }

In the markup, you can write the following code:

<TextBlock Text="{Binding IntProp, Converter={conv:GenericConvExtension FunctionName='ConvertIntToString'}, ConverterParameter=plusOne}" />

However, we can simplify it as well.  It is not necessary to add “Extension” to the extension class name -conv:GenericConvExtension. Just leave it as conv:GenericConv.

You can then define the constructor in the extension so that you do not explicitly specify the property name with the function name:

public GenericConvExtension(string functionName)
{
	FunctionName = functionName;
}

Now, the statement in XAML looks simpler:

<TextBlock Text="{Binding IntProp, Converter={conv:GenericConv ConvertIntToString}, ConverterParameter=plusOne}" />

Note that there are no quotation marks in the name of the conversion function. In the cases when there are no spaces or other symbols in the string, single quotes are not used.

Now all you have to do is get a reference to the method in the ProvideValue method, create an instance of the converter, and pass this reference to it.

A reference to the method can be obtained through the reflection mechanism. To do this, you must know the runtime type in which this method is declared.  In the case of the implementation of conversion methods in static classes, the full name of the static method is passed. You can parse this string, using the full type name through reflection, and then get the method definition as a MethodInfo instance.

In the case of a code behind, we need not only a type but an instance of that type (the method may not be static and the state of the window instance should be considered when the conversion result is issued). Fortunately, this is not a problem because it can be obtained through the input parameter of the ProvideValue method:

public override object ProvideValue(IServiceProvider serviceProvider)
{
  object rootObject = (serviceProvider.GetService(typeof(IRootObjectProvider)) as IRootObjectProvider).RootObject;
  // ...
}

Rootobject is the object in which code-behind is written. In the case of a window, it will be the Window object. By invoking GetType, you can get the conversion method we’re interested in by Reflexing, because its name is set in a previously defined FunctionNname property. Next, you simply create an instance of Genericconverter, passing the resulting methodinfo to it and returning the converter as a result of ProvideValue.

Implementation in  XAML

In the end of the article, I will provide my own implementation. The implementation in the string with the method name accepts both conversion method and optionally method of backward conversion. The syntax is as follows:

General view: 
'[Common_name_of_static_class] [name_of_static_class.]Name_of_conversion_method, [Name_of_static_class.]Name_of_backward_conversion_name' 
Example for a static library with converter methods:
'Converters.ConvertLib IntToString, StringToInt' = 'Converters.ConvertLib.IntToString, Converters.ConvertLib.StringToInt' 
Example for code-behind: 'IntToString' for one-way binding, 'IntToString, StringToInt' for two-way binding 
Mixed variant (direct method in code-behind, the backward one in a static library): 'IntToString, Converters.ConvertLib.StringToInt'

My code will be implemented in XAML in the following way:

<TextBlock Text="{Binding IntProp, Converter={conv:ConvertFunc 'ConvertIntToString'}, ConverterParameter=plusOne}" />

<TextBlock>
	<TextBlock.Text>
		<MultiBinding
			Converter="{conv:ConvertFunc 'ConvertIntAndStringToString'}"
			ConverterParameter="plusOne">
			<Binding Path="IntProp" />
			<Binding Path="StringProp" />
		</MultiBinding>
	</TextBlock.Text>
</TextBlock>

I found the following downsides in my own implementation:

  1. At the time the method is called, various checks occur, and arrays are created for the so parameters. I’m not sure that MethodInfo. Invoke works as quickly as calling a method directly, but I wouldn’t say it’s a big minus in the context of working with WPF/MVVM.
  2. There is no way to use overloads. At the time the MethodInfo is received, the value types will not be known, so the required method overload cannot be retrieved at this time.
  3. In multi-value bindings, it is not possible to create different behavior of the converter depending on the number of passed parameters. If a conversion function is determined for 3 parameters, then the number of multi-value bindings should be the same, while in the standard converter you can create a various number.

Source code:

using System;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Windows.Data;
using System.Windows.Markup;
using System.Xaml;

namespace Converters
{
  public class ConvertFuncExtension : MarkupExtension
  {
    public ConvertFuncExtension()
    {
    }

    public ConvertFuncExtension(string functionsExpression)
    {
      FunctionsExpression = functionsExpression;
    }

    public string FunctionsExpression { get; set; }

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
      object rootObject = (serviceProvider.GetService(typeof(IRootObjectProvider)) as IRootObjectProvider).RootObject;
      MethodInfo convertMethod = null;
      MethodInfo convertBackMethod = null;

      ParseFunctionsExpression(out var convertType, out var convertMethodName, out var convertBackType, out var convertBackMethodName);

      if (convertMethodName != null) {
        var type = convertType ?? rootObject.GetType();
        var flags = convertType != null ?
          BindingFlags.Public | BindingFlags.Static :
          BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;

        if ((convertMethod = type.GetMethod(convertMethodName, flags)) == null)
          throw new ArgumentException($"Specified convert method {convertMethodName} not found on type {type.FullName}");
      }

      if (convertBackMethodName != null) {
        var type = convertBackType ?? rootObject.GetType();
        var flags = convertBackType != null ?
          BindingFlags.Public | BindingFlags.Static :
          BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;

        if ((convertBackMethod = type.GetMethod(convertBackMethodName, flags)) == null)
          throw new ArgumentException($"Specified convert method {convertBackMethodName} not found on type {type.FullName}");
      }

      return new Converter(rootObject, convertMethod, convertBackMethod);
    }

    void ParseFunctionsExpression(out Type convertType, out string convertMethodName, out Type convertBackType, out string convertBackMethodName)
    {
      if (!ParseFunctionsExpressionWithRegex(out string commonConvertTypeName, out string fullConvertMethodName, out string fullConvertBackMethodName))
        throw new ArgumentException("Error parsing functions expression");

      Lazy<Type[]> allTypes = new Lazy<Type[]>(GetAllTypes);

      Type commonConvertType = null;
      if (commonConvertTypeName != null) {
        commonConvertType = FindType(allTypes.Value, commonConvertTypeName);

        if (commonConvertType == null)
          throw new ArgumentException($"Error parsing functions expression: type {commonConvertTypeName} not found");
      }

      convertType = commonConvertType;
      convertBackType = commonConvertType;

      if (fullConvertMethodName != null)
        ParseFullMethodName(allTypes, fullConvertMethodName, ref convertType, out convertMethodName);
      else {
        convertMethodName = null;
        convertBackMethodName = null;
      }

      if (fullConvertBackMethodName != null)
        ParseFullMethodName(allTypes, fullConvertBackMethodName, ref convertBackType, out convertBackMethodName);
      else
        convertBackMethodName = null;
    }

    bool ParseFunctionsExpressionWithRegex(out string commonConvertTypeName, out string fullConvertMethodName, out string fullConvertBackMethodName)
    {
      if (FunctionsExpression == null) {
        commonConvertTypeName = null;
        fullConvertMethodName = null;
        fullConvertBackMethodName = null;
        return true;
      }

      var match = _functionsExpressionRegex.Match(FunctionsExpression.Trim());

      if (!match.Success) {
        commonConvertTypeName = null;
        fullConvertMethodName = null;
        fullConvertBackMethodName = null;
        return false;
      }

      commonConvertTypeName = match.Groups[1].Value;
      if (commonConvertTypeName == "")
        commonConvertTypeName = null;

      fullConvertMethodName = match.Groups[2].Value.Trim();
      if (fullConvertMethodName == "")
        fullConvertMethodName = null;

      fullConvertBackMethodName = match.Groups[3].Value.Trim();
      if (fullConvertBackMethodName == "")
        fullConvertBackMethodName = null;

      return true;
    }

    static void ParseFullMethodName(Lazy<Type[]> allTypes, string fullMethodName, ref Type type, out string methodName)
    {
      var delimiterPos = fullMethodName.LastIndexOf('.');

      if (delimiterPos == -1) {
        methodName = fullMethodName;
        return;
      }

      methodName = fullMethodName.Substring(delimiterPos + 1, fullMethodName.Length - (delimiterPos + 1));

      var typeName = fullMethodName.Substring(0, delimiterPos);
      var foundType = FindType(allTypes.Value, typeName);
      type = foundType ?? throw new ArgumentException($"Error parsing functions expression: type {typeName} not found");
    }

    static Type FindType(Type[] types, string fullName)
      => types.FirstOrDefault(t => t.FullName.Equals(fullName));

    static Type[] GetAllTypes()
      => AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()).ToArray();

    readonly Regex _functionsExpressionRegex = new Regex(
      @"^(?:([^ ,]+) )?([^,]+)(?:,([^,]+))?(?:[\s\S]*)$",
      RegexOptions.Compiled | RegexOptions.CultureInvariant);

    class Converter : IValueConverter, IMultiValueConverter
    {
      public Converter(object rootObject, MethodInfo convertMethod, MethodInfo convertBackMethod)
      {
        _rootObject = rootObject;
        _convertMethod = convertMethod;
        _convertBackMethod = convertBackMethod;

        _convertMethodParametersCount = _convertMethod != null ? _convertMethod.GetParameters().Length : 0;
        _convertBackMethodParametersCount = _convertBackMethod != null ? _convertBackMethod.GetParameters().Length : 0;
      }

      #region IValueConverter

      object IValueConverter.Convert(object value, Type targetType, object parameter, CultureInfo culture)
      {
        if (_convertMethod == null)
          return value;

        if (_convertMethodParametersCount == 1)
          return _convertMethod.Invoke(_rootObject, new[] { value });
        else if (_convertMethodParametersCount == 2)
          return _convertMethod.Invoke(_rootObject, new[] { value, parameter });
        else
          throw new InvalidOperationException("Method has invalid parameters");
      }

      object IValueConverter.ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
      {
        if (_convertBackMethod == null)
          return value;

        if (_convertBackMethodParametersCount == 1)
          return _convertBackMethod.Invoke(_rootObject, new[] { value });
        else if (_convertBackMethodParametersCount == 2)
          return _convertBackMethod.Invoke(_rootObject, new[] { value, parameter });
        else
          throw new InvalidOperationException("Method has invalid parameters");
      }

      #endregion

      #region IMultiValueConverter

      object IMultiValueConverter.Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
      {
        if (_convertMethod == null)
          throw new ArgumentException("Convert function is not defined");

        if (_convertMethodParametersCount == values.Length)
          return _convertMethod.Invoke(_rootObject, values);
        else if (_convertMethodParametersCount == values.Length + 1)
          return _convertMethod.Invoke(_rootObject, ConcatParameters(values, parameter));
        else
          throw new InvalidOperationException("Method has invalid parameters");
      }

      object[] IMultiValueConverter.ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
      {
        if (_convertBackMethod == null)
          throw new ArgumentException("ConvertBack function is not defined");

        object converted;
        if (_convertBackMethodParametersCount == 1)
          converted = _convertBackMethod.Invoke(_rootObject, new[] { value });
        else if (_convertBackMethodParametersCount == 2)
          converted = _convertBackMethod.Invoke(_rootObject, new[] { value, parameter });
        else
          throw new InvalidOperationException("Method has invalid parameters");

        if (converted is object[] convertedAsArray)
          return convertedAsArray;

        // ToDo: Convert to object[] from Tuple<> and System.ValueTuple

        return null;
      }

      static object[] ConcatParameters(object[] parameters, object converterParameter)
      {
        object[] result = new object[parameters.Length + 1];
        parameters.CopyTo(result, 0);
        result[parameters.Length] = converterParameter;

        return result;
      }

      #endregion

      object _rootObject;

      MethodInfo _convertMethod;
      MethodInfo _convertBackMethod;

      int _convertMethodParametersCount;
      int _convertBackMethodParametersCount;
    }
  }
}

Thank you for your attention!