Friday, July 31, 2009

Manipulating the Visual Tree with Setters

As I mentioned in my previous post, one of my current projects involves developing a WPF-based frontend for our ERP software.  For the past week, I’ve been developing a control to represent table forms.  A table form in our ERP system is a tabular representation of data that may also include elements of traditional forms.  Many of the table forms defined in the standard ERP metadata are simple representations of paged data.  Thus, many table forms can be represented using a data grid control.  In these cases, we just throw the data into the Xceed DataGrid for WPF and call it a day.  However, there are more complex table forms that, for various reasons, cannot be represented using a traditional grid control.  For these scenarios, we had to develop a proprietary table form control.

column_header_supertipThe other day, I was putting touching up some of the table form templates, and I ran into a problem.  Each field in our ERP system has a few different textual descriptions: long prompt, short prompt, and header.  The “header” is the text displayed in the column header of a table form.  This text is often abbreviated for fields with a short data length to keep the column widths reasonable.  I felt it would improve usability if we included the long prompt text in the column header tooltips.  Easy enough.  However, I wanted to include some additional information in the tooltips in certain cases.  Due to limitations in the ERP backend, not all table form columns are sortable.  Specifically, you cannot sort by a computed field.  To help avoid confusion, I wanted to display a note in the tooltip footer indicating whether a column can be used for sorting.  Pretty reasonable requirement, right?  Surely this calls for a simple style trigger.  I figure it’ll take five minutes to dig up an appropriate image, write the code, and test it.  *Buzzer*… wrong.

The Problem

The ERP system would be running in our Smart Client, which (by marketing decision) utilizes the three Microsoft Office 2007 color schemes.  To achieve this look, I wrote several control styles from scratch, and the others came from the Actipro WPF Studio control suite.  Now, Actipro makes great controls.  Really great.  Now that we have an enterprise license, we use their stuff everywhere, and it’s been a huge time saver.  Alas, even with the best controls, one occasionally hits a snag.  In this case, the snag was with their ScreenTip control.  A ScreenTip is a somewhat richer version of a ToolTip based on the tooltips used in the Office 2007 Ribbon.  ScreenTips have three regions: a header, content (with a optional image), and a footer.  To achieve my desired result, pictured above, I wanted to display some complex content in the ScreenTip footer: a Grid with an Image displayed to the right and a multi-line TextBlock filling the remaining space.  Without really thinking about it, I wrote the following:

<DataTrigger Binding="{Binding Path=Column.CanUserSort, RelativeSource={RelativeSource Self}}"
             Value="True">
  <Setter Property="ToolTip">
    <Setter.Value>
      <apribbon:ScreenTip Header="{Binding Path=Column.ToolTip, RelativeSource={RelativeSource Self}}">
        <apribbon:ScreenTip.Footer>
          <DockPanel LastChildFill="True">
            <Image DockPanel.Dock="Left"
                   Margin="0,0,3,0"
                   Source="/IAFLibraryWPFProvidersDefault;component/Resources/Images/SortHS.png"
                   Width="16"
                   Height="16"
                   VerticalAlignment="Top" />
            <TextBlock Text="Click to sort by this column.&#0013;Hold &quot;Shift&quot; to sort by multiple columns."
                       TextWrapping="Wrap"
                       VerticalAlignment="Top" />
          </DockPanel>
        </apribbon:ScreenTip.Footer>
      </apribbon:ScreenTip>
    </Setter.Value>
  </Setter>
</DataTrigger>

Can you see the problem?  No?  Well, let’s try to run the application and see what happens…  D’oh!

screentip_exception

So what happened?  The exception text here isn’t very helpful.  Apparently the Xaml parser doesn’t like the Setter value I provided.  That doesn’t give me a whole lot to go on.  Fortunately for me, I knew exactly what the problem was, but only because I’d made the same mistake before.  If I didn’t already understand the problem, I might be tempted to get around the error with a clever application of data binding:

<DataTrigger Binding="{Binding Path=Column.CanUserSort, RelativeSource={RelativeSource Self}}"
             Value="True">
  <Setter Property="ToolTip">
    <Setter.Value>
      <Binding BindsDirectlyToSource="True"
               Mode="OneTime">
        <Binding.Source>
          <apribbon:ScreenTip Header="{Binding Path=Column.ToolTip, RelativeSource={RelativeSource Self}}">
            <!-- ... ScreenTip.Footer ... -->
          </apribbon:ScreenTip>
        </Binding.Source>
      </Binding>
    </Setter.Value>
  </Setter>
</DataTrigger>

And then I would be in even deeper trouble, though I might not know it at the time.  As it happens, the above code does work, but only because of a key property of tool tips: only one is ever visible at a time.  Am I making sense yet?  No?  Let’s go back to my original code snippet and explore what will actually happen at runtime.

