Prettier repetitive XAML with Attached Properties

Datetime:2016-08-23 03:58:49          Topic:          Share

XAML is an incredible markup language for both prototyping concepts and building rich, fluid experiences. The set of capabilities is large, and for some a bit unexplored. One of those areas is a concept called attached properties. An attached property is essentially a global property, which can be set on any object.

Attached properties have been around even in WPF (Windows Presentation Foundation), but this post is directed towards attached properties in the Universal Windows Platform. They are however much similar. Get started with UWP (Universal Windows Platform) development here: https://msdn.microsoft.com/library/windows/apps/xaml/dn609832.aspx#target_win10

So what does attach properties actually solve? There are many use cases, one is to reflect unique values in child elements of a parent. So that the parent can use these values and apply necessary logic (e.g. docking settings). Another use case is surrounding custom animations.

There are many built in animation capabilities in XAML, and you also have the ability to create your own custom animations. Applying your custom animation on many elements may result in very large and repetitive resource in your XAML. These will be consuming to maintain and update.

To get around this problem, you can use attached properties. The following example consists of three elements, applying the same animation – but without cluttering down your XAML markup. Attached properties can help us to create a single process for applying our custom animations.

Its markdown looks like the following:

<GridBackground="{StaticResource AccentColor}">
    <StackPanelStyle="{StaticResource ContentStackPanelStyle}">
        <TextBlockStyle="{StaticResource HeaderTextBlockStyle}"
                    xamlExtensions:ElasticUI.IsEnabled="True"
                    xamlExtensions:ElasticUI.AutoStart="True"
                    xamlExtensions:ElasticUI.StartDelay="0:0:1.0">
            Lorem ipsum dolor
        </TextBlock>
        <TextBlockStyle="{StaticResource BodyTextBlockStyle}"
                    xamlExtensions:ElasticUI.IsEnabled="True"
                    xamlExtensions:ElasticUI.AutoStart="True"
                    xamlExtensions:ElasticUI.StartDelay="0:0:1.15">
            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed
            do eiusmod tempor incididunt ut labore et dolore magna aliqua.
            Ut enim ad minim veniam, quis nostrud exercitation ullamco
            laboris nisi ut aliquip ex ea commodo consequat.
        </TextBlock>
        <ButtonStyle="{StaticResource ButtonStlye}"
                    xamlExtensions:ElasticUI.IsEnabled="True"
                    xamlExtensions:ElasticUI.AutoStart="True"
                    xamlExtensions:ElasticUI.StartDelay="0:0:1.3">
            CONTINUE
        </Button>
    </StackPanel>
</Grid>

Rather tiny and great for code readability and reuse. What has been introduced is three different (attached) properties, located in the xamlExtensions namespace.

  • ElasticUI.IsEnabled: Triggers wither or not to inject needed resources into the FrameworkElement .
  • ElasticUI.AutoStart: Configures the event handler for the Loaded event – which ultimately triggers the animation if requested.
  • ElasticUI.StartDelay: Used when starting the animation – either right away or with a delay.

The more elements in need of this particular animation – the more code we can reuse using attached properties. Notice that I will be using the FrameworkElement object as the base throughout this blog post – this is because the FrameworkElement is what defines the Resources property, which we need for storing the animations (Storyboard).

Anatomy of an Attached Property

An attached property is defined pretty easily, and can be done in a few different flavors. Be sure to create a public containing class for the attached properties – as XAML needs to be able to access your class. I defined mine as such:

public static class ElasticUI
{
 
}

In addition, I made my class static as all of the operations and properties would be static. Now the first step is to define the DependencyProperty.

public static readonly DependencyProperty [PROPERTY_NAME]Property = 
            DependencyProperty.RegisterAttached("[PROPERTY_NAME]", 
                typeof([PROPERTY_TYPE]), typeof(ElasticUI), 
                new PropertyMetadata(default([PROPERTY_TYPE])));

You are required to specify the property name ( [PROPERTY_NAME] ) and property type ( [PROPERTY_TYPE] ). The DependencyProperty object does a lot more for you than just hosting getters and setters. For instance, they care of the logic needed to notify the view about changes when using bindings.

To complete the attached property, you will need to create the getter and setter methods. They are static implementations which will reflect the value onto the DependencyObject itself (the FrameworkElement in our case). Make sure to specify the same property name and property type for the methods.

