Sunday, 6 January 2008

Playing with WPF

I wanted to explore some of the features of WPF today, so decided to knock up a simple example that would provide a contemporary (AKA: crap looking as it's designed by a developer!) user interface to a fictitious application

I wanted the layout to be skinnable and be able to change various aspects and colours of the application by adding extra XAML. Here's a screenshot of how the basic UI functions;

image image

The left image shows how the UI should look by default (albeit with a distinct lack of controls), whilst the image on the right shows the "reports" panel overlaid on top of the default UI. The overlays fade in and out as they are brought up and closed.

Pretty basic right? Well yeah, but it was a good exercise in learning some of the cool features of WPF. Lets start with the basic layout for the application. The following XAML is for the main window and it's panels.

   1: <DockPanel>
   2:     <!-- Header Area - branding -->
   3:     <Border Height="50" DockPanel.Dock="Top" 
   4:             BorderBrush="{DynamicResource BrandingLow}" 
   5:             BorderThickness="0,0,0,1" Padding="10,0,10,0" >
   6:             <TextBlock Opacity="1" FontFamily="Segoe" FontSize="24" 
   7:                        FontStretch="Normal" FontWeight="Light" TextWrapping="Wrap" 
   8:                        Foreground="{DynamicResource BrandingHi}" 
   9:                        VerticalAlignment="Bottom" Margin="0,0,0,5">
  10:                 <Run Foreground="{DynamicResource BrandingLow}">DC</Run><Run FontWeight="Normal">.Finances</Run>
  11:             </TextBlock>
  12:     </Border>
  13:     
  14:     <!-- Menu Area -->
  15:     <Menu DockPanel.Dock="Top" Margin="5,0,0,0" Style="{DynamicResource MenuStyle}">
  16:         <MenuItem Header="_Reports" x:Name="ReportsMenu" Style="{DynamicResource MenuItemStyle}" Click="ReportsMenu_Click" />
  17:         <MenuItem Header="_Admin" x:Name="AdminMenu" Style="{DynamicResource MenuItemStyle}" Click="AdminMenu_Click"/>
  18:     </Menu>
  19:  
  20:     <!-- Content Area -->
  21:     <Grid>
  22:         <!-- Main content - the account register -->
  23:         <local:RegisterPanel x:Name="pnlContent"/>
  24:  
  25:         <!-- Reports Pane - hides and shows as necessary -->
  26:         <local:ReportsPanel x:Name="pnlReports" Visibility="Hidden" PanelClosed="PanelClosed"/>
  27:  
  28:         <!-- Administration Pane - hides and shows as necessary -->
  29:         <local:AdminPanel x:Name="pnlAdministration" Visibility="Hidden" PanelClosed="PanelClosed"/>
  30:     </Grid>
  31: </DockPanel>

As you can see, we basically split the form into 3 sections. The branding area at the top, the menu area and finally the content area which hosts 3 separate user controls, 2 of which are set to be hidden (the admin and reports panels).

Skinning using resources and styles

Notice that in order to allow the application to be "skinned" and have it's colours changed etc, we have made use of {DynamicResource} in a number of places for brushes and for styles. These are then encapsulated into a separate XAML file. For example, the XAML for the BrandingLow resource is as follows;

<SolidColorBrush x:Key="BrandingLow" Color="#FFCFD3DA"/>

This defines the branding low light brush, and can then be referenced as {DynamicResource BrandlingLow} anywhere in XAML that requires a brush. But to get the resource linked to the application, we must add it to the applications resource dictionary. This is done in app.xaml as follows;

   1: <Application x:Class="DC.Finances.App"
   2:     xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
   3:     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
   4:     StartupUri="MainWindow.xaml">
   5:  
   6:     <Application.Resources>
   7:         
   8:         <!-- Pull in the merged resources - use default skin -->
   9:         <ResourceDictionary>
  10:             <ResourceDictionary.MergedDictionaries>
  11:                 <ResourceDictionary Source="Skins\Default\Skin.xaml"/>
  12:             </ResourceDictionary.MergedDictionaries>
  13:         </ResourceDictionary>
  14:  
  15:     </Application.Resources>
  16: </Application>

This embeds the skin.xaml file as a resource. Within this XAML we can either go ahead and define the elements we need or we can split it down into separate files. In this case I chose to split it into additional files to separate the brushes and styles etc.

Styles are interesting in WPF. They allow you to define a common look and feel for different element types and also add behaviour. Take for example, the style used for a menu item as seen in the example above;

   1: <MenuItem Header="_Reports" x:Name="ReportsMenu" 
   2:     Style="{DynamicResource MenuItemStyle}" Click="ReportsMenu_Click" />
   3:  
   4: <MenuItem Header="_Admin" x:Name="AdminMenu" 
   5:     Style="{DynamicResource MenuItemStyle}" Click="AdminMenu_Click"/>

The MenuItemStyle is then defined as;

   1: <Style x:Key="MenuItemStyle" TargetType="{x:Type MenuItem}">
   2:     <Setter Property="Background" Value="Transparent"/>
   3:     <Setter Property="Foreground" Value="{DynamicResource FontColor}"/>
   4:     <Setter Property="FontSize" Value="10"/>
   5:     <Setter Property="Padding" Value="8,5,20,5"/>
   6:     <Style.Triggers>
   7:         <Trigger Property="IsHighlighted" Value="true">
   8:             <Setter Property="Background" 
   9:                 Value="{DynamicResource MenuActiveBackgroundBrush}"/>
  10:             <Setter Property="Foreground" 
  11:                 Value="{DynamicResource MenuActiveFontColor}"/>
  12:             <Setter Property="BorderBrush" Value="#FF000000"/>
  13:         </Trigger>
  14:     </Style.Triggers>
  15: </Style>

This defines a style named MenuItemStyle that applies to elements of type MenuItem. It sets the background colour to transparent, the foreground colour to the standard font colour from the resources, the font to use 10 pixels and to use a specific padding. In addition it then defines some rudimentary behaviour in the form of triggers.

When the IsHighlighted property of the host item (a MenuItem element) is set to true, the three setters that are specified within the trigger are invoked. In this case it changes the background colour, foreground colour and outline brush to be of specific colours. This provides us with a nice rollover effect on the menus that use this style;

image image

Applying animations

As mentioned above, when we click reports or admin on the menu, we want to bring up the appropriate panel, and play a little animation to fade it in, then fade it out when we close it. This is incredibly easy in WPF.

Here's the Window.Resources section from the main form, which defines two storyboard based animations for fading up and down elements;

   1: <Window.Resources>
   2:         <!-- Animation for showing panels -->
   3:         <Storyboard x:Key="ShowPanel">
   4:             <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" Storyboard.TargetProperty="(UIElement.Opacity)">
   5:                 <SplineDoubleKeyFrame KeyTime="00:00:00" Value="0"/>
   6:                 <SplineDoubleKeyFrame KeyTime="00:00:00.3000000" Value="1"/>
   7:             </DoubleAnimationUsingKeyFrames>
   8:             <ObjectAnimationUsingKeyFrames BeginTime="00:00:00" Storyboard.TargetProperty="(UIElement.Visibility)">
   9:                 <DiscreteObjectKeyFrame KeyTime="00:00:00" Value="{x:Static Visibility.Visible}"/>
  10:                 <DiscreteObjectKeyFrame KeyTime="00:00:00.3000000" Value="{x:Static Visibility.Visible}"/>
  11:             </ObjectAnimationUsingKeyFrames>
  12:         </Storyboard>
  13:         
  14:         <!-- Animation for hiding panels -->
  15:         <Storyboard x:Key="HidePanel">
  16:             <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" Storyboard.TargetProperty="(UIElement.Opacity)">
  17:                 <SplineDoubleKeyFrame KeyTime="00:00:00" Value="1"/>
  18:                 <SplineDoubleKeyFrame KeyTime="00:00:00.3000000" Value="0"/>
  19:             </DoubleAnimationUsingKeyFrames>
  20:             <ObjectAnimationUsingKeyFrames BeginTime="00:00:00" Storyboard.TargetProperty="(UIElement.Visibility)">
  21:                 <DiscreteObjectKeyFrame KeyTime="00:00:00" Value="{x:Static Visibility.Visible}"/>
  22:                 <DiscreteObjectKeyFrame KeyTime="00:00:00.3000000" Value="{x:Static Visibility.Hidden}"/>
  23:             </ObjectAnimationUsingKeyFrames>
  24:         </Storyboard>
  25:     </Window.Resources>

Lines 3 - 12 show the animation declaration for fading up an object. The storyboard follows two animation paths, one on the opacity of the target object and one on the visibility property of the target object. For fading up, we see a spline keyframe animation moving the value of opacity from 0 to 1 over the course of 1/3 of a second. This then fades smoothly between the values over the time period specified. Also, we use a discrete keyframe animation to set the visibility property to true when the animation starts.

Lines 15-23 do the exact same thing but in reverse, and the discrete keyframe animation on the visibility property sets the object to hidden at the end.

Simple huh? So the only thing left to do is to trigger the animation when the menu item is clicked and bring the appropriate panel up.

The possible options here are;

a) User clicks the reports or admin menu

