Introduction
I was at work the other day and one of my work colleagues asked me how to create a Rating control (you know the ones with the stars). I talked him through how to do it, but whilst doing so I thought I might have a go at that if I get a spare hour or two. I found some time to give it a go, and have come up with what I think is a pretty flexible RatingControl
for WPF.
You are able to alter the following attributes:
- Overall background color
- Star foreground color
- Star outline color
- Number of stars
- Current value
All of these properties are DependencyProperty
values, so they are fully bindable. This is all wrapped up in a very simple and easy to use UserControl
called RatingsControl
, Here is what the resulting RatingsControl
looks like in a demo window:
What I like about my implementation compared to the more typical implementations you see out there is that mine deals with fractions of Stars. What I mean is that it is possible to provide a value such as 7.5 and the 0.5 will actually only fill 1/2 a star.
Here is all you have to do to use it in XAML:
<local:RatingsControl x:Name="ratings0"
Value="2.6"
NumberOfStars="4"
BackgroundColor="White"
StarForegroundColor="Blue"
StarOutlineColor="Black"
Margin="5"
HorizontalAlignment="Left"/>
See that is pretty easy, isn't it.
How It Works
So I'll just talk you through how it works.
There are actually 2 controls that make this happen.
RatingsControl
There is a top level RatingsControl
that you set values on. Based on those values, the RatingsControl
works out how many stars have been requested (this is dictated by the NumberOfStars
DP). There is also DP value coercing to ensure that the NumberOfStars
DP value does not exceed the RatingsControl.Minimum
and RatingsControl.Maximum
DP property values, which I have set to 0 and 10 respectively.
The code to do this is as follows:
public static readonly DependencyProperty NumberOfStarsProperty =
DependencyProperty.Register("NumberOfStars", typeof(Int32), typeof(RatingsControl),
new FrameworkPropertyMetadata((Int32)5,
new PropertyChangedCallback(OnNumberOfStarsChanged),
new CoerceValueCallback(CoerceNumberOfStarsValue)));
public Int32 NumberOfStars
{
get { return (Int32)GetValue(NumberOfStarsProperty); }
set { SetValue(NumberOfStarsProperty, value); }
}
private static void OnNumberOfStarsChanged(DependencyObject d,
DependencyPropertyChangedEventArgs e)
{
d.CoerceValue(MinimumProperty);
d.CoerceValue(MaximumProperty);
RatingsControl ratingsControl = (RatingsControl)d;
SetupStars(ratingsControl);
}
private static object CoerceNumberOfStarsValue(DependencyObject d, object value)
{
RatingsControl ratingsControl = (RatingsControl)d;
Int32 current = (Int32)value;
if (current < ratingsControl.Minimum) current = ratingsControl.Minimum;
if (current > ratingsControl.Maximum) current = ratingsControl.Maximum;
return current;
}
What happens is that when either the RatingsControl.Value
or the RatingsControl.NumberOfStars
DP values change, the following logic is run which creates the correct number of StarControl
, and sets their actual Value based on a share of the overall RatingsControl.Value
.
private static void SetupStars(RatingsControl ratingsControl)
{
Decimal localValue = ratingsControl.Value;
ratingsControl.spStars.Children.Clear();
for (int i = 0; i < ratingsControl.NumberOfStars; i++)
{
StarControl star = new StarControl();
star.BackgroundColor = ratingsControl.BackgroundColor;
star.StarForegroundColor = ratingsControl.StarForegroundColor;
star.StarOutlineColor = ratingsControl.StarOutlineColor;
if (localValue > 1)
star.Value = 1.0m;
else if (localValue > 0)
{
star.Value = localValue;
}
else
{
star.Value = 0.0m;
}
localValue -= 1.0m;
ratingsControl.spStars.Children.Insert(i,star);
}
}
As can be seen from this code, this is where each StarControl
gets created and is assigned a Value. For example, if the overall RatingsControl.Value
was 7.5 and the RatingsControl.NumberOfStars
was 8, we would loop through creating 8 StarControl
s, where the first 7 StarControl
s would get their Value
DP to 1.0 all except the last one which would get its Value
DP to 0.5.
A lot of the other DPs previously mentioned such as BackgroundColor
/StarForegroundColor
/StarOutlineColor
DPs are simply used to set the corresponding DP values on any created StarControl
which can also be seen above.
So how do the StarControl
s work and how to they render partial stars?
StarControl
A StarControl
represents a single star within the overall RatingsControl
, and each StarControl
has the following DPs:
BackgroundColor
(set via RatingsControl
)
StarForegroundColor
(set via RatingsControl
)
StarOutlineColor
(set via RatingsControl
)
Value
(set via RatingsControl
, but is coerced between the StarControl.Minimum
and StarControl.Maximum
DP values, which are set at 0.0 and 1.0 respectively)
Minimum
used to coerce StarControl.Value
if it is out of acceptable range
Maximum
used to coerce StarControl.Value
if it is out of acceptable range
The XAML that described what a StarControl
looks like is as follows:
<UserControl x:Class="StarRatingsControl.StarControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="Auto" Width="Auto">
<Grid x:Name="gdStar">
<Path Name="starForeground" Fill="Gray" Stroke="Transparent" StrokeThickness="1"
Data="M 5,0 L 4,4 L 0,4 L 3,7 L 2,11 L 5,9 L 6,
9 L 9,11 L 8,7 L 11,4 L 7,4 L 6,0"/>
<Rectangle x:Name="mask" Margin="0"/>
<Path Name="starOutline" Fill="Transparent"
Stroke="Transparent" StrokeThickness="1"
Data="M 5,0 L 4,4 L 0,4 L 3,7 L 2,11 L 5,9 L 6,
9 L 9,11 L 8,7 L 11,4 L 7,4 L 6,0"/>
</Grid>
</UserControl>
So again getting back to how the StarControl
is able to render fractions of stars, well there are 2 tricks in use here:
Trick 1: Clipping
In the constructor of the StarControl
, there is a Clip geometry set up which prevents the StarControl
from rendering anything that would appear outside of this clipped geometry.
public StarControl()
{
this.DataContext = this;
InitializeComponent();
gdStar.Width = STAR_SIZE;
gdStar.Height = STAR_SIZE;
gdStar.Clip = new RectangleGeometry
{
Rect = new Rect(0, 0, STAR_SIZE, STAR_SIZE)
};
mask.Width = STAR_SIZE;
mask.Height = STAR_SIZE;
}
Trick 2: Moving Mask
There is actually a Rectangle (I call this Mask) that is between the star background Path and the star outline path, this Rectangle (Mask) is the same Color as the background, and has its Margin adjusted to be moved the correct place to give the illusion of a partially filled star. And then the outline is drawn on top of the moved Rectangle.
This figure explains this a bit better:
And here is the code that deals with this in the StarControl:
private static void OnValueChanged(DependencyObject d,
DependencyPropertyChangedEventArgs e)
{
d.CoerceValue(MinimumProperty);
d.CoerceValue(MaximumProperty);
StarControl starControl = (StarControl)d;
if (starControl.Value == 0.0m)
{
starControl.starForeground.Fill = Brushes.Gray;
}
else
{
starControl.starForeground.Fill = starControl.StarForegroundColor;
}
Int32 marginLeftOffset = (Int32)(starControl.Value * (Decimal)STAR_SIZE);
starControl.mask.Margin = new Thickness(marginLeftOffset, 0, 0, 0);
starControl.InvalidateArrange();
starControl.InvalidateMeasure();
starControl.InvalidateVisual();
}
That's It... Hope You Liked It
Anyway there you go, hope you liked it. I know this is a very small article, but I am hoping it may be useful to someone.
Thanks
As always votes / comments are welcome.
History
- 27th November, 2009: Initial post