Dec 28, 2008

Design Time Feature Implementation in Silverlight Toolkit

Introduction

This is the second post of my Silverlight Design Time Features series. The first post Design Time Features in Silverlight Toolkit showed design time features of Silverlight Toolkit controls. This post explains how it is implemented. Hopefully this post can help explaining our source code, demonstrating how to implement design time features for Silverlight, and providing a framework and source code readers can use directly in their own projects.

Overview

Download "Silverlight Toolkit - Binaries, Samples, Documentation, Unit Tests and Sources" of December 2008 release, open Source\Silverlight.Controls.sln in Visual Studio, and we will see that there are 12 design projects under Design solution folder, three for each control assembly.

Silverlight.Controls.sln in Visual Studio

Take Controls project for example, there are three design projects:

  • Controls.Design: builds Windows.Controls.Design.dll, which contains design time features shared by both Visual Studio and Expression Blend.
  • Controls.Expression.Design: builds Windows.Controls.Expression.Design.dll, which contains design time features for Expression Blend only.
  • Controls.VisualStudio.Design: builds Windows.Controls.VisualStudio.Design.dll, which contains design time features for Visual Studio only.

There is also a Design.Common folder that contains two files: Extensions.cs and MetadataBase.cs, which are shared by all design projects.

It is highly recommended that you read Justin Angel's blog post Silverlight Design Time Extensibility: it provides good background/overview information on Silverlight design time extensibility, on which below implementation is based.

Project

All 12 design projects follow the same implementation pattern, so I will just use Control.Design project to explain them all.

Load Silverlight.Controls.sln under Source directory into Visual Studio for the first time, we will see screen like below: 

Silverlight.Controls.Design.csproj in Visual Studio

  • Controls.Design has a project dependency on Controls project. This dependency ensures that Controls project is built first, and Controls.Design has a reference to Microsoft.Windows.Controls assembly built by Controls project. A design time assembly always references the run time assembly it provides design time features for.
  • Controls.Design references Microsoft.Windows.Design & Microsoft.Windows.Design.Extensibility assemblies, both of which are under directory %DevEnvDir%\PublicAssemblies\ (c:\Program Files\Microsoft Visual Studio 9.0\Common7\IDE\PublicAssemblies\ on my laptop). A design time assembly often references those two assemblies, which provide the designer extensibility framework. A design time assembly always references System assembly too, which contains System.ComponentModel namespace, where many of the metadata attributes (like CategoryAttribute, DescriptionAttribute) are defined.
  • Controls.Design references System.Windows assembly. We can see the reference in Controls.Design.csproj too:

        <Reference Include="System.Windows, Version=2.0.5.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e, processorArchitecture=MSIL">
            <SpecificVersion>False</SpecificVersion>
            <HintPath>..\Binaries\System.Windows.dll</HintPath>
            <Private>False</Private>
        </Reference>


    Please note:
    • Control.Design is a Windows Class Library project, not a Silverlight project; and the generated Microsoft.Windows.Controls.Design.dll is a .NET assembly run on desktop inside designers like Visual Studio or Expression Blend, not a Silverlight assembly run inside a browser, even though Microsoft.Windows.Controls.Design.dll is the design time assembly for the Silverlight run time assembly Microsoft.Windows.Controls.dll.
    • The referenced System.Windows assembly is actually a Silverlight assembly (version 2.0.5.0). System.Windows assembly may not be needed by all design projects for Silverlight controls. We need it here mostly because of the GetMemberName<T>(Expression<Func<T, object>> expr) method in Extension.cs that we will discuss more later in the post. The mix of .NET and Silverlight references creates all kinds of interesting issues, as we will see soon.

      Below ildasm screenshot shows the mixed references to .NET and Silverlight assemblies by Microsoft.Windows.Controls.Design.dll:
      ildasm Microsoft.Windows.Controls.Design.dll Microsoft.Windows.Controls.Design assembly manifest
      • .ver 2:0:21024:1838 is the version number of Silverlight Toolkit 2008 December release.
      • .ver 3:5:0:0 is the version number for .NET framework 3.5.
      • .ver 2:0:0:0 is the version number for Silverlight 2.0.
    • When you first load Controls.Design project into Visual Studio, there is a "The referenced component 'System.Windows' could not be found" warning next to the System.Windows assembly reference in project window, and in Error List window too. This is because Controls.Design is a .NET project instead of Silverlight, and we don't know where Silverlight is installed (it is under C:\Program Files\Microsoft Silverlight\2.0.31005.0 on my laptop, and under C:\Program Files (X86)\Microsoft Silverlight\2.0.31005.0 on my desktop). The solution is to have a pre-build event command ..\CopySystemWindows.bat that copies System.Windows.dll from Silverlight install direction to ..\Binaries. So once you've built the project, the warning will go away, as we will see shortly.
  • In above Visual Studio screenshot, there is another warning sign next to the file Microsoft.Windows.Controls.XML in project widow, when you load Controls.Design project into Visual Studio for the first time. This file is generated by building the dependent Controls project, because in Controls project's Properties -> Build tab, the option "XML documentation file" is checked, and has a path of "..\Binaries\Microsoft.Windows.Controls.XML". If you build Controls.Design project, the warning will go away, as we will see shortly.
  • Controls.Design project has a Metadata.cs file, and links to Extension.cs and MetadataBase.cs in Design.Common folder. All 12 design projects have those three files. We will discuss them in more detail later.