public static [PROPERTY_TYPE] Get[PROPERTY_NAME](DependencyObjectobj)
{
    return ([PROPERTY_TYPE])obj.GetValue([PROPERTY_NAME]);
}
 
public static void Set[PROPERTY_NAME](DependencyObjectobj, [PROPERTY_TYPE] value)
{  
    obj.SetValue([PROPERTY_NAME], value);
}

Injecting Storyboards

When inserting resources into a FrameworkElement using an attached property, you will most likely be doing so using C# or any other language supported in the UWP. While some things will still be very natural to keep in your global resources, you should do so. But as soon as you notice that you’re duplicating pieces, it could be time to think about an alternate route.

Storyboards are a bit tricky, because they need to be attached to a DependencyObject – they can be a hassle to reuse. It’s also not meant to work with multiple targets. You can run into odd behaviors if you’re trying to switch targets during different states of the Storyboard (such as value resets, etc.). A better approach would be to have a separate Storyboard for each FrameworkElement, but that gets you into the repetitive problem. Attached properties can reduce the repetitiveness for you – while still maintaining the notion in XAML that we are doing something underneath the hood.

The Storyboards injected via code should absolutely leverage styles and values from the global resource dictionaries if needed. As done below (view Merged resource dictionaries at: https://msdn.microsoft.com/en-us/windows/uwp/controls-and-patterns/resourcedictionary-and-xaml-resource-references) .

<!-- Animations -->
<Durationx:Key="ElasticAnimationOpacityDuration">0:0:.25</Duration>
<x:Doublex:Key="ElasticAnimationOpacityFrom">0</x:Double>
<x:Doublex:Key="ElasticAnimationOpacityTo">1</x:Double>
<Durationx:Key="ElasticAnimationRepositionDuration">0:0:.75</Duration>
<x:Doublex:Key="ElasticAnimationRepositionFrom">50</x:Double>
<x:Doublex:Key="ElasticAnimationRepositionTo">0</x:Double>
<x:Int32x:Key="ElasticAnimationRepositionOscillations">2</x:Int32>
<x:Doublex:Key="ElasticAnimationRepositionSpringiness">7</x:Double>
<EasingModex:Key="ElasticAnimationRepositionEasingMode">EaseOut</EasingMode>
<ElasticEasex:Key="ElasticAnimationRepositionElastingEase"
                Oscillations="{StaticResource ElasticAnimationRepositionOscillations}"
                Springiness="{StaticResource ElasticAnimationRepositionSpringiness}"
                EasingMode="{StaticResource ElasticAnimationRepositionEasingMode}">
</ElasticEase>

You can access the global resources via the Application.Current.Resources property.

private static StoryboardCreateStoryboard(ResourceDictionaryresources, DependencyObjectelement)
{
    // Create Opacity DoubleAnimation.
    var opacityAnimation = new DoubleAnimation
    {
        From = (double)resources[ElasticAnimationOpacityFromKey],
        To = (double)resources[ElasticAnimationOpacityToKey],
        Duration = TimeSpan.FromMilliseconds(1000 *
        (double)resources[ElasticAnimationOpacityDurationKey])
    };
    Storyboard.SetTargetProperty(opacityAnimation, "Opacity");
 
    // Create Reposition DoubleAnimation.
    var repositionAnimation = new DoubleAnimation
    {
        From = (double)resources[ElasticAnimationRepositionFromKey],
        To = (double)resources[ElasticAnimationRepositionToKey],
        Duration = TimeSpan.FromMilliseconds(1000 *
        (double)resources[ElasticAnimationRepositionDurationKey]),
        EasingFunction = (EasingFunctionBase)resources[ElasticAnimationRepositionElastingEaseKey]
    };
    Storyboard.SetTargetProperty(repositionAnimation,
        "(UIElement.RenderTransform).(CompositeTransform.TranslateY)");
 
    // Create Storyboard and add the DoubleAnimations.
    var storyboard = new Storyboard();
    Storyboard.SetTarget(storyboard, element);
    storyboard.Children.Add(opacityAnimation);
    storyboard.Children.Add(repositionAnimation);
    return storyboard;
}

Now using the above, and the anatomy of an attached property. I proceeded to implement the ElasticUI class as such:

public static class ElasticUI
{
    private const string ElasticAnimationKey = "ElasticAnimation";
    private const string ElasticAnimationRepositionFromKey = "ElasticAnimationRepositionFrom";
    private const string ElasticAnimationOpacityFromKey = "ElasticAnimationOpacityFrom";
    private const string ElasticAnimationOpacityToKey = "ElasticAnimationOpacityTo";
    private const string ElasticAnimationOpacityDurationKey = "ElasticAnimationOpacityDuration";
    private const string ElasticAnimationRepositionToKey = "ElasticAnimationRepositionTo";
    private const string ElasticAnimationRepositionDurationKey = "ElasticAnimationRepositionDuration";
    private const string ElasticAnimationRepositionElastingEaseKey = "ElasticAnimationRepositionElastingEase";
 
    public static readonly DependencyPropertyIsEnabledProperty = DependencyProperty.RegisterAttached(
        "IsEnabled", typeof (bool), typeof (ElasticUI), new PropertyMetadata(default(bool)));
 
    public static void SetIsEnabled(DependencyObjectelement, bool value)
    {
        element.SetValue(IsEnabledProperty, value);
 
        // Get objects and check for null.
        var frameworkElement = elementas FrameworkElement;
        var resources = Application.Current.Resources;
 
        if (frameworkElement == null || resources == null)
        {
            return;
        }
 
        if (value)
        {
            // If the Storyboard has already been added, no need to do it again.
            if (frameworkElement.Resources.ContainsKey(ElasticAnimationKey))
            {
                return;
            }
 
            // Set RenderTransform and default values.
            frameworkElement.RenderTransformOrigin = new Point(0.5, 0.5);
            frameworkElement.RenderTransform = new CompositeTransform
            {
                ScaleX = 1,
                ScaleY = 1,
                TranslateY = (double) resources[ElasticAnimationRepositionFromKey]
            };
            frameworkElement.Opacity = 0;
 
            // Create the Storyboard.
            var storyboard = CreateStoryboard(resources, element);
 
            // Add the Storyboard to the Resources of the FrameworkElement.
            frameworkElement.Resources.Add(ElasticAnimationKey, storyboard);
 
            // Add event handler.
            frameworkElement.Loaded += OnFrameworkElementLoaded;
        }
        else
        {
            if (!frameworkElement.Resources.ContainsKey(ElasticAnimationKey))
            {
                return;
            }
 
            // Remove the Storyboard.
            frameworkElement.Resources.Remove(ElasticAnimationKey);
 
            // Remove event handler.
            frameworkElement.Loaded -= OnFrameworkElementLoaded;
        }
    }
 
    public static bool GetIsEnabled(DependencyObjectelement)
    {
        return (bool) element.GetValue(IsEnabledProperty);
    }
 
    public static readonly DependencyPropertyAutoStartProperty = DependencyProperty.RegisterAttached(
        "AutoStart", typeof(bool), typeof(FrameworkElement), new PropertyMetadata(default(bool)));
 
    public static void SetAutoStart(DependencyObjectelement, bool value)
    {
        element.SetValue(AutoStartProperty, value);
    }
 
    public static bool GetAutoStart(DependencyObjectelement)
    {
        return (bool)element.GetValue(AutoStartProperty);
    }
 
    public static readonly DependencyPropertyStartDelayProperty = DependencyProperty.RegisterAttached(
        "StartDelay", typeof(Duration), typeof(FrameworkElement), new PropertyMetadata(default(Duration)));
 
    public static void SetStartDelay(DependencyObjectelement, Durationvalue)
    {
        element.SetValue(StartDelayProperty, value);
    }
 
    public static DurationGetStartDelay(DependencyObjectelement)
    {
        return (Duration)element.GetValue(StartDelayProperty);
    }
 
    private static StoryboardCreateStoryboard(ResourceDictionaryresources, DependencyObjectelement)
    {
        // Create Opacity DoubleAnimation.
        var opacityAnimation = new DoubleAnimation
        {
            From = (double)resources[ElasticAnimationOpacityFromKey],
            To = (double)resources[ElasticAnimationOpacityToKey],
            Duration = TimeSpan.FromMilliseconds(1000 *
            (double)resources[ElasticAnimationOpacityDurationKey])
        };
        Storyboard.SetTargetProperty(opacityAnimation, "Opacity");
 
        // Create Reposition DoubleAnimation.
        var repositionAnimation = new DoubleAnimation
        {
            From = (double)resources[ElasticAnimationRepositionFromKey],
            To = (double)resources[ElasticAnimationRepositionToKey],
            Duration = TimeSpan.FromMilliseconds(1000 *
            (double)resources[ElasticAnimationRepositionDurationKey]),
            EasingFunction = (EasingFunctionBase)resources[ElasticAnimationRepositionElastingEaseKey]
        };
        Storyboard.SetTargetProperty(repositionAnimation,
            "(UIElement.RenderTransform).(CompositeTransform.TranslateY)");
 
        // Create Storyboard and add the DoubleAnimations.
        var storyboard = new Storyboard();
        Storyboard.SetTarget(storyboard, element);
        storyboard.Children.Add(opacityAnimation);
        storyboard.Children.Add(repositionAnimation);
        return storyboard;
    }
 
    private static async void OnFrameworkElementLoaded(object sender, RoutedEventArgs e)
    {
        var frameworkElement = senderas FrameworkElement;
        if (frameworkElement == null)
        {
            return;
        }
 
        // Check if AutoStart is requested.
        var autoStart = GetAutoStart(frameworkElement);
        if (!autoStart)
        {
            return;
        }
 
        // Get Storyboard.
        var storyboard = frameworkElement.Resources[ElasticAnimationKey] as Storyboard;
        if (storyboard == null)
        {
            return;
        }
 
        // Get start delay.
        var startDelay = GetStartDelay(frameworkElement);
        if (startDelay.HasTimeSpan && startDelay.TimeSpan.TotalMilliseconds > 0)
        {
            await Task.Delay(startDelay.TimeSpan);
            await CoreWindow.GetForCurrentThread().
                Dispatcher.RunIdleAsync(h => storyboard.Begin());
        }
        else
        {
            storyboard.Begin();
        }
    }
}

ElasticUI.IsEnabledchecks if it needs to create the Storyboard and add it – or simply remove it from the FrameworkElement’s resources. It also hooks up the Loaded event for the element, which will automatically start the animation if the ElasticUI.AutoStart property is set to true. If it is, it will acquire the ElasticUI.StartDelay value and hold for the requested time if needed.

The final step is to reference the ElasticUI class (or your own class for the attach properties) in the XAML views:

xmlns:xamlExtensions="using:App4.XamlExtensions"

My final view looked like this:

<Page
    x:Class="App4.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:xamlExtensions="using:App4.XamlExtensions"
    mc:Ignorable="d"
    RequestedTheme="Dark">
    
    <GridBackground="{StaticResource AccentColor}">
        <StackPanelStyle="{StaticResource ContentStackPanelStyle}">
            <TextBlockStyle="{StaticResource HeaderTextBlockStyle}"
                      xamlExtensions:ElasticUI.IsEnabled="True"
                      xamlExtensions:ElasticUI.AutoStart="True"
                      xamlExtensions:ElasticUI.StartDelay="0:0:1.0">
                Lorem ipsum dolor
            </TextBlock>
            <TextBlockStyle="{StaticResource BodyTextBlockStyle}"
                      xamlExtensions:ElasticUI.IsEnabled="True"
                      xamlExtensions:ElasticUI.AutoStart="True"
                      xamlExtensions:ElasticUI.StartDelay="0:0:1.15">
                Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed
                do eiusmod tempor incididunt ut labore et dolore magna aliqua.
                Ut enim ad minim veniam, quis nostrud exercitation ullamco
                laboris nisi ut aliquip ex ea commodo consequat.
            </TextBlock>
            <ButtonStyle="{StaticResource ButtonStlye}"
                      xamlExtensions:ElasticUI.IsEnabled="True"
                      xamlExtensions:ElasticUI.AutoStart="True"
                      xamlExtensions:ElasticUI.StartDelay="0:0:1.3">
                CONTINUE
            </Button>
        </StackPanel>
    </Grid>
</Page>

When launching, the following result is produced:

The XAML view is extremely clean and easy to read. All of the animations originate from a single injection process, which allows you to make modifications and changes in the future – which will apply to all of your instances.

This is merely a simple illustration of what can be achieved using attached properties. You can get much more complex by hooking into more events and creating bindings. If you want to learn more about UWP development, head to: https://msdn.microsoft.com/library/windows/apps/xaml/dn609832.aspx#target_win10

-Simon Jaeger