a1) No current panel - just fade up the selected panel

a2) Current panel visible - fade it down, then fade up the selected panel

b) User clicks the close button on a panel

b1) Fade down the active panel

To implement this logic, I chose to use the code behind to track an active FrameworkElement, then when I click on a menu option, I invoke the fade animation on the active element, fade up the new element and make it the active one.

Triggering animations is as straight forward as;

((Storyboard)this.Resources["HidePanel"]).Begin(_activeChildComponent);

As such, the entire logic for showing/hiding panels etc is shown below;

   1: public partial class Window1 : Window
   2: {
   3:     // This will be used to track any active open panels
   4:     FrameworkElement _activeChildComponent = null;
   5:     
   6:      /// <summary>
   7:     /// User clicked reports menu, hide the current panel if there is one and show the reports pane
   8:     /// </summary>
   9:     /// <param name="sender"></param>
  10:     /// <param name="e"></param>
  11:     private void ReportsMenu_Click(object sender, RoutedEventArgs e)
  12:     {
  13:         HideActivePanel();
  14:         ShowPanel(pnlReports);
  15:     }
  16:  
  17:     /// <summary>
  18:     /// User clicked admin menu, hide the current panel if there is one and show the reports pane
  19:     /// </summary>
  20:     /// <param name="sender"></param>
  21:     /// <param name="e"></param>
  22:     private void AdminMenu_Click(object sender, RoutedEventArgs e)
  23:     {
  24:         HideActivePanel();
  25:         ShowPanel(pnlAdministration);
  26:     }
  27:     
  28:     /// <summary>
  29:     /// User clicked to close the panel
  30:     /// </summary>
  31:     /// <param name="sender"></param>
  32:     /// <param name="e"></param>
  33:     private void PanelClosed(object sender, EventArgs e)
  34:     {
  35:         HideActivePanel();
  36:     }
  37:  
  38:     /// <summary>
  39:     /// If an active panel is open, this will fade it down
  40:     /// </summary>
  41:     private void HideActivePanel()
  42:     {
  43:         if (_activeChildComponent == null) return;
  44:         ((Storyboard)this.Resources["HidePanel"]).Begin(_activeChildComponent);
  45:         // TODO: Need to find a mechanism to wait until the fade has completed
  46:         _activeChildComponent = null;
  47:     }
  48:  
  49:     /// <summary>
  50:     /// Sets the panel specified to be the active panel
  51:     /// </summary>
  52:     /// <param name="target"></param>
  53:     private void ShowPanel(FrameworkElement target)
  54:     {
  55:         ((Storyboard)this.Resources["ShowPanel"]).Begin(target);
  56:         _activeChildComponent = target;
  57:     }
  58: }

Have you spotted the deliberate mistake? I've not yet worked out how to wait for the animation to complete, so when you go from panel to panel, the current panel doesn't get chance to fade down properly before the new panel fades up... I'll save that for another day though.

2 comments:

  1. Thanks for the post! Did you ever discover how to get the fade out animation to complete before the fade in animation begins, and if so, can you share? I'm currently stuck trying to figure this out.

    ReplyDelete
  2. I didn't and I haven't tried it out yet as I've not had much chance to work with WPF recently.

    ReplyDelete