Build Controls.Design project or the whole solution, we can see:

Build output of Silverlight.Control.sln

  • All projects built fine with 0 errors and 0 warnings.
  • The warning signs next to System.Windows reference and Microsoft.Windows.Controls.XML file disappeared.
  • The code analysis of the build process takes a long time, and it generates two warnings. The first one, CA0060, is another issue of the mixed .NET and Silverlight references and code in Extension.cs. You can get rid of the warning by copying System.dll and System.Core.dll from Silverlight installation directory to Source\Binaries directory and building again.

MetadataBase.cs

MetadataBase.cs, together with Metadata.cs, implement a framework for design time metadata registration:

  • DescriptionAttributes are automatically generated for public control classes (ie, subclasses of FrameworkElement) and their public properties from their "/// <summary>" XML documentation comments in source code. (Good commenting pays! :-)
  • To hide a control class from a designer, add a line like below to AddAttributes method in the project's Metadata.cs file:
        builder.AddCallback(typeof(TreeViewItem), b => b.AddCustomAttributes(new ToolboxBrowsableAttribute(false)));
  • To register other custom attributes for a control class, add a XxxMetadata.cs file to the project, like the ViewboxMetadata.cs discussed later .

If you like the framework, you can use MetadataBase.cs and Metadata.cs directly in your own project, subject to Microsoft Public License included at the beginning of all our source files.

MetadataBase.cs:

// (c) Copyright Microsoft Corporation.
// This source is subject to the Microsoft Public License (Ms-PL).
// Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details.
// All other rights reserved.

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Windows;
using System.Xml.Linq;
using Microsoft.Windows.Design.Metadata;

namespace Microsoft.Windows.Controls.Design.Common
{
    /// <summary>
    /// MetadataRegistration class.
    /// </summary>
    public class MetadataRegistrationBase
    {
        /// <summary>
        /// Build design time metadata attribute table.
        /// </summary>
        /// <returns>Custom attribute table.</returns>
        protected virtual AttributeTable BuildAttributeTable()
        {
            AttributeTableBuilder builder = new AttributeTableBuilder();

            AddDescriptions(builder);
            AddAttributes(builder);
            AddTables(builder);

            return builder.CreateTable();
        }

