Wednesday, June 17, 2009

Creating an Ellipsis (...) TextBlock in Silverlight.

So I recently had the requirement to truncate text in a Silverlight TextBlock when it is too wide to fit, and suffix it with '...'. Easy I thought - hah hah you didn't bet on Silverlight though! I thought I could do some things around

1. MultiBindings - bind the actualwidth of the textblock, and the string property. Pass them into a IMultiValueConverter and work out how much text can be shown. Unfortunately SL doesn't support Multi Bindings. Pah.

2. Subclass TextBlock. I hate using inheritance to solve these sorts of problems - I don't want to force people to use my own version of TextBlock - what if for some reason TextBlock gets extended in future and I've killed the inheritance tree.

3. Explicity grab the TextBlock in the code-behind and have a helper function to set the text whenever it changes. I’m working against ViewModels and want to keep my code-behind empty so whilst this might work it’s not what I’m looking for.

4. Attached Properties - the old attached behaviour via attached property trick. That’ll save the day – and here’s how it works:

My first thought was an attached behaviour that would somehow get hold of the Binding, stash it, monitor it for changes, and create a new Text binding which would get the text with ellipsis where appropriate. SL doesn't allow you to get the underlying Binding though. Only set it. Pah.

So I decided Converters would be involved. Again, the problem was that I couldn't get the underlying Binding so I decided to make my one SL concession which was to ask the user of my new behaviour (EllipsisText) to pass me the Property they want to bind to as a string, instead of using a binding markup expression.

public static readonly DependencyProperty EllipsisTextPropertyNameProperty =
DependencyProperty.RegisterAttached(
"EllipsisTextPropertyName", typeof(string), typeof(EllipsisTextBoxBehaviour),
new PropertyMetadata(null, OnEllipsisTextChanged));



When this value is set I want to create a new Binding which will use a Custom Converter. The converter will need to know about the TextBlock though so it can get the maximum Width we have to play with.



private static void ConfigureEllipsis(TextBlock textBlock, string propertyName)
{
var binding = new Binding
{
Converter = new EllipsisTextConverter(textBlock),
Path = new PropertyPath(propertyName)
};
textBlock.SetBinding(TextBlock.TextProperty, binding);
}


Finally the converter – it is pretty simple. It takes the width of the TextBlock, then begins to measure how big the block would have to be to show the whole text. I do this by creating a new textblock, setting the font, etc to the same as the target one, setting the Text to the full string, then telling it to measure itself:



var textBlock = new TextBlock
{
Text = textToFit,
FontFamily = _textBlock.FontFamily,
FontSize = _textBlock.FontSize,
FontStretch = _textBlock.FontStretch,
FontStyle = _textBlock.FontStyle,
FontWeight = _textBlock.FontWeight
};



textBlock.UpdateLayout();


I can then begin to check the ActualWidth of this textblock against the width of the target one. Start chopping bits of the string (of course suffixed with ‘…’ when necessary) until the text fits in the allowed width. The TextBlock you are binding against needs an explicit Width set for this to work. The Layout pass hasn’t run when the converter fires and we need a Width to work with.



To use this all you have to do is:



<TextBlock Width="60" BehaviourExtensions:EllipsisTextBoxBehaviour.EllipsisTextPropertyName="BindingProperty" />



Full code:



public static class EllipsisTextBoxBehaviour
{
public static readonly DependencyProperty EllipsisTextPropertyNameProperty =
DependencyProperty.RegisterAttached(
"EllipsisTextPropertyName", typeof(string), typeof(EllipsisTextBoxBehaviour),
new PropertyMetadata(null, OnEllipsisTextChanged));

private static void OnEllipsisTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var textBlock = d as TextBlock;
var propertyName = e.NewValue as string;
if (textBlock != null && e.NewValue is string)
{
ConfigureEllipsis(textBlock, propertyName);
}
}

/// <summary>
///
Creates a new binding on the text property.
/// </summary>
private static void ConfigureEllipsis(TextBlock textBlock, string propertyName)
{
var binding = new Binding
{
Converter = new EllipsisTextConverter(textBlock),
Path = new PropertyPath(propertyName)
};
textBlock.SetBinding(TextBlock.TextProperty, binding);
}

public static void ClearEllipsisTextPropertyName(DependencyObject obj)
{
obj.ClearValue(EllipsisTextPropertyNameProperty);
}

public static string GetEllipsisTextPropertyName(DependencyObject obj)
{
return (string)obj.GetValue(EllipsisTextPropertyNameProperty);
}

public static void SetEllipsisTextPropertyName(DependencyObject obj, string text)
{
obj.SetValue(EllipsisTextPropertyNameProperty, text);
}

private class EllipsisTextConverter: IValueConverter
{
private readonly TextBlock _textBlock;

public EllipsisTextConverter(TextBlock textBlock)
{
_textBlock = textBlock;
}

public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (_textBlock.Width == 0)
{
return value;
}

string textToFit = value as string;
if (textToFit != null)
{
var textBlock = new TextBlock
{
Text = textToFit,
FontFamily = _textBlock.FontFamily,
FontSize = _textBlock.FontSize,
FontStretch = _textBlock.FontStretch,
FontStyle = _textBlock.FontStyle,
FontWeight = _textBlock.FontWeight
};

int charsToChop = 0;
bool needsEllipsis = false;
do
{
textBlock.Text = textToFit.Substring(0, textToFit.Length - charsToChop) + (needsEllipsis ? "..." : "");
textBlock.UpdateLayout();

charsToChop++;
needsEllipsis = charsToChop > 0;

} while (
charsToChop < textToFit.Length &&
textBlock.ActualWidth > _textBlock.Width);

return textBlock.Text;
}
return value;

}

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

}