We have a DataTrigger defined in the ‘Triggers’ collection of a Style.  This particular Trigger/Setter combination effectively says, “if this column can be sorted, change the column header’s tooltip to X”, where X is a ScreenTip instance.  Well, when the Xaml parser runs through the document and instantiates the Setter, it will also instantiate a ScreenTip and assign it to the Setter’s ‘Value’ property.  Now, consider that Styles are usually shared, meaning the same Style instance may be applied to several different elements at runtime.  That means that every time this Style is applied to a sortable column header, the exact same ScreenTip instance will be assigned to its ToolTip property.  This is where we run into a problem: a Visual can only have one parent—it cannot appear more than once in the same logical tree, nor can it exist in more than one logical tree.  For this reason, a Setter value may not derive from Visual or ContentElement.  This prevents us from writing styles which might attempt to add the same visual content to multiple controls.

So why does the second code snippet work?  Only because of a technicality in the way WPF’s tool tip service is implemented.  Apparently, a tool tip is only injected into the tree when it is about to be shown, and then removed again when it disappears.  Since only one tool tip is ever visible at a time, I am theoretically immune from the consequences of my own bad design.  If I were to modify the second code snippet to change the Content property instead of the ToolTip property, then I would encounter something like this:

screentip_exception_2

At least then we would get a useful error message.  OK, so now that we understand the problem, what are we going to do?  Normally, situations like this can be avoided if a control exposes template properties for its various “regions”.  When you model a control’s structure with a template, what you’re actually doing is describing the UI structure that should be created when the template is applied—think of it as a blueprint rather than the finished product.  While the ScreenTip control has a “HeaderTemplate” paired with its “Header” property, there is no “FooterTemplate” property.  My initial impulse was to send a feature request to Actipro, but then I realized that it wouldn’t help me anyway.  Why not?  Well, a ToolTip (and thus a ScreenTip) is not part of the “owner” element’s logical tree—that means it can’t be targeted using a Setter’s “TargetName” property, as it does not exist in the proper name scope.  Thus, we could not simply change the “FooterTemplate” value via a Trigger.

Admittedly, ToolTips are something of an edge case, making runtime modification more difficult than with most other controls.  But there are bound to be other cases in which we want to manipulate the visual tree using Triggers and Setters but don’t have the proper tools at our disposal.  So what do we do in those situations?  We need to come up with a mechanism that is similar to a template—something that provides a blueprint of the visual tree to be created.  Instead of having the Setter say, “assign this ScreenTip instance as the ToolTip”, we need a way to make it say, “create a new ScreenTip from this specification and assign it as the ToolTip.”  We need the Setter to cook up a new value on demand each time it gets applied.  We can do this with some custom markup extensions.

The Solution

Fortunately, we don’t have to start from scratch.  Last year, Ælij of the WPF Contrib project blogged about using markup extensions to add better support for generics in Xaml.  He created an ‘Activator’ markup extension which takes in a type and a collection of property setters and then returns a newly instantiated object with the specified properties set.  For instance, the following snippet would create an Image element and set the appropriate Source:

<local:Activator Type="{x:Type Image}">
  <local:ActivatorSetter PropertyName="Source"
                         Value="/VisualTreeManipulationWithSetters;Component/Resources/Images/SortHS.png" />
</local:Activator>

The code on Ælij’s blog is a great starting point—it even supports generics out of the box.  However, it has some limitations that we’ll need to overcome.  First, only supports simple property setters.  In order to declare a DockPanel with multiple children, we will need to extend the ActivatorExtension class to support collection properties.  This can be done pretty easily by adding a condition to the ProvideValue method:

   1: var propertyValueType = actualPropertyValue.GetType();
   2: if (property.PropertyType.IsAssignableFrom(propertyValueType) && !property.IsReadOnly)
   3: {
   4:     // If the value is assignable, assign it
   5:     property.SetValue(value, actualPropertyValue);
   6: }
   7: else if (property.Converter.CanConvertFrom(propertyValueType) && !property.IsReadOnly)
   8: {
   9:     // Try to use a type converter to get the value
  10:     try
  11:     {
  12:         property.SetValue(
  13:             value,
  14:             property.Converter.ConvertFrom(actualPropertyValue));
  15:     }
  16:     catch (FormatException formatException)
  17:     {
  18:         throw new XamlParseException(
  19:             "Cannot convert value.",
  20:             formatException);
  21:     }
  22: }
  23: /*************************************
  24:  * BEGIN COLLECTION PROPERTY SUPPORT *
  25:  *************************************/
  26: else if (typeof(IList).IsAssignableFrom(property.PropertyType) &&
  27:          property.IsReadOnly &&
  28:          (actualPropertyValue is IEnumerable))
  29: {
  30:     // If the property is a read-only collection property, and the setter
  31:     // value is also a collection, then add the items in the setter value
  32:     // to the collection property.
  33:     var list = property.GetValue(value) as IList;
  34:     if (list != null)
  35:     {
  36:         foreach (var item in (IEnumerable)actualPropertyValue)
  37:         {
  38:             // If the item is a markup extension, provide the correct value.
  39:             var markupExtensionItem = item as MarkupExtension;
  40:             if (markupExtensionItem != null)
  41:                 list.Add(markupExtensionItem.ProvideValue(serviceProvider));
  42:             else
  43:                 list.Add(item);
  44:         }
  45:     }
  46:     else
  47:     {
  48:         // Collection property is 'null', but property is read-only,
  49:         // so we can't create one.
  50:         throw new InvalidOperationException(
  51:             "Read-only collection property is null: " + property.Name);
  52:     }
  53: }

