Last week I posted a technique for customizing controls in WPF that don’t offer a replaceable ControlTemplate – I described them as “lookful” controls because their behavior and appearance are tied in with their functionality. My approach involved creating a custom control that derives from the one being targeted. That solution has some significant downsides, though, including that if the control is part of another (composition) then you have to modify the parent control to tell it to use your new custom control as a child – it works in most cases, it’s just not ideal. I figured someone might call me out on it eventually and, sure enough, the day I posted it fellow WPF’er “John” ruminated in a comment about using attached properties to accomplish the task instead. That got my wheels turning and I decided to put together a solution that, frankly, doesn’t stink…
The control I covered was the TextBlock, and the customizations I made involved adding a dependency property called IsTextTrimmed to my derived control which indicates if the text is being trimmed (clipped with ellipses appended). This time I’ll tackle that same issue, but do it in a way that doesn’t require custom controls, is easy to use, and is flexible enough to support any number of additional customizations.
At the end of this post there is a link to a solution which contains the TextBlockService assembly and a Sample WpfApplication that demonstrates its usage. As with everything we post on The Tranxition Developer Blog, feel free to use it however you wish, ask questions, or suggest alternatives. Rather than cover how all of this works (there are a lot of WPF concepts involved), I’m just going to lay out the key bits and then explain a little about why I made certain design and implementation decisions.
All of the functionality resides in the TextBlockService assembly with most of the logic in TextBlockService.cs. That file holds a single public class called TextBlockService which serves as an attached property provider for two properties, the critical one being TextBlockService.IsTextTrimmed.
The attached properties mechanism is one of the coolest (and least intuitive) things about WPF. In this case, I’m bucking the norm which involves backing all attached properties with corresponding dependency properties. As a result, my class doesn’t need to implement any dependency property requirements and also need not derive from the DependencyObject class. It seems odd at first, but the instance values for these properties are maintained entirely by the framework, with no help from my class.
1 public static readonly DependencyPropertyKey IsTextTrimmedKey = DependencyProperty.RegisterAttachedReadOnly(
2 “IsTextTrimmed”,
3 typeof( bool ),
4 typeof( TextBlockService ),
5 new PropertyMetadata( false ) );
That code fragment is what registers my attached property as “TextBlockService.IsTextTrimmed” in XAML, sets it type to bool, and sets the default value to false. Since this property can’t logically be “set” by a consumer, it’s registered as read-only which means instead of returning a DependencyProperty object, it returns a DependencyPropertyKey. That key is used to retrieve a special type of DependencyProperty object:
1 public static readonly DependencyProperty IsTextTrimmedProperty = IsTextTrimmedKey.DependencyProperty;
Which is used when retrieving the current value:
1 [AttachedPropertyBrowsableForType( typeof( TextBlock ) )]
2 public static Boolean GetIsTextTrimmed( TextBlock target )
3 {
4 return (Boolean) target.GetValue( IsTextTrimmedProperty );
5 }
This property (IsTextTrimmed) only makes sense when attached directly to a TextBlock so I used AttachedPropertyBrowsableForType to restrict designers to only displaying it as an attribute on TextBlock elements. This doesn’t actually prevent users from trying to attach it to other element types, but it won’t show as an available option in the IDE. In addition I made the parameter type TextBlock on GetIsTextTrimmed where normally for attached properties this will be something much more generic like DependencyObject or UIElement, etc.
With that bit of code we’ve created a full fledged read-only property that can be attached to any TextBlock control. However, we lack any means to actually set the value (besides its default).
I simply adapted a function that calculates the current trimmed state for any given TextBlock by adding a bit of logic at the top to retrieve the present IsTextTrimmed value if the TextBlock is not in a state where it can be evaluated:
private static bool CalculateIsTextTrimmed( TextBlock textBlock )
{
if ( !textBlock.IsArrangeValid )
{
return GetIsTextTrimmed( textBlock );
}
Typeface typeface = new Typeface(
textBlock.FontFamily,
textBlock.FontStyle,
textBlock.FontWeight,
textBlock.FontStretch );
// FormattedText is used to measure the whole width of the text held up by TextBlock container
FormattedText formattedText = new FormattedText(
textBlock.Text,
System.Threading.Thread.CurrentThread.CurrentCulture,
textBlock.FlowDirection,
typeface,
textBlock.FontSize,
textBlock.Foreground );
formattedText.MaxTextWidth = textBlock.ActualWidth;
// When the maximum text width of the FormattedText instance is set to the actual
// width of the textBlock, if the textBlock is being trimmed to fit then the formatted
// text will report a larger height than the textBlock. Should work whether the
// textBlock is single or multi-line.
return ( formattedText.Height > textBlock.ActualHeight );
}
(Note that I’m using a version of the alternate trimmed state algorithm with input from Jean-Marie Pirelli that was referred to by John in the comment on my original blog entry. This algorithm may have issues when certain transforms are applied, but it has much better maintainability than my original reflection-based approach.)
Now that the state can be determined, we need something to trigger that determination. Meet EventManager.RegisterClassHandler:
1 static TextBlockService()
2 {
3 // Register for the SizeChanged event on all TextBlocks, even if the event was handled.
4 EventManager.RegisterClassHandler(
5 typeof( TextBlock ),
6 FrameworkElement.SizeChangedEvent,
7 new SizeChangedEventHandler( OnTextBlockSizeChanged ),
8 true );
9 }
This code in the service class’s static constructor causes an event handler for the SizeChangedEvent (from FrameworkElement) to be registered for every instance of the TextBlock class. Additionally it allows me to specify that the event handler should be called even if the event has already been marked “handled” by a previous handler. I’m greedy like that. The actual event handler is fairly straight forward, just checking to make sure trimming is even possible before bothering to evaluate it:
1 public static void OnTextBlockSizeChanged( object sender, SizeChangedEventArgs e )
2 {
3 var textBlock = sender as TextBlock;
4 if ( null == textBlock )
5 {
6 return;
7 }
8
9 if ( TextTrimming.None == textBlock.TextTrimming )
10 {
11 SetIsTextTrimmed( textBlock, false );
12 }
13 else
14 {
15 SetIsTextTrimmed( textBlock, CalculateIsTextTrimmed( textBlock ) );
16 }
17 }
That handler uses an additional function called SetIsTextTrimmed. In a read-write attached property, that function is required to be present as that’s what WPF uses to write a new property value. In our case the property is read-only, so I still went ahead and created the function but marked it private:
1 private static void SetIsTextTrimmed( TextBlock target, Boolean value )
2 {
3 target.SetValue( IsTextTrimmedKey, value );
4 }
(This uses the DependencyPropertyKey instance rather than the DependencyProperty object because SetValue has 2 overloads, one that takes a DependencyProperty (for read-write properties) and one that takes a DependencyPropertyKey (for read-only properties). GetValue doesn’t care about writeability, so it only has a single version which expects a DependencyProperty instance.)
That actually covers the entirety of adding a (read-only) attached property to every TextBlock with the only usage requirements being (obviously) a reference to the TextBlockService assembly and (to use it from XAML) importing TextBlockService as a clr-namespace:
xmlns:tbs=”clr-namespace:TextBlockService;assembly=TextBlockService”
Once that’s imported, making use of the property is super easy (assuming you’ve figured out property triggers!)
1 <TextBlock Text=”Some not-so-random text” TextTrimming=”WordEllipsis” TextWrapping=”NoWrap”>
2 <TextBlock.Resources>
3 <Style TargetType=”TextBlock”>
4 <Style.Triggers>
5 <Trigger Property=”tbs:TextBlockService.IsTextTrimmed” Value=”True”>
6 <Setter Property=”Background” Value=”Pink” />
7 </Trigger>
8 </Style.Triggers>
9 </Style>
10 </TextBlock.Resources>
11 </TextBlock>
And as always, the pudding:
Don’t know about you, but I find that incredibly cool. We were able to develop and build an attached property in a class in a separate assembly and all that was needed to make use of it from a consuming application is to import the class’ CLR namespace!
Now that we’ve built out our IsTextTrimmed attached property and shown how easy it is to use, let’s take it a step further and add an some additional custom TextBlock behavior into TextBlockService. I wanted to add an attached property that, when set to true for any given TextBlock, automatically enables a ToolTip that shows the hidden text whenever the text is being trimmed. I called that property TextBlockService.AutomaticToolTipEnabled and here’s how it works.
Like IsTextTrimmed, the AutomaticToolTipEnabled attached property does not have an associated dependency property that backs it. Unlike IsTextTrimmed, AutomaticToolTipEnabled is not a read-only property and, its implementation is much less involved since the property itself has no custom logic:
1 public static readonly DependencyProperty AutomaticToolTipEnabledProperty = DependencyProperty.RegisterAttached(
2 “AutomaticToolTipEnabled”,
3 typeof( bool ),
4 typeof( TextBlockService ),
5 new FrameworkPropertyMetadata( true, FrameworkPropertyMetadataOptions.Inherits ) );
6
7 [AttachedPropertyBrowsableForType( typeof( DependencyObject ) )]
8 public static Boolean GetAutomaticToolTipEnabled( DependencyObject element )
9 {
10 if ( null == element )
11 {
12 throw new ArgumentNullException( “element” );
13 }
14 return (bool) element.GetValue( AutomaticToolTipEnabledProperty );
15 }
16
17 public static void SetAutomaticToolTipEnabled( DependencyObject element, bool value )
18 {
19 if ( null == element )
20 {
21 throw new ArgumentNullException( “element” );
22 }
23 element.SetValue( AutomaticToolTipEnabledProperty, value );
24 }
Some additional differences to note are:
- The value defaults to true.
- The value is automatically inherited by child elements.
- The property can be attached to any DependencyObject.
Points two and three are incredibly powerful when it comes to applying this property to multiple controls which makes it even more wonderful that all of that behavior is provided for free by the framework.
OK, so we’ve created a new attached property – but by itself that’s not going to provide any additional behavior. There are a lot of ways you could go about this, but the way I felt was most reusable under any number of scenarios was to create a ResourceDictionary (TextBlockServiceDictionary.xaml) with some simple property triggers.
1 <ResourceDictionary
2 xmlns=http://schemas.microsoft.com/winfx/2006/xaml/presentation
3 xmlns:x=http://schemas.microsoft.com/winfx/2006/xaml
4 xmlns:tbs=”clr-namespace:TextBlockService”>
5
6 <!–
7 Rather than forcing *all* TextBlocks to adopt TextBlockService styles,
8 using x:Key allows a more friendly opt-in model.
9 –>
10 <Style TargetType=”TextBlock” x:Key=”TextBlockService”>
11 <Style.Triggers>
12 <MultiTrigger>
13 <MultiTrigger.Conditions>
14 <Condition Property=”tbs:TextBlockService.AutomaticToolTipEnabled” Value=”True” />
15 <Condition Property=”tbs:TextBlockService.IsTextTrimmed” Value=”True” />
16 </MultiTrigger.Conditions>
17
18 <Setter Property=”ToolTip” Value=”{Binding RelativeSource={x:Static RelativeSource.Self}, Path=Text}” />
19 </MultiTrigger>
20 </Style.Triggers>
21 </Style>
22
23 </ResourceDictionary>
First off, if you’ve never worked with resource dictionaries before, just think of it as a collection of resources (styles, templates, etc). If you import a dictionary, you import all of the resources it contains.
Two things are crucial here: First, I’m importing the TextBlockService CLR namespace, which means I automatically have access to the IsTextTrimmed attached property and the new AutomaticToolTipEnabled attached property.
Second, I’ve defined a style using TextBlock as the target type but also giving it an x:Key identifier. As a result, this style is considered an “explicit” style and will not be automatically applied to TextBlocks – something has to explicitly cause the style to be applied. I did this for several reasons, but a major one is that there are a lot of intricacies in WPF’s style system and many of them come into play when you attempt to use multiple implicit styles for the same type. As a result, it’s just easier for everyone if this style remains explicit. (By the way, Ian Griffiths has a great blog entry which explains default styles that I ran across while researching some of those intricacies.)
I defined a multi-condition trigger that says (in prose form): whenever AutomaticToolTipEnabled is true and IsTextTrimmed is true, set the value of the ToolTip property for this TextBlock to the value of the TextBlock’s Text property.
Making use of that style is also quite straightforward, it just requires merging in the resource dictionary. In addition to pulling in the styles, you also need to cause your TextBlocks to use the style. That’s easy to do and it’s also easy to cause those styles to apply to all TextBlocks in a control, window or even application (if it’s a WPF application.)
1 <Window.Resources>
2 <ResourceDictionary>
3 <ResourceDictionary.MergedDictionaries>
4 <!– Merge in the TextBlockService style –>
5 <ResourceDictionary Source=”/TextBlockService;component/TextBlockServiceDictionary.xaml” />
6 </ResourceDictionary.MergedDictionaries>
7
8 <!– Use BasedOn to apply TextBlockService behavior to every TextBlock in this Window –>
9 <Style TargetType=”TextBlock” BasedOn=”{StaticResource TextBlockService}”>
10 <Style.Setters>
11 <Setter Property=”TextWrapping” Value=”NoWrap” />
12 <Setter Property=”TextTrimming” Value=”WordEllipsis” />
13 <Setter Property=”Text” Value=”Some not-so-random text” />
14 </Style.Setters>
15 </Style>
16 </ResourceDictionary>
17 </Window.Resources>
(Note: the ResourceDictionary.Source property uses Pack URI syntax which is fairly well documented on MSDN.)
In this case I’m not only applying the TextBlockService style to all my TextBlocks (by attaching it to my style via the BasedOn attribute), but I’m also adding some additional global properties. Remember, since the default value of AutomaticToolTipEnabled is true, the above fragment will enable the full host of functionality my TextBlockService has to offer for every TextBlock in that Window.
Finally, the AutomaticToolTipEnabled attached property can also be used in a more granular fashion:
25 <StackPanel DockPanel.Dock=”Top”>
26 <!– Adopts the default True value for AutomaticToolTipEnabled –>
27 <TextBlock />
28
29 <!– Explicity sets AutomaticToolTipEnabled –>
30 <TextBlock tbs:TextBlockService.AutomaticToolTipEnabled=”False” Background=”LightGray” />
31
32 <Grid tbs:TextBlockService.AutomaticToolTipEnabled=”True”>
33 <!– Inherits a value for AutomaticToolTipEnabled –>
34 <TextBlock />
35 </Grid>
36 </StackPanel>

