Monday, August 15, 2011

Silverlight 5 Incompatibility: Play by the Rules

I've been using the Silverlight 5 beta for some time now with no issues, so it was a surprise when I came across a project that did not work. It was definitely a backwards compatibility issue because it would run fine in Silverlight 4, but once installed the version 5 runtime, no luck. I started digging deeper and found the culprit. The problem is easy to reproduce. Create a simple command:

public class SelectCommand : ICommand
{
    public bool CanExecute(object parameter)
    {
        return true;
    }

    public void Execute(object parameter)
    {
        if (parameter != null)
        {
            MessageBox.Show(parameter.ToString());
        }
    }

    public event EventHandler CanExecuteChanged;
}

Then a view model (read-only so it doesn't have to implement property change notification):

public class ViewModel
{
    private ICommand _selectCommand;

    public List<string> Items
    {
        get { return new List<string> {"one", "two", "three", "four"}; }
    }

    public ICommand SelectedCommand
    {
        get { return _selectCommand ?? (_selectCommand = new SelectCommand()); }
    }
}

Now for the Xaml. Here is the rub ... in Silverlight 4, there is no such thing as ancestor binding to easily reference elements higher in the visual tree. It is a common pattern to have a view model with a collection bound to a list, and a command to operate against items in the list. The rub is that the data template for the items is scoped to the item itself, not the parent view model.

There are a number of ways to resolve this, such as using element name binding, but one hack was to stick a content control in the resource dictionary and use it to hold the parent data context. Take a look at this Xaml:

<UserControl.DataContext>
    <DataBindingIssue:ViewModel/>
</UserControl.DataContext>
<Grid x:Name="LayoutRoot" Background="White">
    <Grid.Resources>
        <ContentControl x:Key="BindingContent" Content="{Binding}"/>
    </Grid.Resources>
    <ItemsControl ItemsSource="{Binding Items}">
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <TextBlock Text="{Binding}">
                    <i:Interaction.Triggers>
                        <i:EventTrigger EventName="MouseLeftButtonDown">
                            <i:InvokeCommandAction Command="{Binding Source={StaticResource BindingContent}, Path=Content.SelectedCommand}"
                                                    CommandParameter="{Binding}"/>
                        </i:EventTrigger>
                    </i:Interaction.Triggers>
                </TextBlock>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>
</Grid>

If you compile the example (put the grid in the main page and add the classes described earlier) and run it in Silverlight 4, it works no problem. The content control which is really designed to display UI is being hacked to hold the data context, and then the items can reference back to the control and use the content to bind to the command. When you click on an item in the list, you get a nice message box displaying the value.

This example is not so friendly when run in Silverlight 5. The clicks simply don't happen. You can even convert the project to a Silverlight 5 one, remove and re-add the reference to the System.Windows.Interactivity namespace, and it will still not register clicks. If you debug, you'll find that the trigger does fire. It is passed the command parameter with the value of the item in the list ... but the command is null, so there is nothing for it to call.

Why is that? What happened? If you know the history of Silverlight, since version 3.0 the team has made a monumental effort to keep the versions backwards-compatible. When going to Silverlight 5, you don't get a special Silverlight 4 runtime - you get a 5 runtime that supports all of the Silverlight 4 features. This is obviously a problem for this example because the code breaks.

I filed this as a bug for the Silverlight team to consider in case others have used this technique and need the compatibility, but the "true bug" is in the code itself. This is an example of a hack or an exploitation of a bug that allows something to happen, but isn't necessarily the intended behavior. For all I know the bug was fixed in Silverlight 5. If you dive into the Silverlight documentation for resource dictionaries, you'll find this little nugget:

In order for an object to be defined in and accessed from a ResourceDictionary, the object must be able to be shared ... Any object that is derived from the UIElement type is inherently not shareable, unless it is generated from a control template.

In a nutshell, a content control really doesn't belong in a resource dictionary. Actually, if you think about it, the entire thing is a bit of a hack because a content control is designed to be part of the visual tree and in this example, it's being used simply to marshall a data context.

A better approach would be to create a class that is specifically intended to hold onto the reference. That class is easy enough to create and can even keep the same Content property:

public class DataContextBinder : DependencyObject
{
    public static readonly DependencyProperty ContentProperty =
        DependencyProperty.Register(
            "Content",
            typeof(object),
            typeof(DataContextBinder),
            new PropertyMetadata(null));

    public object Content
    {
        get { return GetValue(ContentProperty); }
        set { SetValue(ContentProperty, value); }
    }
}

That is a simple class that is a dependency object (not a UIElement) and can hold any other type of object. It's not intended for the visual tree and is entirely shareable. And, sure enough, if you replace the content control in the previous example with the data context binder, the code runs fine in both Silverlight 4 and Silverlight 5.

Just another reason to take extra effort to play by the rules and learn what a control expects, so you don't suffer from a "hack" that gets called out in a later version.

Jeremy Likness