Introduction
The digital meter control is a custom control in WPF which can be used as a real time monitor for showing a decimal value in a formatted way with a nice animation. What I mean by formatted way is that you can set the precision, scaling factor, and the measurement unit of your decimal value, and this control takes care of how to show it. This is a look-less control which means the logic of this control is separated from its design.
In this article, I am not going to show you how to create a custom control in WPF, so if you don’t know how to create a custom control, please visit Creating a look-less custom control in WPF.
You might find this control useful in scenarios your application reads data from other devices like reading the environment temperature etc.
Using the Code
Using this control is simple and straightforward. First, you should add a reference to Asaasoft.DigitalMeter.dll in your project. Then, define an xmlns
directive for it, like shown below:
After that, you can create an instance of the digital meter control like this:
<lib:digitalmeter precision="5" scalingfactor="2"
measurementunit="m" foreground="Black" removed="Gold"/>
This control has a ValueChanged
routed event which can be used to informed you that a value has changed. In addition, you can set the Foreground
and Background
properties to create your desired look for it. In the code below, you can see how easy it is to create different looks for the digital meter:
<Window x:Class="Asaasoft.DigitalMeter.Demo.DemoWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:lib="clr-namespace:Asaasoft.DigitalMeter;assembly=Asaasoft.DigitalMeter"
Title="DemoWindow" Width="839" Height="509" >
...
<StackPanel Orientation="Vertical">
<Grid HorizontalAlignment="Center">
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<lib:DigitalMeter x:Name="digitalMeter1" Grid.Row="0"
Grid.Column="0" Margin="10"/>
<lib:DigitalMeter x:Name="digitalMeter2" Grid.Row="0"
Grid.Column="1" ScalingFactor="2" MeasurementUnit="m"
Foreground="Black" Background="Gold"
Width="280" Margin="10"/>
<lib:DigitalMeter x:Name="digitalMeter3" Grid.Row="1"
Grid.Column="0" MeasurementUnit="bps"
Foreground="DarkGray" Background="Gray" Margin="10"/>
<lib:DigitalMeter x:Name="digitalMeter4" Grid.Row="1"
Grid.Column="1" Precision="7" ScalingFactor="1"
MeasurementUnit="ml" Foreground="Black"
Background="Lime" Margin="10" />
<lib:DigitalMeter x:Name="digitalMeter5" Grid.Row="2"
Grid.Column="0" ScalingFactor="4" MeasurementUnit="N"
Foreground="CornflowerBlue" Background="Navy" Margin="10" />
<lib:DigitalMeter x:Name="digitalMeter6" Grid.Row="2"
Grid.Column="1" Precision="7" MeasurementUnit="Pa"
Foreground="White" Background="OrangeRed" Margin="10" />
</Grid>
...
</StackPanel>
</Window>
Here is the result:
How it Works (Logic)
As you can see, the DigitalMeter
has these properties:
Value
: is the current value of the DigitalMeter
.
Precision
: is the length of the integral value + the fractional value.
ScalingFactor
: is the length of the fractional value.
MeasurementUnit
: is a measurement unit of Value
which is shown in the right side of the DigitalMeter
.
ValueText
: is the result of formatting the Value
based on the Precision
and ScalingFactor
. For example, if you set Value=80.2
, Precision=5
, and ScalingFactor=2
, then ValueText
is equal to 080.20.
Just remember that ValueText
should not be set in your code (it has a public accessor because it must be accessible in the template).
public class DigitalMeter : Control
{
...
#region Dependency Properties
public int Precision
{
get
{
return (int)GetValue(PrecisionProperty);
}
set
{
SetValue(PrecisionProperty, value);
}
}
public static readonly DependencyProperty PrecisionProperty =
DependencyProperty.Register("Precision", typeof(int),
typeof(DigitalMeter),
new PropertyMetadata( 5, new PropertyChangedCallback(SetValueText)));
public int ScalingFactor
{
get
{
return (int)GetValue(ScalingFactorProperty);
}
set
{
SetValue(ScalingFactorProperty, value);
}
}
public static readonly DependencyProperty ScalingFactorProperty =
DependencyProperty.Register("ScalingFactor", typeof(int),
typeof(DigitalMeter),
new PropertyMetadata(0, new PropertyChangedCallback(SetValueText)) );
public decimal Value
{
get
{
return (decimal)GetValue(ValueProperty);
}
set
{
decimal oldValue = Value;
SetValue(ValueProperty, value);
if ( oldValue != value )
OnValueChanged( oldValue, value );
}
}
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register("Value", typeof(decimal),
typeof(DigitalMeter),
new PropertyMetadata( 0M, new PropertyChangedCallback( SetValueText ) ) );
...
#endregion
...
}
In order to set the proper value for ValueText
, we need to be informed when the value of one of ValueProperty
, PrecisionProperty
, or ScalingFactorProperty
is changed. We can do this via the PropertyChangedCallback
delegate which is one of the parameters in the PropertyMetadata
constructor. As it is shown above, the SetValueText
method is called when one of the values of ValueProperty
,PrecisionProperty
or ScalingFactorProperty
is changed.
Here is the SetValueText
method:
private static void SetValueText( DependencyObject d,
DependencyPropertyChangedEventArgs e )
{
DigitalMeter dm = (DigitalMeter)d;
dm.ValueText = HelperClass.FormatDecimalValue(dm.Value,
dm.Precision, dm.ScalingFactor );
}
FormatDecimalValue
in HelperClass
is a static method which is responsible for creating the proper ValueText
. If the value is too big for showing, it will create a ValueText
using #. For example, for Value=20080.2
, Precision=5
, and ScalingFactor=2
, the result is ###.##. You can see this method below:
internal static string FormatDecimalValue( decimal value, int precision, int scalingFactor )
{
string valueText = "";
if ( scalingFactor == 0 )
{
valueText = Math.Round(value, 0).ToString().PadLeft(precision, '0');
}
else
{
decimal integralValue = Decimal.Truncate(value);
decimal fractionalValue = Math.Round(value - integralValue, scalingFactor);
string fractionalValueText = fractionalValue.ToString();
if ( fractionalValueText.IndexOf( '.' ) > 0 )
fractionalValueText = fractionalValueText.Remove(0, 2);
valueText = integralValue.ToString().PadLeft(precision - scalingFactor, '0');
valueText = string.Format("{0}.{1}", valueText,
fractionalValueText.PadRight(scalingFactor, '0'));
}
if ( ( scalingFactor == 0 && valueText.Length > precision ) ||
( scalingFactor > 0 && valueText.Length > precision + 1 ) )
valueText = string.Empty.PadLeft(precision - scalingFactor, '#') + "." +
string.Empty.PadLeft(scalingFactor, '#');
return valueText;
}
Design
The default template of any WPF control is located in Generic.xaml in the Themes folder. So, you can find the default template for this control in Generic.xaml in the Themes folder. Also, we must assign the default template of this control in the DigitalMeter
constructor like below:
#region Constructor
public DigitalMeter()
{
DefaultStyleKey = typeof(DigitalMeter);
}
#endregion
Before I start describing the actual design of this control, let me show you the simple design of this control.
As it is illustrated above, there are two files in the Themes folder. In order to use a simple template, you should rename SimpleGeneric.xaml to Generic.xaml. Here is the content of SimpleGeneric.xaml:
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:core="clr-namespace:System;assembly=mscorlib"
xmlns:local="clr-namespace:Asaasoft.DigitalMeter">
<Style TargetType="local:DigitalMeter">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:DigitalMeter">
<StackPanel>
<Border BorderBrush="Black" CornerRadius="5"
Padding="10" BorderThickness="1">
<TextBlock Text="{TemplateBinding ValueText}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</StackPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
As it is shown above, the Text
property of TextBlock
is bound to ValueText
via this XAML syntax:
Text="{TemplateBinding ValueText}"
Here is the result of this template:
Now, I am going to describe the actual design for this control. I wanted to use an animation which starts counting up/down to the new value and becomes blurry when counting up/down (the blurry effect was my friend Khaled Atashbahar's idea, which made the animation cooler). The core of this template is shown below:
<Border Background="{TemplateBinding Background}"
BorderBrush="Black" BorderThickness="1.5"
CornerRadius="15" Padding="20,20,0,20">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition></ColumnDefinition>
<ColumnDefinition Width="100"></ColumnDefinition>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" x:Name="ValueTextBlock"
Foreground="{TemplateBinding Foreground}"
Text="{Binding Mode=OneWay}" HorizontalAlignment="Center"
VerticalAlignment="Center" />
<TextBlock Grid.Column="0" x:Name="BlurValueTextBlock"
Foreground="{TemplateBinding Foreground}"
Text="{Binding}" Opacity="0.0"
HorizontalAlignment="Center"
VerticalAlignment="Center" >
<TextBlock.BitmapEffect>
<BlurBitmapEffect Radius="3" />
</TextBlock.BitmapEffect>
</TextBlock>
<TextBlock Grid.Column="1" Text="{TemplateBinding MeasurementUnit}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>
</Border>
<Border BorderBrush="Black" BorderThickness="1.5"
CornerRadius="15" Padding="6">
<Border.Background>
<LinearGradientBrush EndPoint="0.5,0.5" StartPoint="0.5,0">
<GradientStop Color="#AAFFFFFF" Offset="0"/>
<GradientStop Color="#00FFFFFF" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
</Border>
There are two Border
s. The first one is used for showing data, and the second one is used for creating a glassy effect.
- First Border (Showing the Data)
For showing data, I used three TextBlock
s.
The first and the second TextBlock
s are responsible for showing the ValueText
and they are in the same column. The second one has the blurry effect and the Opacity
is set to 0 to make it invisible. When the animation starts, it makes the Opacity
of the second TextBlock
to 1, so it becomes visible, and that is why you can see the blurry effect.
The third TextBlock
is responsible for showing the MeasurementUnit
property.
- Second Border (Creating the Glassy Effect)
The LinearGradientBrush
is applied for the top half of DigitalMeter
, and it starts from a transparent color and moves to white. So this control looks glassy.
A collapsed TextBox
is used in order to fire the animation. The animation starts when the Text
of TextBox
is changed. Notice that TextBox.Text
is bound to ValueText
. In addition, changing the opacity of the TextBlock
s (those which are responsible for showing the data) happens here.
<TextBlock x:Name="collapsedTextBlock" Text="{Binding Mode=OneWay}" Visibility="Collapsed"/>
<TextBox Name="collapsedTextBox" Text="{Binding Mode=OneWay}" Visibility="Collapsed">
<TextBox.Triggers>
<EventTrigger RoutedEvent="TextBox.TextChanged">
<BeginStoryboard>
<Storyboard>
<local:CounterAnimation
Storyboard.TargetName="BlurValueTextBlock"
Storyboard.TargetProperty="Text"
From="{Binding Mode=OneWay}"
To ="{Binding ElementName=collapsedTextBlock, Path=Text}"
Duration="0:0:0.4" />
<DoubleAnimationUsingKeyFrames
Storyboard.TargetName="ValueTextBlock"
Storyboard.TargetProperty="(UIElement.Opacity)"
Duration="0:0:0.4">
<LinearDoubleKeyFrame Value="0.0" KeyTime="0:0:0.0" />
<LinearDoubleKeyFrame Value="0.0" KeyTime="0:0:0.399999" />
<LinearDoubleKeyFrame Value="1.0" KeyTime="0:0:0.4" />
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames
Storyboard.TargetName="BlurValueTextBlock"
Storyboard.TargetProperty="(UIElement.Opacity)"
Duration="0:0:0.4">
<LinearDoubleKeyFrame Value="1.0" KeyTime="0:0:0.0" />
<LinearDoubleKeyFrame Value="1.0" KeyTime="0:0:0.399999" />
<LinearDoubleKeyFrame Value="0.0" KeyTime="0:0:0.4" />
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</TextBox.Triggers>
</TextBox>
Because the ValueText
that is bound to the TextBox
is a string
, we need a custom animation class to create the animation based on the ValueText
. As you might have noticed, the responsible class for it is CounterAnimation
. CounterAnimation
is inherited from StringAnimationBase
, and the GetCurrentValueCore
method tells us the current value based on the elapsed time and the From
and To
properties. You can see this method below:
protected override string GetCurrentValueCore( string defaultOriginValue,
string defaultDestinationValue, AnimationClock animationClock )
{
if ( To.Contains("#") )
return To;
TimeSpan? current = animationClock.CurrentTime;
int precision = To.Length;
int scalingFactor = 0;
if ( To.IndexOf('.') > 0 )
{
precision--;
scalingFactor = precision - To.IndexOf('.');
}
decimal from = 0;
if ( !string.IsNullOrEmpty(From) )
{
if ( !From.ToString().Contains("#") )
from = Convert.ToDecimal(From);
else
{
string max = "".PadLeft(precision, '9');
if ( scalingFactor > 0 )
max = max.Insert(precision - scalingFactor, ".");
from = Convert.ToDecimal(max);
}
}
decimal to = Convert.ToDecimal(To);
decimal increase = 0;
if ( Duration.HasTimeSpan && current.Value.Ticks > 0 )
{
decimal factor = (decimal)current.Value.Ticks /
(decimal)Duration.TimeSpan.Ticks;
increase = ( to - from ) * factor;
}
from += increase;
return HelperClass.FormatDecimalValue(from, precision, scalingFactor);
}
Points of Interest
You might have noticed that for the CounterAnimation
in the animation section of the current template, I created a collapsed TextBlock
whose Text
is boud to the ValueText
and the To
property of the CounterAnimation
is set to To="{Binding ElementName=collapsedTextBlock, Path=Text}"
instead of To ="{TemplateBinding ValueText}"
. But, the second way doesn't work. I know this template is not as good as it should be, so I would really like to know if you have a better idea for it ;).