Well, this post has droned on long enough. If you’re interested, please take a look at the Sample project I included in this zip file (right click, Save As…, and change the extension to .zip – WordPress doesn’t permit uploading files with a .zip extension.)
And as I mentioned earlier, please feel free to leave questions, comments or suggestions. We’re all learning.
January 21, 2009 at 3:43 pm |
Fantastic ! works great, thanks a lot for sharing.
I made a small change in OnTextBlockSizeChanged: I removed the condition on the TextWrapping property as the ellipsis works fine in this case (although the 3.5 docs say it’s not supported).
February 4, 2009 at 3:55 pm |
I found out that the algorithm does not work for a multi-line text box. I changed it like this:
Replace the last return statement in CalculateIsTextTrimmed by:
formattedText.MaxTextWidth = textBlock.ActualWidth;
return (formattedText.Height > textBlock.ActualHeight);
So, the FormattedText instance is given a max width, so if it has to occupy more height than the given TextBlock, it means that the text was trimmed.
This also works for a single-line text block.
February 4, 2009 at 6:26 pm |
Great, Jean-Marie!
I had a hunch that algorithm wouldn’t work for multi-line text boxes and if I remember correctly, is partly why I added the condition on TextWrapping (though it’s been a while, now).
Glad you got it working; I’m updating the code sample to reflect the more inclusive algorithm. It now works with multi-line TextBlocks and with TextWrapping enabled or disabled.
February 4, 2009 at 8:41 pm |
[...] Customizing "lookful" WPF controls [UPDATE: This solution has been superceded by a much better implementation in this later post.] [...]