Customizing “lookful” WPF controls

[UPDATE: This solution has been superceded by a much better implementation in this later post.]

If you’ve spent any time reading material about WPF, it’s inevitable that the term “lookless” has come up at least once. Ironically, that’s not actually a word, at least not according to Webster. However, the term has been drilled into the minds of WPF developers the world over and will likely be added to the Windows dictionary at some point. Microsoft even used the word several times in this patent application which describes in precise detail what it means. In case the practical meaning isn’t clear, in essence “lookless” equates to “has a flexible UI”. In other words, lookless controls are focused on functionality, not appearance or even visual behavior. Of course most controls do have a default appearance, but lookless controls are, by nature, almost completely customizable (limited mostly by imagination and time.)

Matt Duffin of the UK even suggested we all grab a pen and write “All controls in WPF should be lookless” over and over until it becomes ingrained. Not sure I would go that far, but clearly the overwhelming guidance for WPF designers is that most controls should have a stylable and/or templatable appearance which is independent of functionality. Being aware of that guidance just makes it even more frustrating when you run into a control built into the framework which violates that tenet.

The particular control I’m referring to in this case is the TextBlock. Recently I had a desire to enable ToolTips on a TextBlock when (and only when) its text is being trimmed. I’ve written a separate post which goes into the solution to that specific problem in more depth, but getting there required a lot of foundational work which can be applied to address any number of issues.

Unlike most of the built-in WPF controls which derive from the Control class, TextBlock derives directly from the FrameworkElement class. MSDN says there are two standard methods for building FrameworkElement-based components: direct rendering and custom element composition. TextBlock performs direct rendering because it overrides the OnRender method and provides DrawingContext operations that explicitly define the component visuals. It also performs custom element composition because it uses other visual components like Flow Documents to provide its content. What’s most important to this discussion is simply that TextBlock does not derive from Control thus its visual appearance can not be changed by simply overriding its ControlTemplate (which is extremely easy to do via Expression Blend.) What this means for would-be direct rendering control customizers is that we’ve got a lot more work to do.

For instance, let’s say I want to display a different background in the TextBlock based on whether or not the text inside it is being trimmed. Telling the TextBlock to trim is simple enough:

   1 <TextBlock TextTrimming=”WordEllipsis” TextWrapping=”NoWrap” Text=”Some not-so-random text” />

Now whenever the TextBlock isn’t given enough space to display the full text, it will automatically clip the text and append an ellipsis (…). In order to provide a different background based on the state of a control, typically you can make use of a style trigger – which is exactly what I’ll attempt in this case:

    1 <TextBlock TextTrimming=”WordEllipsis” TextWrapping=”NoWrap” Text=”Some not-so-random text”>

    2     <TextBlock.Resources>

    3         <Style TargetType=”TextBlock”>

    4             <Style.Triggers>

    5                 <Trigger Property=”? Value=”True”>

    6                     <Setter Property=”Background” Value=”HotPink” />

    7                 </Trigger>

    8             </Style.Triggers>

    9         </Style>

   10     </TextBlock.Resources>

   11 </TextBlock>

Ah, right. It appears I’m missing an important bit of information. That big red question-mark above is referring to a dependency property on TextBlock to which we need to bind in order to determine whether the text is being trimmed. However, there is no such property documented for TextBlock. In actual fact, no such property exists at all, but I’ll get to that in a moment.

First I want to point out that the easiest way to resolve these situations for normal controls is to access their control template and start looking for relevant triggers and properties. In many cases you’ll find that the control provides exactly the information you need and it’s just a matter of applying a property trigger or a slightly altered template to get the desired behavior (or appearance.) For example, the control template for the CheckBox class is laid out on MSDN and with just a quick glance I see that the checkmark graphic is created by a border brush with a predefined path. Again, the ways in which you can customize that control’s appearance and behavior are virtually limitless.

Not so for the TextBlock. Because there’s no control template we’ll have to resort to poking around with Reflector (which, in case you hadn’t heard, is now owned by Red Gate Software, not Lutz Roeder.) I couldn’t possibly recall everything I did to arrive with this solution, apart from attaching a time-sliced video of my computer in action as I traversed up and down class hierarchies and ran innumerable expressions in my debugger’s Immediate Window. Suffice to say it was a lot of digging and comprehending and ultimately, a lot of guessing that led me to the conclusion that the simplest and most direct way to determine if a TextBlock is currently trimming the text is to compare the Width property of RenderSize with the Width property of a private instance field named “_firstLine” inside the OnRenderSizeChanged method.