And now we can write the following:

<local:Activator Type="{x:Type apribbon:ScreenTip}">
  <local:ActivatorSetter PropertyName="Header"
                         Value="{Binding Path=Column.ToolTip, RelativeSource={RelativeSource Self}}" />
  <local:ActivatorSetter PropertyName="Footer">
    <local:ActivatorSetter.Value>
      <local:Activator Type="{x:Type DockPanel}">
        <local:ActivatorSetter PropertyName="Children">
          <x:Array Type="{x:Type System:Object}">
            <local:Activator Type="{x:Type Image}">
              <local:ActivatorSetter PropertyName="DockPanel.Dock"
                                     Value="Left" />
              <local:ActivatorSetter PropertyName="Source"
                                     Value="/VisualTreeManipulationWithSetters;Component/Resources/Images/SortHS.png" />
              <local:ActivatorSetter PropertyName="Width"
                                     Value="16" />
              <local:ActivatorSetter PropertyName="Height"
                                     Value="16" />
              <local:ActivatorSetter PropertyName="Margin"
                                     Value="0,0,3,0" />
              <local:ActivatorSetter PropertyName="VerticalAlignment"
                                     Value="Top" />
            </local:Activator>
            <local:Activator Type="{x:Type TextBlock}">
              <local:ActivatorSetter PropertyName="Text"
                                     Value="Click to sort by this column.&#0013;Hold 'shift' to sort multiple columns." />
              <local:ActivatorSetter PropertyName="TextWrapping"
                                     Value="Wrap" />
              <local:ActivatorSetter PropertyName="VerticalAlignment"
                                     Value="Top" />
            </local:Activator>
          </x:Array>
        </local:ActivatorSetter>
      </local:Activator>
    </local:ActivatorSetter.Value>
  </local:ActivatorSetter>
</local:Activator>

Since the Image source defined above uses an assembly-qualified pack URI, the correct image should load without any problems.  However, if the image were a loose content file, we might run into some issues.  The original ActivatorExtension code does not account for the base URI of the parent document, which means relative URIs may not resolve properly.  We can fix this by capturing the IUriContext in the ProvideValue method and making it available to the TypeConverter that gets invoked on Line 14.  One of the TypeConverter.Convert method overloads takes an argument of type ITypeDescriptorContext, which derives from IServiceProvider.  WPF’s ImageSource type converter uses this argument to retrieve the IUriContext if one is available.  We already have an IServiceProvider at our disposal in the ProvideValue method, so we can use that to request the IUriContext.  We can then create a simple ITypeDescriptorContext implementation that consumes the IUriContext and then returns it from the GetService method when it’s requested by the type converter.

We have but one last change to make to ActivatorExtension: the original author’s property name resolution code will not resolve attached dependency properties.  To make this work, we need to make the following change:

/*******************
 * Replace this... *
 *******************/
if (property == null)
{
    throw new XamlParseException(
        string.Format(
            "Invalid property name '{0}'.",
            propertyValue.PropertyName));
}
 
/*******************
 * ...with this... *
 *******************/
if (property == null)
{
    // If the property couldn't be resolved, it might be an attached
    // dependency property.  See if the property name matches the
    // attached property form and if it does, resolve the property.
    var typeQualifierEnd = propertyName.IndexOf('.');
    if ((typeQualifierEnd >= 0) && (typeQualifierEnd < (propertyName.Length - 1)))
    {
        var xamlTypeResolver = (IXamlTypeResolver)serviceProvider.GetService(typeof(IXamlTypeResolver));
        if (xamlTypeResolver != null)
        {
            var qualifiedTypeName = propertyName.Substring(0, typeQualifierEnd);
            var attachedPropertyName = propertyName.Substring(typeQualifierEnd + 1);
            var propertyOwnerType = xamlTypeResolver.Resolve(qualifiedTypeName);
            if (propertyOwnerType != null)
            {
                property = DependencyPropertyDescriptor.FromName(
                    attachedPropertyName,
                    propertyOwnerType,
                    _type);
            }
        }
    }
    if (property == null)
    {
        throw new XamlParseException(
            string.Format(
                "Invalid property name '{0}'.",
                propertyValue.PropertyName));
    }
}