        /// <summary>
        /// Find all AttributeTableBuilder subclasses in the assembly 
        /// and add their attributes to the assembly attribute table.
        /// </summary>
        /// <param name="builder">The assembly attribute table builder.</param>
        [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Design time dll should not fail!")]
        private static void AddTables(AttributeTableBuilder builder)
        {
            Debug.Assert(builder != null, "AddTables is called with null parameter!");

            Assembly asm = Assembly.GetExecutingAssembly();
            foreach (Type t in asm.GetTypes())
            {
                if (t.IsSubclassOf(typeof(AttributeTableBuilder)))
                {
                    try
                    {
                        AttributeTableBuilder atb = (AttributeTableBuilder)Activator.CreateInstance(t);
                        builder.AddTable(atb.CreateTable());
                    }
                    catch (Exception e)
                    {
                        Debug.Assert(false, string.Format(CultureInfo.InvariantCulture, "Exception in AddTables method: {0}", e));
                    }
                }
            }
        }

        /// <summary>
        /// Gets or sets the case sensitive resource name of the embedded XML file.
        /// </summary>
        protected string XmlResourceName { get; set; }

        /// <summary>
        /// Gets or sets the assembly FullName for types' assembly-qualified names.
        /// </summary>
        protected string AssemblyFullName { get; set; }

        /// <summary>
        /// Create description attribute from run time assembly xml file.
        /// </summary>
        /// <param name="builder">The assembly attribute table builder.</param>
        [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Design time dll should not fail.")]
        private void AddDescriptions(AttributeTableBuilder builder)
        {
            Debug.Assert(builder != null, "AddDescriptions is called with null parameter!");

            if (string.IsNullOrEmpty(XmlResourceName) || 
                string.IsNullOrEmpty(AssemblyFullName))
            {
                return;
            }

            XDocument xdoc = XDocument.Load(new StreamReader(
                Assembly.GetExecutingAssembly().GetManifestResourceStream(XmlResourceName)));
            if (xdoc == null)
            {
                return;
            }

            foreach (XElement member in xdoc.Descendants("member"))
            {
                try
                {
                    string name = (string)member.Attribute("name");
                    bool isType = name.StartsWith("T:", StringComparison.OrdinalIgnoreCase);
                    if (isType ||
                        name.StartsWith("P:", StringComparison.OrdinalIgnoreCase))
                    {
                        int lastDot = name.Length;
                        string typeName;
                        if (isType)
                        {
                            typeName = name.Substring(2);
                        }
                        else
                        {
                            lastDot = name.LastIndexOf('.');
                            typeName = name.Substring(2, lastDot - 2);
                        }
                        typeName += AssemblyFullName;

                        Type t = Type.GetType(typeName);
                        if (t != null && t.IsPublic && t.IsClass && 
                            t.IsSubclassOf(typeof(FrameworkElement)))
                        {
                            string desc = member.Descendants("summary").FirstOrDefault().Value;
                            desc = desc.Trim();
                            desc = string.Join(" ", desc.Split(new char[] { ' ', '\t', '\n' }, StringSplitOptions.RemoveEmptyEntries));

                            if (isType)
                            {
                                builder.AddCallback(t, b => b.AddCustomAttributes(new DescriptionAttribute(desc)));
                            }
                            else
                            {
                                string propName = name.Substring(lastDot + 1);
                                PropertyInfo pi = t.GetProperty(propName);
                                MethodInfo mi;
                                if (pi != null && (mi = pi.GetSetMethod()) != null && mi.IsPublic)
                                {
                                    builder.AddCallback(t, b => b.AddCustomAttributes(propName, new DescriptionAttribute(desc)));
                                }
                            }
                        }
                    }
                }
                catch (Exception e)
                {
                    Debug.Assert(false, string.Format(CultureInfo.InvariantCulture, "Exception in AddDescriptions method: {0}", e));
                }
            }
        }

        /// <summary>
        /// Provide a place to add custom attributes without creating a AttributeTableBuilder subclass.
        /// </summary>
        /// <param name="builder">The assembly attribute table builder.</param>
        protected virtual void AddAttributes(AttributeTableBuilder builder)
        {
        }
    }
}

MetadataBase.cs implements the MetadataRegistrationBase class. Let's discuss some its key methods:

AddDescriptions

All run time assembly projects have "XML documentation file" option checked, and with a path like "..\Binaries\Microsoft.Windows.Controls.XML", in Project -> Properties -> Build tab, Output section. You can also find the setting in .csproj file like below:

<DocumentationFile>..\Binaries\Microsoft.Windows.Controls.Design.XML</DocumentationFile>

Below is an excerpt of Microsoft.Windows.Controls.XML to show what the generated documentation XML file looks like:

<?xml version="1.0"?>
<doc>
    <assembly>
        <name>Microsoft.Windows.Controls</name>
    </assembly>
    <members>
        <member name="T:Microsoft.Windows.Controls.Viewbox">
            <summary>
            Defines a content decorator that can stretch and scale a single child to
            fill the available space.
            </summary>
            <QualityBand>Preview</QualityBand>
        </member>
        <member name="F:Microsoft.Windows.Controls.Viewbox.ChildElementName">
            <summary>
            Name of  child element in Viewbox's default template.
            </summary>
        </member>
        <member name="M:Microsoft.Windows.Controls.Viewbox.IsValidStretchValue(System.Object)">
            <summary>
            Check whether the passed in object value is a valid Stretch enum value.
            </summary>
            <param name="o">The object typed value to be checked.</param>
            <returns>True if o is a valid Stretch enum value, false o/w.</returns>
        </member>
        <member name="P:Microsoft.Windows.Controls.Viewbox.Child">
            <summary>
            Gets or sets the single child element of a Viewbox.
            </summary>
        </member>
    </members>
</doc>

The name attribute of each <member> element follows the pattern:

  • "T:Microsoft.Windows.Controls.Viewbox": "T:" indicates this is a type, followed by the type's full name;
  • "F:Microsoft.Windows.Controls.Viewbox.ChildElementName": "F:" indicates this is a field, followed by the field's fully qualified name;
  • "M:Microsoft.Windows.Controls.Viewbox.IsValidStretchValue(System.Object)": "M:" indicates this is a method, followed by the method's fully qualified name and parameters;
  • "P:Microsoft.Windows.Controls.Viewbox.Child": "P:" indicates this is a property, followed by the property's full name;

Control.Design project links to the Microsoft.Windows.Controls.XML file as a embedded resource:

Microsoft.Windows.Controls.XML as embedded resource 
Controls.Design.csproj: <EmbeddedResource Include="..\Binaries\Microsoft.Windows.Controls.XML" />

AddDescriptions method parses the embedded XML file and generates DescriptionAttribute for public control classes and their public properties:

MetadataBase.cs:134: builder.AddCallback(t, b => b.AddCustomAttributes(new DescriptionAttribute(desc)));
MetadataBase.cs:143: builder.AddCallback(t, b => b.AddCustomAttributes(propName, new DescriptionAttribute(desc)));

AddAttributes

AddAttributes is usually overridden in Metadata.cs file to add a ToolboxBrowsableAttribute(false) custom attribute for control classes that should not show up in a designer's toolbox:

  • if the control class should be hidden from all designers, add the ToolboxBrowsableAttribute(false) custom attribute to the Xxx.Design project;
  • if it should be hidden from Visual Studio only, add the custom attribute to Xxx.VisualStudio.Design project;
  • if it should be hidden from Expression Blend only, add the custom attribute to Xxx.Expression.Design project;

Below is AddAttributes implementation in Metadata.cs of Controls.VisualStudio.Design project:

/// <summary>
/// Provide a place to add custom attributes without creating a AttributeTableBuilder subclass.
/// </summary>
/// <param name="builder">The assembly attribute table builder.</param>
protected override void AddAttributes(AttributeTableBuilder builder)
{
    builder.AddCallback(typeof(TreeViewItem), b => b.AddCustomAttributes(new ToolboxBrowsableAttribute(false)));
}
AddTables

To add attributes other than ToolboxBrowsableAttribute(false) for a type, add a XxxMetadata.cs file like ViewboxMetadata.cs below to the appropriate design project:

ViewboxMetadata.cs:

// (c) Copyright Microsoft Corporation.
// This source is subject to the Microsoft Public License (Ms-PL).
// Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details.
// All other rights reserved.

using System.ComponentModel;
using Microsoft.Windows.Controls.Design.Common;
using Microsoft.Windows.Design.Metadata;

namespace Microsoft.Windows.Controls.Design
{
    /// <summary>
    /// To register design time metadata for Viewbox.
    /// </summary>
    internal class ViewboxMetadata : AttributeTableBuilder
    {
        /// <summary>
        /// To register design time metadata for Viewbox.
        /// </summary>
        public ViewboxMetadata()
            : base()
        {
            AddCallback(
                typeof(Viewbox),
                b =>
                {
                    b.AddCustomAttributes(Extensions.GetMemberName<Viewbox>(x => x.BorderThickness), new BrowsableAttribute(false));
                    b.AddCustomAttributes(Extensions.GetMemberName<Viewbox>(x => x.BorderBrush), new BrowsableAttribute(false));
                    b.AddCustomAttributes(Extensions.GetMemberName<Viewbox>(x => x.Background), new BrowsableAttribute(false));
                    b.AddCustomAttributes(Extensions.GetMemberName<Viewbox>(x => x.Foreground), new BrowsableAttribute(false));

                    b.AddCustomAttributes(Extensions.GetMemberName<Viewbox>(x => x.Child), new CategoryAttribute(Properties.Resources.CommonProperties));
                    b.AddCustomAttributes(Extensions.GetMemberName<Viewbox>(x => x.Stretch), new CategoryAttribute(Properties.Resources.CommonProperties));
                    b.AddCustomAttributes(Extensions.GetMemberName<Viewbox>(x => x.StretchDirection), new CategoryAttribute(Properties.Resources.CommonProperties));
                });
        }
    }
}
  • It is recommended to follow the naming convention here. Take Viewbox for example, the file name is ViewboxMetadata.cs, and the class name is ViewboxMetadata.
  • The metadata class must inherit from AttributeTableBuilder.
  • You can add custom attributes in the constructor of the metadata class:
    • You can use either the call back model (as in ViewboxMetada.cs above) or the direct model. The callback model is supposedly more efficient.
      • direct model example:
            AddCustomAttributes(
                typeof(Viewbox),   // type
                "BorderThickness", // property name
                new Attribute[] { new BrowsableAttribute(false) }); // custom attribute array
    • To provide the property name parameter to AddCustomAttribute call, you can either use the method Extensions.GetMemberName() as in ViewboxMetadata.cs (will discuss more about it later in the post) to get the property name in a type safe way, or provide the property name directly as string like "BorderThickness" in above direct model implementation.

The AddTables(AttributeTableBuilder builder) method:

  • enumerates all subclasses of AttributeTableBuilder in the executing assembly
  • creates an instance of each found AttributeTableBuilder subclass:
        AttributeTableBuilder atb = (AttributeTableBuilder)Activator.CreateInstance(t);
  • add the attribute table of the found class to builder:
        builder.AddTable(atb.CreateTable());

Metadata.cs

Metadata.cs implements MetadataRegistration class, which inherits from MetadataRegistrationBase class implemented in MetadataBase.cs. It also implements IRegisterMetadata interface.

Metadata.cs:

// (c) Copyright Microsoft Corporation.
// This source is subject to the Microsoft Public License (Ms-PL).
// Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details.
// All other rights reserved.

using System.Reflection;
using Microsoft.Windows.Controls.Design.Common;
using Microsoft.Windows.Design.Metadata;

namespace Microsoft.Windows.Controls.Design
{
    /// <summary>
    /// MetadataRegistration class.
    /// </summary>
    public class MetadataRegistration : MetadataRegistrationBase,  IRegisterMetadata
    {
        /// <summary>
        /// Design time metadata registration class.
        /// </summary>
        public MetadataRegistration()
            : base()
        {
            AssemblyName asmName = typeof(Viewbox).Assembly.GetName();
            XmlResourceName = asmName.Name + ".Design." + asmName.Name + ".XML"; // "Microsoft.Windows.Controls.Design.Microsoft.Windows.Controls.XML"
            AssemblyFullName = ", " + asmName.FullName;
        }