For that to be possible, we’ll have to create a new control which derives from TextBlock and override its OnRenderSizeChanged. Additionally, reflection will be necessary in order to access private members of the base class. Sadly that does mean we’re going to deliberately degrade the control’s performance and, as such, this technique should only be used as a last resort. That’s not the only reason, though. Microsoft does not guarantee that the internal implementation details of any of its libraries won’t change drastically between versions. Which means even though this code works on .NET Framework 3.5 SP1, it may not work at all for the next service pack or major release. The code I’m presenting is not robust and should be augmented with mechanisms to deal with that scenario.

Speaking of code, here it is. In order to publish information about the control’s trimming status I added a read-only dependency property named IsTextTrimmed. I’m also caching the reflected type information so the only repeat performance hit is actually retrieving the values:

    1 /// <summary>

    2 /// Adds the read-only dependency property <c>IsTextTrimmed</c> to the built-in

    3 /// <see cref=”TextBlock”/> <see cref=”FrameworkElement”/>.

    4 /// </summary>

    5 public class EnhancedTextBlock : TextBlock

    6 {

    7     #region Members

    8 

    9     /// <summary>

   10     /// Accessor for the <c>_firstLine</c> private instance field of the <see cref=”TextBlock”/> class.

   11     /// </summary>

   12     private static FieldInfo _firstLineField = typeof( TextBlock ).GetField( “_firstLine”, BindingFlags.NonPublic | BindingFlags.Instance );

   13 

   14     /// <summary>

   15     /// Accessor for the <c>Width</c> internal instance property of the <c>MS.Internal.Text.LineMetrics</c> struct.

   16     /// </summary>

   17     private static PropertyInfo _lineMetricsWidthProperty = _firstLineField.FieldType.GetProperty( “Width”, BindingFlags.NonPublic | BindingFlags.Instance );

   18 

   19     #endregion (Members)

   20 

   21     #region Dependency Properties

   22 

   23     /// <summary>

   24     /// Key returned upon registering the read-only dependency property <c>IsTextTrimmed</c>.

   25     /// </summary>

   26     public static readonly DependencyPropertyKey IsTextTrimmedKey = DependencyProperty.RegisterReadOnly(

   27         “IsTextTrimmed”,

   28         typeof( bool ),

   29         typeof( EnhancedTextBlock ),

   30         new PropertyMetadata( false ) );    // defaults to false

   31 

   32     /// <summary>

   33     /// Identifier associated with the read-only dependency property <c>IsTextTrimmed</c>.

   34     /// </summary>

   35     public static readonly DependencyProperty IsTextTrimmedProperty = IsTextTrimmedKey.DependencyProperty;

   36 

   37     #endregion (Dependency Properties)

   38 

   39     #region Properties

   40 

   41     /// <summary>

   42     /// Gets the current effective value of the IsTextTrimmed dependency property.

   43     /// </summary>

   44     public bool IsTextTrimmed

   45     {

   46         get

   47         {

   48             return (bool) GetValue( IsTextTrimmedProperty );

   49         }

   50     }

   51 

   52     #endregion (Properties)

   53 

   54     #region Methods

   55 

   56     /// <summary>

   57     /// Raises the SizeChanged event, using the specified information as part of the eventual event data.

   58     /// </summary>

   59     /// <param name=”sizeInfo”>Details of the old and new size involved in the change</param>

   60     protected override void OnRenderSizeChanged( SizeChangedInfo sizeInfo )

   61     {

   62         base.OnRenderSizeChanged( sizeInfo );

   63 

   64         SetIsTextTrimmed();

   65     }

   66 

   67     /// <summary>

   68     /// Sets the local value of read-only dependency property <see cref=”IsTextTrimmed”/>.

   69     /// </summary>

   70     /// <remarks>

   71     /// Uses reflection to access private and internal members of the base TextBlock class.

   72     /// Therefore, this method may introduce a significant performance drain.

   73     /// </remarks>

   74     private void SetIsTextTrimmed()

   75     {

   76         if ( TextTrimming.None == TextTrimming )

   77         {

   78             SetValue( IsTextTrimmedKey, false );

   79         }

   80         else

   81         {

   82             Double firstLineWidth = (Double) _lineMetricsWidthProperty.GetValue( _firstLineField.GetValue( this ), null );

   83             Double renderWidth = RenderSize.Width;

   84 

   85             SetValue( IsTextTrimmedKey, firstLineWidth > renderWidth );

   86         }

   87     }

   88 

   89     #endregion (Methods)

   90 }

