In my previous post I described how DataTrigger and MultiDataTrigger can be used to customise the style of a UserControl . Now I want to expand on the usage of these triggers, specifically to control Storyboard animations. In this post I will describe how to achieve this using dependency properties (DP) and logic in the XAML code-behind. In the next post I will detail how to move the logic out of the code-behind into a ViewModel in accordance with the Model-View-ViewModel pattern (MVVM).
The Demo Application
I created a small program for scrolling image panels to demonstrate controlling Storyboards using triggers. The main panel displays one image at a time and the user manually scrolls the images left and right by left clicking on a scroller control, which also indicates the total number of panels. See the video below for a demonstration:
The main panel control consists of two images; one for the currently displayed (on-screen) image and one for the previously displayed (off-screen) image:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
<Grid> <Grid x:Name="OffScreenLayout" Style="{StaticResource GridStyle}"> <Image x:Name="OffScreenImage" Style="{StaticResource ImageStyle}" Source="{Binding OffScreenImageSource}" > <Image.RenderTransform> <TranslateTransform x:Name="OffScreenImageTT" /> </Image.RenderTransform> </Image> </Grid> <Grid x:Name="OnScreenLayout" Style="{StaticResource GridStyle}"> <Image x:Name="OnScreenImage" Style="{StaticResource ImageStyle}" Source="{Binding OnScreenImageSource}" > <Image.RenderTransform> <TranslateTransform x:Name="OnScreenImageTT" /> </Image.RenderTransform> </Image> </Grid> </Grid> |
Scrolling
For simulating scrolling I update the source data of each image at the start of a scroll through the specified bindings ( OffScreenImageSource and OnScreenImageSource ). Then I translate both images either left or right by the width of the panel via a Storyboard animation. The Grid clips the final image so you only ever see a panel’s width worth of data. For each image I label the TranslateTransform property so that I can adjust it in the Storyboard animation. I use OffScreenImageTT and OnScreenImageTT to label the transform of both images. The Storyboard animations target the x-component of the labelled transforms. Interpolating between the x-component’s current value and a new value over a particular duration is achieved using EasingDoubleKeyFrames. In this case I want to update the on-screen image by scrolling the new image right by the width of a panel, from being off-screen, in 0.15 seconds:
1 2 3 4 5 |
<Storyboard x:Key="OnScrollRight"> <DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="X" Storyboard.TargetName="OnScreenImageTT"> <EasingDoubleKeyFrame KeyTime="0" Value="{Binding ScrollLeftWidth}"/> <EasingDoubleKeyFrame KeyTime="0:0:0.15" Value="0"/> </DoubleAnimationUsingKeyFrames> |
To complete the scroll action, I translate the old image off-screen by the same amount in the same amount of time:
1 2 3 4 5 |
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="X" Storyboard.TargetName="OffScreenImageTT"> <EasingDoubleKeyFrame KeyTime="0" Value="0"/> <EasingDoubleKeyFrame KeyTime="0:0:0.15" Value="{Binding ScrollRightWidth}"/> </DoubleAnimationUsingKeyFrames> </Storyboard> |
Before invoking the animations above I need to update the source that each Image control is bound to (i.e. the data stored in OnScreenImageSource and OffScreenImageSource ) by storing the currently selected pane. This logic is in the code-behind (not very MVVM but we will cover that in the next post):
1 2 3 4 5 6 7 8 9 |
private void ScrollPanelLeft() { if (mCurrentPanel == 0) mCurrentPanel = mPanelImages.Count - 1; else --mCurrentPanel; OffScreenImageSource = OnScreenImageSource; OnScreenImageSource = mPanelImages[mCurrentPanel]; } |
The application can scroll through any number of images for the panels. These could come from anywhere. For example they could be stored in a database that the application queries. However for the sake of simplicity in this demo I load them from an an XML file using an XML Serializer.
Triggering Storyboards
With the Storyboards all setup I can trigger them from XAML using either a DataTrigger or a MultiDataTrigger . In this instance I want to trigger a particular Storyboard based on multiple conditions, so I use a MultiDataTrigger :
1 2 3 4 5 6 7 8 9 10 11 |
<MultiDataTrigger> <MultiDataTrigger.Conditions> <Condition Binding="{Binding ScrollLeft}" Value="True" /> <Condition Binding="{Binding IsScrolling}" Value="False" /> <Condition Binding="{Binding WrapScroll}" Value="False" /> </MultiDataTrigger.Conditions> <Setter Property="{Binding IsScrolling}" Value="True" /> <MultiDataTrigger.EnterActions> <BeginStoryboard Storyboard="{StaticResource OnScrollLeft}"/> </MultiDataTrigger.EnterActions> </MultiDataTrigger> |
When the required conditions are met, the IsScrolling DP is set to prevent any further scrolling until the animation is complete. The EnterActions property contains a collection of TriggerActions that are executed once the trigger has been activated. I add the relevant BeginStoryboard action for invoking a scroll in the desired direction. You may have noticed the WrapScroll DP. This is set to true when scrolling involves wrapping around from left end to right end or vice versa.
Attaching Triggers
There are only three places in WPF where triggers can be attached: elements, styles and templates. In this instance attaching the triggers in a Style is ruled out as it requires invoking Storyboard animations that target named elements (i.e. OnScreenImageTT and OffScreenImageTT ). Data triggers cannot be attached directly to elements . Therefore these data triggers need to be attached in a template. In this example I use a ControlTemplate :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
<ControlTemplate x:Key="GridTemplate" TargetType="ContentControl"> <ControlTemplate.Resources> <Storyboard x:Key="OnScrollRight"> ... </Storyboard> <ControlTemplate.Resources> <ControlTemplate.Triggers> <MultiDataTrigger> ... </MultiDataTrigger> </ControlTemplate.Triggers> <Grid Width="{Binding TotalWidth}"> ... </Grid> </ControlTemplate> <Grid> <ContentControl Template="{StaticResource GridTemplate}"/> </Grid> |
Scroller Control
Since only one panel is on display at a time, it isn’t immediately obvious how many panels can be scrolled. Therefore I created another control that represents each panel with a radio button. This scroller control is required to display an arbitrary number of radio buttons, based on the number of panels loaded from the XML file. To achieve this I used an ImageBrush . An ImageBrush is a type of TileBrush that can display images as tiles. By setting the ImageBrush to stretch to fill the space in the Grid cell that it resides, I can dynamically update the number of radio buttons through a binding. Below I bind the total width of the Grid to the TotalWidth DP. This DP is calculated based on the width of a single button and the total number of panels in the code-behind. The parent Grid is defined by a single row and three columns where the end columns are the width of a single radio button for the arrow controls (see below), and the middle column fills the remaining space. I specify the position and size of each tile through the Viewport property. The Viewbox property enables me to specify the zoom factor of the image. By setting the ViewportUnits to Absolute I can then set the width and height of the Viewport to be that of a single radio button resulting in the tiling effect. To add a margin around each radio button I use the Viewport starting position and the Viewbox zoom factor. The Margin , Width and Height properties of the Image control need to be calculated based on the values of both the Viewport and Viewbox properties of the ImageBrush, so that the highlighted radio button Image is the same size as each radio button in the ImageBrush .
1 2 3 4 5 6 7 8 9 10 |
<Grid Width="{Binding TotalWidth}"> ... <Grid.ColumnDefinitions> <ColumnDefinition Width="{Binding ButtonWidthHeight}"/> <ColumnDefinition Width="*"/> <ColumnDefinition Width="{Binding ButtonWidthHeight}"/> </Grid.ColumnDefinitions>\ ... <Grid Grid.Row="0" Grid.Column="1"> <ImageBrush ImageSource="{StaticResource ButtonImage}" TileMode="Tile" AlignmentX="Center" Stretch="Uniform" Viewport="{Binding ViewportRect}" ViewportUnits="Absolute" Viewbox="{Binding ViewboxRect}" ViewboxUnits="RelativeToBoundingBox" /> |
1 2 3 4 5 6 7 8 |
ButtonWidthHeight = buttonWidthHeight; TotalWidth = (numPanels + 2) * ButtonWidthHeight; // 2 to account for the arrows ViewportRect = new Rect(buttonMargin, buttonMargin, ButtonWidthHeight, ButtonWidthHeight); var imageSizeComparedToButtonSize = 1.0 - ((buttonMargin * 2.0) / ButtonWidthHeight); // 2.0 to account for margin on both sides (i.e. top & bottomw, left & right) var zoomFactor = 1.0 / imageSizeComparedToButtonSize; ViewboxRect = new Rect(0.0, 0.0, zoomFactor, zoomFactor); HighlightedButtonWidthHeight = ButtonWidthHeight - (buttonMargin * 2.0f); // 2.0f to account for margin on both sides (i.e. top & bottomw, left & right) ButtonMargin = new Thickness(buttonMargin, buttonMargin, (TotalWidth - (3.0f * ButtonWidthHeight) + buttonMargin), buttonMargin); // 3.0f for both arrow buttons and a single radio button |
The control is bookended by arrow controls that react to mouse input for scrolling. Initiating a scroll was achieved by hooking up a custom routed event to the ButtonClickAction of each arrow control in the code-behind:
1 2 3 4 5 6 7 8 |
leftArrowControl.ButtonClickAction = () => { if (!IsScrolling) { var scrollLeftEventArgs = new ScrollRoutedEventArgs(ScrollRoutedEventArgs.Direction.Left, Scroll.ScrollEvent); RaiseEvent(scrollLeftEventArgs); } }; |
Summary
Triggers are a very useful way to manipulate a UI based on a particular condition. In this case I was playing Storyboard animations upon activating a trigger. Previously I have shown how to change control styles through triggers. They can also be used to set the values of DPs. I have only been concentrating on data triggers, however there are others such as the EventTrigger which can trigger an action based on an event being fired (e.g. MouseEnter event). There is also the standard Trigger which automatically resets the value of the property that it has changed once the condition is no longer met.
All of the logic that resided in the XAML code-behind made me uneasy and had a bit of “code smell” about it. Code-behind logic is difficult to test and potentially incurs unnecessary overhead if the UI ever changes. While this UI is very unlikely to change, as it was created specifically for this Blog post, real world UIs change regularly where new features are added over time. Having the UI tied to logic in the code-behind file can incur a higher cost for modification since both the logic and the UI might have to change. This prompted the adoption of the MVVM pattern and removing all logic from the code-behind files. I will cover this in the next post and also briefly describe how Expression Blend can be used to fill in the gaps in vanilla WPF for adopting the MVVM pattern. I will also provide a link to the code for the demo application.