Alrighty, that marks the last of the changes to ActivatorExtension.  So are we ready to go?  Not quite.  If we put my last Xaml snippet into a Setter value, the Xaml parser will just invoke the Activator extension and store the result, just as it did with the binding.  We need to somehow defer the call to the Activator extension until the Setter is actually invoked.  How can we do that?  Simple—we just wrap it in another markup extension!  You see, if a markup extension returns another markup extension, the second markup extension will not get invoked until the first time the value is needed (in this case, the first time the trigger fires).  Here’s what we need to do now:

Create a second markup extension and call it “DeferredActivatorExtension”.  Give it the same properties as ActivatorExtension (‘Type’ and ‘PropertyValues’).  The ProvideValue method will cook up a regular ActivatorExtension, populating its properties using its own values.  And now we can simply return the ActivatorExtension, and it will be invoked later on when its value is actually needed.  Simple, yes?  Actually, no.  I lied.  It’s a little more complicated than that.  We need a third markup extension!  Why?  Because we need to capture the original IServiceProvider passed to the DeferredActivatorExtension and make sure it gets passed to the ActivatorExtension.  A simple helper class will suffice:

private class ActivatorExtensionWrapper : MarkupExtension
{
    private readonly ActivatorExtension _activatorExtension;
    private readonly IServiceProvider _serviceProvider;
 
    public ActivatorExtensionWrapper(
        ActivatorExtension activatorExtension,
        IServiceProvider serviceProvider)
    {
        if (activatorExtension == null)
            throw new ArgumentNullException("activatorExtension");
        if (serviceProvider == null)
            throw new ArgumentNullException("serviceProvider");
        _activatorExtension = activatorExtension;
        _serviceProvider = serviceProvider;
    }
 
    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        return _activatorExtension.ProvideValue(_serviceProvider);
    }
}

Now instead of returning the ActivatorExtension from DeferredActivatorExtension’s ProvideValue method, we create an ActivatorExtensionWrapper, pass in the ActivatorExtension and IServiceProvider as constructor arguments, and return.  Voila!  We now make some simple adjustments to our Setter value, and we’re done:

<local:DeferredActivator Type="{x:Type apribbon:ScreenTip}">
  <local:ActivatorSetter PropertyName="Header"
                         Value="{Binding Path=Column.ToolTip, RelativeSource={RelativeSource Self}}" />
  <local:ActivatorSetter PropertyName="Footer">
    <local:ActivatorSetter.Value>
      <local:DeferredActivator Type="{x:Type DockPanel}">
        <local:ActivatorSetter PropertyName="Children">
          <x:Array Type="{x:Type System:Object}">
            <local:DeferredActivator Type="{x:Type Image}">
              <local:ActivatorSetter PropertyName="DockPanel.Dock"
                                     Value="Left" />
              <local:ActivatorSetter PropertyName="Source"
                                     Value="/VisualTreeManipulationWithSetters;Component/Resources/Images/SortHS.png" />
              <local:ActivatorSetter PropertyName="Width"
                                     Value="16" />
              <local:ActivatorSetter PropertyName="Height"
                                     Value="16" />
              <local:ActivatorSetter PropertyName="Margin"
                                     Value="0,0,3,0" />
              <local:ActivatorSetter PropertyName="VerticalAlignment"
                                     Value="Top" />
            </local:DeferredActivator>
            <local:DeferredActivator Type="{x:Type TextBlock}">
              <local:ActivatorSetter PropertyName="Text"
                                     Value="Click to sort by this column.&#0013;Hold 'shift' to sort multiple columns." />
              <local:ActivatorSetter PropertyName="TextWrapping"
                                     Value="Wrap" />
              <local:ActivatorSetter PropertyName="VerticalAlignment"
                                     Value="Top" />
            </local:DeferredActivator>
          </x:Array>
        </local:ActivatorSetter>
      </local:DeferredActivator>
    </local:ActivatorSetter.Value>
  </local:ActivatorSetter>
</local:DeferredActivator>

MISSION ACCOMPLISHED™.

Improving on the Solution

There are a number of ways in which we could improve on this design.  For starters, holding a reference to the IServiceProvider indefinitely is undesirable.  It would be better to implement simple implementations of IUriContext and IXamlTypeResolver that retain as little information as possible to do their jobs.  For IUriContext, we simply need to capture the BaseUri value—no sense in holding on to the entire parser context.  The IXamlTypeResolver implementation would be more involved, and both are beyond the scope of this blog post, which has already grown to excessive length (sorry!).

The Code

For those of you who want to see the complete code for this post, with all Actipro dependencies removed, I have posted the code here:

0 Comments:

Post a Comment

Subscribe to Post Comments [Atom]

<< Home