All that’s left is to modify our original markup to make use of the new and improved EnhancedTextBlock and replace the question-mark with our custom IsTextTrimmed property:

    1 <Window

    2    xmlns=”http://schemas.microsoft.com/winfx/2006/xaml/presentation”

    3    xmlns:x=”http://schemas.microsoft.com/winfx/2006/xaml”

    4    x:Class=”WpfApplication1.Window1″

    5    xmlns:local=”clr-namespace:WpfApplication1″

    6    Height=”54″

    7    Width=”120″>

    8     <Grid HorizontalAlignment=”Center”>

    9         <local:EnhancedTextBlock TextTrimming=”WordEllipsis” TextWrapping=”NoWrap” Text=”Some not-so-random text”>

   10             <local:EnhancedTextBlock.Resources>

   11                 <Style TargetType=”local:EnhancedTextBlock”>

   12                     <Style.Triggers>

   13                         <Trigger Property=”IsTextTrimmed Value=”True”>

   14                             <Setter Property=”Background” Value=”HotPink” />

   15                         </Trigger>

   16                     </Style.Triggers>

   17                 </Style>

   18             </local:EnhancedTextBlock.Resources>

   19         </local:EnhancedTextBlock>

   20     </Grid>

   21 </Window>

Finally, last but not least – evidence that the background color does change if (and only if) the text is being trimmed:

image image

What’s left to say except, “That’s Hot!”

8 Responses to “Customizing “lookful” WPF controls”

  1. cgassib Says:

    Man that non-syntax highlighted code is difficult to read…

  2. Shawn Hempel Says:

    Alright Chris, you got your fancy syntax highlighting… :)

  3. cgassib Says:

    “that’s hot”

  4. John Says:

    Here is one other viable solution I found in my Googling:

    http://social.msdn.microsoft.com/Forums/en-US/wpf/thread/4406e828-bee1-49fb-a26a-3128a18e0595/

    There, they build a temporary FormattedText object, and compare its Width to the TextBlock’s ActualWidth property. I’m wondering if I can make it into an attached property… but I’ll have to learn how to make an attached property first :)

  5. hempelcx Says:

    Thanks, John.

    That is an interesting approach to determining the value of IsTextTrimmed, though I wonder if it’s prone to problems where Transforms are involved. Could be interesting to do some testing on that.

    Nonetheless, Marco’s approach does eliminate the need for reflection and thus is more future-proof (and works in partial-trust scenarios).

    I may put up an additional post discussing ways you can (and can’t) use attached properties as an extensibility mechanism. There are some interesting possibilities here that may eliminate the need for a derived control.

  6. John Says:

    Yeah, is this sort of thing even possible without subclassing? My understanding is that attached property values don’t actually do anything unless there’s some class out there that’s looking for it. Like, I can set the DockPanel.Dock property on a TextBox, but if that TextBox is not actually contained in a DockPanel, there’s no effect to it.

    So maybe you don’t need to subclass, but you at least need to create a class that contains the TextBlock in order to set that attached property appropriately.

    But I’ve only been doing this for like two weeks, so I’m not sure I know what I’m talking about.

  7. John Says:

    Nevermind, answered my own question. Short answer is yes, it’s possible, by creating a “Service” class like what Manish Dalal did here: http://weblogs.asp.net/manishdalal/archive/2008/09/24/prevention-the-first-line-of-defense-with-attach-property-pixie-dust.aspx

    In my TextBlockService class, I created a ShowToolTipWhenTrimming property that, when set to True, listens to the TextBlock’s SizeChanged event, and then sets the TextBlock’s ToolTipService.IsEnabled attached property to true if the text is being trimmed. I’ll create a blog post when I get around to it…

  8. hempelcx Says:

    Hey John,

    It sounds like your TextBlockService class and my TextBlockService class have a lot in common. I tried to make mine as generic and reusable as possible, but you may have come up with some ideas I didn’t get in there.

    Anyway, feel free to take a look at my latest post:
    http://tranxcoder.wordpress.com/2008/10/12/customizing-lookful-wpf-controls-take-2/

Leave a Reply