        /// <summary>
        /// Borrowed from System.Windows.Controls.Toolbox.Design.MetadataRegistration:
        /// use a static flag to ensure metadata is registered only one.
        /// </summary>
        private static bool _initialized;

        /// <summary>
        /// Called by tools to register design time metadata.
        /// </summary>
        public void Register()
        {
            if (!_initialized)
            {
                MetadataStore.AddAttributeTable(BuildAttributeTable());
                _initialized = true;
            }
        }

        /// <summary>
        /// Provide a place to add custom attributes without creating a AttributeTableBuilder subclass.
        /// </summary>
        /// <param name="builder">The assembly attribute table builder.</param>
        protected override void AddAttributes(AttributeTableBuilder builder)
        {
        }
    }
}

Let's discuss some of its key methods:

MetadataRegistration

This constructor initializes two key fields:

  • XmlResourceName: resource name of the embedded documentation XML file, used by MetadataRegistrationBase.AddDescriptions method.
  • AssemblyFullName: full name of the run time assembly this design time assembly is for.

You need to replace the type Viewbox with your own class if you use Metadata.cs in your own design project.

Register

This is the only method of IRegisterMetadata interface. It adds the custom attribute table, built from AddDescripions, AddAttributes & AddTables methods described above, to designer's metadata store:
        MetadataStore.AddAttributeTable(BuildAttributeTable());

Extensions.cs

This file contains the implementation of extension method GetMemberName<T>(Expression<Func<T, object>> expr) that is used to get the name of a member of a type with compile time check and IntelliSense:

GetMemberName extension method

The idea was originally proposed by Jafar Husain (see his blog post Symbols in C# 3.0), and then improved by Justin Angel. It is a great trick to avoid typos, but the drawback is that it pulls in Silverlight references and assemblies into an otherwise pure .NET assembly. Here is the complete source:

Extensions.cs:

// (c) Copyright Microsoft Corporation.
// This source is subject to the Microsoft Public License (Ms-PL).
// Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details.
// All other rights reserved.

using System;
using System.Linq.Expressions;

namespace Microsoft.Windows.Controls.Design.Common
{
    /// <summary>
    /// This set of internal extension methods provide general solutions and 
    /// utilities in a small enough number to not warrant a dedicated extension
    /// methods class.
    /// </summary>
    internal static class Extensions
    {
        /// <summary>
        /// Helper method to get member name with compile time verification to avoid typo.
        /// </summary>
        /// <typeparam name="T">The containing class of the member whose name is retrieved.</typeparam>
        /// <param name="expr">The lambda expression usually in the form of o => o.member.</param>
        /// <returns>The name of the property.</returns>
        public static string GetMemberName<T>(Expression<Func<T, object>> expr)
        {
            Expression body = ((LambdaExpression)expr).Body;
            MemberExpression memberExpression = body as MemberExpression;
            if (memberExpression == null)
            {
                memberExpression = (MemberExpression)((UnaryExpression)body).Operand;
            }
            return memberExpression.Member.Name;
        }
    }
}

Conclusion

This post described the implementation of design time features in the December 2008 release of Silverlight Toolkit, and introduced a simple framework for implementing design time feature for Silverlight controls. You can model the implementation and reuse the framework in your own projects. The framework is still very primitive, supporting metadata registration only, since that's all Blend supports for now. I will look into improving the framework and design time features for Silverlight Toolkit, like adding custom inline/extended/dialog editors, design time data, design time only behaviors etc.

As I stated in previous post Design Time Features in Silverlight Toolkit, design time experiences for controls are very important. It not only improves the experience and productivity of developers who use those controls with deign time features, but also improves the experience of end users, since more and more applications give users more flexibility in customizing user interface, like changing layout by dragging and dropping controls, or changing a control's settings, just like in a designer, except it is at run time.