共用方式為


WPF: Allow your users to define their own styles using XAML

An application I’ve been working on has the vague property of “displaying items on the screen”, and a fairly unusual requirement which specifies that users be able to completely define how the items look.

Our items are displayed on a timeline and can represent pretty much anything (up to the user), hence the flexibility required for the visuals.

In order to support this, we do the following:

  1. Have a generic class (DataItem) that represents a rendered item and which supports a few basic properties (name, start date, etc).
  2. Support any number of custom properties for that class (stored as an internal collection).
  3. Allow the user to define a “Style name” for any item (think of a CSS style name).
  4. Allow them to define a global list of styles (think of a CSS stylesheet), each with custom XAML.
  5. Allow that XAML to bind to any custom property of an item.
  6. Lookup the style at runtime, load the XAML then display it in the user control.

There are a few small tricks that make this possible:

 

Binding To Custom Properties

Because custom properties on an item are just key-value pairs (in our case, strings), we needed a unified way to read any property (whether native or custom) from an item so that XAML binding could use a single syntax.

First, we create a method on our item class that returns the value of any property:

    1: public string GetFieldValue(string fieldName)
    2: {
    3:     // First attempt to get an actual item property using reflection.
    4:     PropertyInfo prop = typeof(DataItem).GetProperties()
    5:         .SingleOrDefault( p => p.Name.EqualsIgnoreCase(fieldName) );
    6:     if (prop != null)
    7:     {
    8:         object val = prop.GetValue(this, null);
    9:         return val == null ? "" : val.ToString();
   10:     }
   11:  
   12:     // Failing that, attempt to get it from the custom field collection.
   13:     if ( _CustomFields.ContainsKey(fieldName) )
   14:         return _CustomFields[fieldName];
   15:     // Failing that, just return an empty string.
   16:     else
   17:         return "";
   18: }

So now we can get the value of an item’s property whether it’s native or custom – but how do we use XAML data binding to bind to the results of this method? Use a Value Converter:

    1: /// <summary>
    2: /// This takes as a parameter the name of the field to read from the
    3: /// DataItem, and returns the value of that field from the bound-to.
    4: /// DataItem.
    5: /// </summary>
    6: public class DataItemPropertyReader : IValueConverter
    7: {
    8:     public object Convert(object value, Type targetType,
    9:         object parameter, System.Globalization.CultureInfo culture)
   10:     {
   11:         return (value as DataItem)
   12:           .GetFieldValue(parameter.ToString());
   13:     }
   14:  
   15:     public object ConvertBack(object value, Type targetType,
   16:         object parameter, System.Globalization.CultureInfo culture)
   17:     {
   18:         throw new NotImplementedException();
   19:     }
   20: }

Declare an instance of this guy which is app-wide (say, in Styles.xaml):

    1: <!-- This is used by individual DataItems to bind to custom properties in items -->
    2: <MyNamespace:DataItemPropertyReader x:Key="DI" />

And now the users who write the XAML can bind like this:

    1: <TextBlock Text="{Binding Converter={StaticResource DI}, ConverterParameter=Name}" />
    2: <TextBlock Text="{Binding Converter={StaticResource DI}, ConverterParameter=Custom1}" />
    3: <TextBlock Text="{Binding Converter={StaticResource DI}, ConverterParameter=Address}" />

 

Displaying Custom XAML Dynamically

Now it comes time to read this XAML and display it. This has been shown before many times, so I won’t go into it too much. It’s fairly simple; the only trip-ups are name-spaces and data contexts.

At runtime, use user control replaces the contents of its layout as such:

    1: private void RedrawItem()
    2: {
    3:     string XAML = GetXAMLByStyleName( Item.StyleName );
    4:  
    5:     UIElement el = Common.CreateUIElementFromXAML(XAML);
    6:  
    7:     // Remove what was there, and add the new stuff.
    8:     LayoutRoot.Children.Clear();
    9:     LayoutRoot.Children.Add(el);
   10:  
   11:     // Set the context, so that data binding knows
   12:     // what to look at.
   13:     LayoutRoot.DataContext = this.Item;
   14: }
   15:  
   16: public static UIElement CreateUIElementFromXAML(string XAML)
   17: {
   18:     // Wrap the XAML in a grid, to guarantee one root element
   19:     XAML = string.Format("<Grid>{0}</Grid>", XAML);
   20:  
   21:     byte[] XAMLbytes = System.Text.Encoding.UTF8.GetBytes(XAML);
   22:     MemoryStream memstream = new MemoryStream(XAMLbytes);
   23:  
   24:     // Can only load the XAML if we know about the right namespaces.
   25:     // (You'd do well to centralize this bit of code)
   26:     ParserContext pc = new ParserContext();
   27:     pc.XmlnsDictionary.Add("",
   28:       "https://schemas.microsoft.com/winfx/2006/xaml/presentation");
   29:     pc.XmlnsDictionary.Add("x",
   30:       "https://schemas.microsoft.com/winfx/2006/xaml");
   31:     pc.XmlnsDictionary.Add("mc",
   32:       "https://schemas.openxmlformats.org/markup-compatibility/2006");
   33:     
   34:     UIElement el = (UIElement)XamlReader.Load(memstream, pc);
   35:     return el;
   36: }

As for performance; it’s not too bad. My app doesn’t need to do this for more than a few dozen items, so we haven’t really pushed it.

 

Avi