Introduction
A couple of RIAs I developed shared some similar requirements in one particular aspect: for the key calculation result UI, as well as the critical numbers displayed to the users, it's user-friendly to animate the numbers when it changes, because the tumbling effects bring attention to the changing fields, helping the user to make more sense of their inputs. The NumberTumbler
custom control abstracts out those generic logics and visuals (default) for animating changing numbers, it can be easily re-used in different Silverlight applications.
This article presents the design and interface considerations together with implementation details for NumberTumbler
custom control in Silverlight 3 Beta. It also makes notes about some lessons learned during the custom control development process. The companion downable code includes a Visual Studio 2008 SP1 solution with 3 projects: the ASP.NET Web Project that wraps the Silverlight 3 application, a Silverlight 3 application project that demonstrates the capabilities of the NumberTumbler
control, and finally, the NumberTumbler
custom control class library project. Although this is all about a specific custom control, the process and techniques are applicable to other custom control development.
If you have Silverlight 3 Beta plug-in installed, you can take a look at the sample Silverlight 3 Beta application. If you are interested about how it works, read on. :-)
What Does NumberTumbler Do?
Usually, the calculated or monitored number field in RIA UI is a read only label-like field, and its text property is data bound to the application data model, say model.calcResult
. The application business logic processes user data, then updates model.calcResult
value. Whenever model.calcResult
changes, Silverlight Data Binding engine will notify the UI element to update the display. It's a quick and sudden display update, sometimes user does not get a chance to notice the update. NumberTumbler
has all the regular data binding capabilities, whenever model.calcResult
changes, it not only tumbles the display number from previous value to the updated one, it also presents some subtle visual indicator to the users about whether the number is going up or down ---- useful when monitoring a stock quote for example.
At the high level, no changes to application logic at all, calculation or monitoring mechanism runs as usual, data binding expression as before, just replace the regular label-like control with the NumberTumbler
, it encapsulates all the logic and state manages within itself, and your application will provide better experiences for "changing numbers".
NumberTumbler
can still have customized look and feel, we get this customization for free by Custom Control. Additionally, common attributes, like font, size, color, etc., will be customizable as well. At the minimum, when no concrete property value is set to the instance, NumberTumbler
will function with default values.
As for the tumbling effects itself, the duration of the number animation from previous value to a new value is customizable too. Because some application may need a longer duration to show the calculation in progress, others may require brief updates visually.
The default Control Template of NumberTumbler
also provides the "subtle" visual indicator when it's tumbling: the left hand side red blinking down arrow and the right hand side green blinking up arrow. Those visual indicators will be invisible when there are no number updates.
That's pretty much all the "jobs" NumberTumbler
will do, let's build it.
Control Contract
The control contract is the program interface that dictates how application code interacts with NumberTumbler
. Based on the specification listed in the above section, NumberTumbler
should provide 2 custom data bindable properties, one for the number it displays (Amount
property), another for how long the tumbling should last (TumbleSeconds
property). Since these properties are data bindable and participate in animations, they are implemented as 2 public Dependency Properties. Fig.1. shows the code in NumberTumbler.cs:
Fig.1. Public Bindable Properties in NumberTumbler
#region Amount Dependency Property ---- Interaction
public double Amount
{
get { return (double)GetValue(AmountProperty); }
set { this.FromAmount = this.Amount; SetValue(AmountProperty, value); }
}
public static readonly DependencyProperty AmountProperty =
DependencyProperty.Register("Amount", typeof(double), typeof(NumberTumbler),
new PropertyMetadata(new PropertyChangedCallback
(NumberTumbler.OnAmountPropertyChange)));
private static void OnAmountPropertyChange
(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
NumberTumbler ctrl = d as NumberTumbler;
ctrl.OnAmountChange((double)e.NewValue);
}
#endregion
#region TumbleSeconds Dependency Property
public double TumbleSeconds
{
get { return (double)GetValue(TumbleSecondsProperty); }
set { SetValue(TumbleSecondsProperty, value); }
}
public static readonly DependencyProperty TumbleSecondsProperty =
DependencyProperty.Register("TumbleSeconds",
typeof(double), typeof(NumberTumbler),
new PropertyMetadata(2.0, new PropertyChangedCallback
(NumberTumbler.OnTumbleSecondsPropertyChange)));
private static void OnTumbleSecondsPropertyChange
(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
NumberTumbler ctrl = d as NumberTumbler;
ctrl.OnDurationChange();
}
#endregion
Both properties have callback methods registered, corresponding instance methods (OnAmountChange
and OnDurationChange
) will be invoked whenever property value changes. This callback mechanism is crucial to start the tumbling animation when Silverlight data bind engine notifies NumberTumbler
the Amount
just changed, we'll see the details when we get to the animation logic section.
One thing worth paying some attention to is how the default value of 2 is registered via PropertyMetadata
for TumbleSeconds
, this would allow the tumble animation to run in 2 seconds even when the application code doesn't provide the concrete value for TumbleSeconds
. It's also valuable when the property is data bound to other UI element, more on this when we discuss the demo project.
What above is the program contract for NumberTumbler
. It's also important to provide "Designer Contract" to allow designers and tools to better understand the control structures, and enable the skinning and templating support in Expression Blend.
NumberTumbler
's "designer contract" is still based on the requirements listed in the section above. It needs a part to display the number, together with 3 visual states: Normal
, TumbleUp
and TumbleDown
:
Fig.2. Control Contract for Parts and States
[TemplatePart(Name = "TumblerViewer", Type = typeof(FrameworkElement))]
[TemplatePart(Name = "TumblerDownMark", Type = typeof(FrameworkElement))]
[TemplatePart(Name = "TumblerUpMark", Type = typeof(FrameworkElement))]
[TemplateVisualState(Name = "Normal", GroupName = "CommonStates")]
[TemplateVisualState(Name = "TumbleUp", GroupName = "CommonStates")]
[TemplateVisualState(Name = "TumbleDown", GroupName = "CommonStates")]
public class NumberTumbler : Control
{
...
}
Although Silverlight Custom Control attributed parts and states are not required by the runtime, in NumberTumbler
's case, the TumblerViewer
part is intended to display the number (Amount
property) visually, it's required by the control logic. Without the TumblerDownMark
or TumblerUpMark
parts, there would be no visual indicator when number moves down or up, but the number is still tumbling and displayed, the essential functionality still works. But if the TumblerViewer
part is missing, no number will be displayed. The code in override OnApplyTemplate
method will make sure TumblerViewer
part's existence:
Fig.3. Make sure parts exist
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
this._tumblerViewer = (FrameworkElement)GetTemplateChild("TumblerViewer");
if (null == this._tumblerViewer)
throw new NotImplementedException("Template Part TumblerViewer
is required to display the tumbling number.");
VisualStateManager.GoToState(this, "Normal", false);
}
When reviewing the "jobs" that NumberTumbler
should do listed in the last section, most of them are covered by the program public
properties and the 3 parts and 3 states in designer contract. What about the customizations for font, size, color, etc.? Those properties are inherited from the base class (Control
class), so we get them for free.
Actually, if the base class is TextBlock
, we could inherit more useful properties, like WordWraps
, etc.
From a technical perspective, it makes more sense to derive NumberTumbler
from TextBlock
, rather than Control
, because NumberTumbler
only needs to extend the functionality in TextBlock
. Unfortunately, Silverlight 3 Beta's TextBlock
is a sealed class. There are more sealed control classes than I expected, I don't quite understand why TextBlock
needs to be sealed, this is the first lesson learned for building custom control: only a handful of Silverlight Controls included in framework can be derived from, many of them are sealed. The base class of your custom control is usually Control
, ContentControl
or ItemsControl
. Wish this will change in Silverlight 3 RTW.
Default Control Template
Now we defined NumberTumbler
control's program and designer contract. It's time to provide the default control template in generic.xaml under themes folder in the NumberTumbler
class library project. The default control template provides all the parts and states listed in the class attributes, every visual tree element in the template can be customized by application skinning or in Expression Blend:
Fig.4. generic.xaml skeleton code for the default control template
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x=http://schemas.microsoft.com/winfx/2006/xaml
xmlns:hanray="clr-namespace:Hanray">
-->
<Style TargetType="hanray:NumberTumbler">
<Setter Property="Template">
<Setter.Value>
-->
<ControlTemplate TargetType="hanray:NumberTumbler">
-->
<Grid x:Name="LayoutRoot">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
-->
<Image x:Name="TumblerDownMark" Margin="10,10,0,10"
Source="NumberTumbler;component/themes/downimg.png" />
-->
<TextBlock x:Name="TumblerViewer" Margin="0,10,0,10"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="{TemplateBinding AmountStr}" />
-->
<Image x:Name="TumblerUpMark" Margin="0,10,10,10"
Source="NumberTumbler;component/themes/upimg.png" />
</StackPanel>
-->
<VisualStateManager.VisualStateGroups>
-->
<VisualStateGroup x:Name="CommonStates">
-->
<VisualState x:Name="Normal">
...
</VisualState>
<VisualState x:Name="TumbleUp">
...
</VisualState>
<VisualState x:Name="TumbleDown">
...
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
You may have noticed the Text
property of TumblerView
part (TextBlock
) is bound to AmountStr
instead of Amount
by TemplateBinding
. Why not TemplateBinding
to the public Amount
property and use IValueConverter
instance to convert the double
type Amount
to the right format in string
? Like:
Fig.5. TemplateBinding Experiment
<Grid.Resources>
<hanray:AmountFormatter x:Key="amtFormatter" />
</Grid.Resources>
-->
<TextBlock x:Name="TumblerViewer" Canvas.Top="0" Canvas.Left="0"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="{TemplateBinding Amount, Converter={StaticResource amtFormatter},
ConverterParameter=\{0:n\}}" />
Unfortunately, the above "technically correct" approach throws AG_E_UNKNOWN_ERROR
exception in runtime. After removing Converter
and ConverterParameter
from the TemplateBinding
expression, the exception is gone, but no display for the Amount
at all.
This is the second lesson learned: In default ControlTemplate
's TemplateBinding
, if the source property data type is not the same type as the target property, then another "medium" property can help to work around it by converting the type. The source property Amount
's type is double
, the target Text
property type is string
, so I create a private
property, AmountStr
, as string
, to make the TemplateBinding
working correctly.
Tumbling Animation Logics
Now we have default ControlTemplate
defined in generic.xaml based on the pre-defined control contract, let's design the tumbling animation logics.
Obviously, since the public
property Amount
data type is double
, it's very easy to calculate interpolated Amount
by a DoubleAnimation
to simulate the "tumbling", and the animation StoryBoard
duration is set by the value of TumbleSeconds
property.
Fig.6. Set up the Tumbler
private Storyboard _tumblerBorard = null;
private DoubleAnimation _tumbler = null;
private void PrepareTumbler()
{
if (null != _tumblerBorard)
{
_tumblerBorard.Stop();
return;
}
_tumblerBorard = new Storyboard();
_tumbler = new DoubleAnimation();
SetTumbleDuration();
_tumbler.Completed += new EventHandler(_tumbler_Completed);
Storyboard.SetTarget(_tumbler, this);
Storyboard.SetTargetProperty(_tumbler,
new PropertyPath("NumberTumbler.TumbleAmount"));
_tumblerBorard.Children.Add(_tumbler);
}
private void _tumbler_Completed(object sender, EventArgs e)
{
VisualStateManager.GoToState(this, "Normal", false);
}
private void SetTumbleDuration()
{
Duration duration = new Duration(TimeSpan.FromSeconds(this.TumbleSeconds));
_tumbler.Duration = duration;
}
The SetTumbleDuration()
method is executed not only by PrepareTumbler()
method, but also invoked by the property change callback method (OnTumbleSecondsPropertyChange
). PrepareTumbler()
method makes sure the StoryBoard
and DoubleAnimation
instance are instantiated on demand and only once, and SetTumbleDuration()
method will always set the new value of TumbleSeconds
property to the DoubleAnimation
's Duration.
Since Silverlight does not support triggers in XAML yet, we need to set up our own triggers to start the tumbling DoubleAnimation
. Whenever the public
property Amount
changes, property callback method OnAmountPropertyChange()
will call instance method OnAmountChange()
method. (see Fig.1.) If it's the initial time to display Amount
, OnAmountChange()
simply displays the number without animation, otherwise it calls StartTumbler()
to show the tumbling effect:
Fig.7. Tumble triggers
private bool _firstRun = true;
protected virtual void OnAmountChange(double newAmount)
{
this.ToAmount = newAmount;
if (_firstRun)
{
this.TumbleAmount = this.ToAmount;
_firstRun = false;
}
else
StartTumbler();
}
private void StartTumbler()
{
string stateName = "Normal";
if (this.FromAmount < this.ToAmount)
stateName = "TumbleUp";
else if (this.FromAmount > this.ToAmount)
stateName = "TumbleDown";
else
this.TumbleAmount = this.ToAmount;
if (stateName != "Normal")
{
this.PrepareTumbler();
this._tumbler.From = this.FromAmount;
this._tumbler.To = this.ToAmount;
this._tumblerBorard.Begin();
}
VisualStateManager.GoToState(this, stateName, false);
}
Notice the StoryBoard
's target property is not Amount
, it's TumbleAmount
instead. This is the third lesson I learned: the animated property should not be the public
property that serves as the animation trigger. If it is, then every interpolated value changes in timelime will cause the trigger and animation to start again, this is definitely not we intended. The solution is to add 3 more private
properties (all in double
type): when the public Amount
changes, its previous value will copy to FromAmount
; the updated new Amount
will be the value of ToAmount
; and lastly, whenever TumbleAmount
is interpolated by the DoubleAnimation
, its property change callback method (OnTumbleAmountPropertyChange)
) will update AmountStr
, which is data bound to the TumblerViewer
part as a string
:
Fig.8. Tumble wires
private static void OnTumbleAmountPropertyChange
(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
NumberTumbler ctrl = d as NumberTumbler;
ctrl.AmountStr = String.Format(CultureInfo.InvariantCulture,
"{0:n}", (double)e.NewValue);
}
Use the NumberTumbler Control
There is nothing special about how to reference the control assembly in the demo project References list, also need to specify the namespace of the NumberTumbler
control in XAML, those steps are the same to all referenced custom control in a Silverlight application. Since only the Amount
property is required (TumbleSeconds
defaults to 2 seconds, remember? See Fig.1.), the XAML is very simple:
Fig.9. Use NumberTumbler in XAML
<hanray:NumberTumbler x:Name="numTumbler"
Amount="2009.04"
Margin="100" HorizontalAlignment="Center"
FontWeight="Bold" Foreground="Navy" FontSize="26">
</hanray:NumberTumbler>
We have no custom code for Margin
, HorizontalAlignment
, FontWeight
, FontSize
and Foreground
properties, they are inherited from Control
baseclass.
To experiment with some new features in Silverlight 3 Beta, the downable demo project code uses element to element binding for TumbleSeconds
property. No additional code is in the code behind CS file to make the two way binding work between the two controls:
Fig.10. Element to element binding for TumbleSeconds property
<hanray:NumberTumbler x:Name="numTumbler"
Amount="2009.04" TumbleSeconds="{Binding Text, ElementName=tumbleSecs}"
Margin="100" HorizontalAlignment="Center"
FontWeight="Bold" Foreground="Navy" FontSize="26">
</hanray:NumberTumbler>
<Canvas Margin="0,0,0,10" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center"
VerticalAlignment="Center" >
<TextBlock Text="Tumbling Duration: " />
<TextBox x:Name="tumbleSecs" Text="{Binding TumbleSeconds,
ElementName=numTumbler}" Width="30" Margin="0,-4, 0, 0" />
<TextBlock Text=" seconds." />
</StackPanel>
The demo application also has some UI element to change the Amount
up and down programmatically, give it a try!
Conclusions
By building this little simple custom control, I feel it's not as easy as building a user control in Silverlight. Although the custom controls have advantages in customization and reusability, the lessons learned in sealed class and TemplateBinding
data types are worth some attention when you build new custom controls. Hopefully, Microsoft will make Silverlight custom control easier to build in future releases. But before it happens, if you need one little control to tumble the changing numbers in UI, this one can help to get started!
History
- 2009.04.20 - First draft
- 2009.04.26 - Ready to review