Monday, February 27, 2012

Fill up a WPF progress bar with a linear gradient

Not so long ago I was faced with a problem how to fill a rectangle with a gradient, which shows a progress, with the gradient stops depending on the current progress. I know I know, this probably tells you nothing. I believe a good visual example will save me a thousand words, so here it comes. This is what I wanted to say:
imageimageimage
So lets discuss what we see here. We have three styled progress bars, each filled with the same background. The middle one serves as a reference point only. The middle and the last text boxes are filled with a regular linear gradient brush. And I mean regular, absolutely no magic there. When we change the progress using the slider, you can see that the rectangle which is used to show current progress shrinks or expands, but the last gradient behaves in a weird way. This is actually the default bahavior here. However, what I really expect is the behavior of the first progress bar – the fill gradient depends on the current progress. In other words, you can imagine 100% as the full gradient. Than, I want to crop it based on current progress.

So how to write a custom brush which would do that ? Yes, yes, you’re right. It’s not possible. You cannot write custom brushes in WPF. That’s sad but it’s true. No luck ? No! Attached behavior comes to the rescue! Whenever progress changes the attached behavior will recreate LinearGradientBrush and recalculate gradient stops based on the original gradient. It’s actuall simple, let’s have a quick overview of the code itself.

public class LinearGradientBrushBehavior : Behavior<RangeBase>
{
    protected override void OnAttached()
    {            
        AssociatedObject.Loaded += AssociatedObject_Loaded;
        AssociatedObject.ValueChanged += AssociatedObject_ValueChanged;

        var sourceBrush = AssociatedObject.Foreground as LinearGradientBrush;
        if (sourceBrush != null)
        {
            SourceBrush = sourceBrush;
        }
    }
        
    protected override void OnDetaching()
    {
        AssociatedObject.Loaded -= AssociatedObject_Loaded;
        AssociatedObject.ValueChanged -= AssociatedObject_ValueChanged;
    }

    private void AssociatedObject_Loaded(object sender, RoutedEventArgs e)
    {
        CalculateNewGradient(Progress);
    }

    private void AssociatedObject_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
    {
        CalculateNewGradient(Progress);
    }

    private double Progress
    {
        get { return AssociatedObject.Value / (AssociatedObject.Maximum - AssociatedObject.Minimum); }
    }

    #region SourceBrush

    public LinearGradientBrush SourceBrush
    {
        get { return (LinearGradientBrush)GetValue(SourceBrushProperty); }
        set { SetValue(SourceBrushProperty, value); }
    }

    public static readonly DependencyProperty SourceBrushProperty =
        DependencyProperty.Register(
            "SourceBrush", typeof(LinearGradientBrush), typeof(LinearGradientBrushBehavior), new UIPropertyMetadata(null));

    #endregion
        
    private void CalculateNewGradient(double progress)
    {
        var brush = new LinearGradientBrush();
        brush.StartPoint = SourceBrush.StartPoint;
        brush.EndPoint = SourceBrush.EndPoint;

        foreach (var gradientStop in SourceBrush.GradientStops)
        {
            var offset = (1 - gradientStop.Offset) / progress;
            var newGradientStop = new GradientStop(gradientStop.Color, 1 - offset);
            brush.GradientStops.Add(newGradientStop);
        }

        ApplyNewGradient(brush);
    }

    private void ApplyNewGradient(LinearGradientBrush brush)
    {
        AssociatedObject.Foreground = brush;
    }
}

The most important part is the CalculateNewGradient method, which is called once when the control loads up and each time the progress changes. That method is responsible for calculating new offsets for all gradient stops. Recalculated gradient is than reapplied to the Foreground property. I don’t have to add that rectangle’s Fill is bound to it :) It’s also worth pointing out that this behavior works with RangeBase inherited controls, so you can utilize it to create nice looking Slider!

One thing to consider here is performance. I haven’t noticed any problems, even though each time progress changes, new instance of LinearGradientBrush is created. Which is good :) You can find the sources on my SkyDrive. Hope you like it!

2 comments:

Aybe said...

Hello,

I was looking for a progress bar to represent a sound 'hotness' meter.

Unless I misread your post, your formula is wrong for what you're trying to achieve.

Say i have a green/yellow/red gradient like a VU-meter, offsetted respectively at 0.0, 0.5 and 1.0. When at a progress of 0.5 your formula shows the yellow/red part but not green/yellow part which IMO is what should be visible.
The red part should be visible only when reaching the end of the progress, not at beginning.

Anyway, here's my formula :

(replace the for...each loop in CalculateNewGradient with this one)

foreach (GradientStop gradientStop in SourceBrush.GradientStops)
{
var offset = gradientStop.Offset * (1.0d / progress);
var value = new GradientStop(gradientStop.Color, offset);
brush.GradientStops.Add(value);
}

Otherwise, that's a great post, thanks !!!

Martin Fallenstedt said...

Thank you Piotr. Glad to see that there is so much talent in Poland.