Search code examples
wpfxamlprogress-barclipping

Clipping non-opaque double progress bar in WPF


I'm trying to make a custom progress bar in WPF that has two values (the second is always equal to or higher than the first). The basic bar works ok like so:

<wpft:ClippingBorder BorderBrush="{StaticResource Border}"
                     Background="{StaticResource Background}"
                     BorderThickness="1" CornerRadius="4">
    <Grid Margin="-1" x:Name="Bars">
        <Border BorderBrush="{StaticResource Border}"
                Background="{Binding Value2Brush}"
                BorderThickness="1" CornerRadius="4"
                HorizontalAlignment="Left"
                Width="{Binding Value2Width}" />
        <Border BorderBrush="{StaticResource Border}"
                Background="{Binding Value1Brush}"
                BorderThickness="1" CornerRadius="4"
                HorizontalAlignment="Left"
                Width="{Binding Value1Width}" />
    </Grid>
</wpft:ClippingBorder>

(Where ClippingBorder is this. It's used to prevent glitching at the outer corners when the values are near 0.)

The net result is a nice rounded display: Progress image

Zoomed view, to more clearly show the rounded corners:

zoomed

In particular note that both of the inner bars share the same outer border and their right edge curves to the left, just like the outer border.

This works because it draws the longer bar first, then the shorter one on top of it. However, it only works reliably when the brushes are fully opaque -- in particular if Value1Brush is partially transparent then some of Value2Brush will show through it, which I don't want.

Ideally, I want the longer bar to only draw that portion of itself that extends beyond the shorter bar -- or equivalently, to set the clipping/opacity mask of the longer bar to be transparent in the area where the shorter bar is drawn.

But I'm not sure how to do that without losing the rounded corners.


Solution

  • This is not a general solution, unfortunately, but it seems to work for this case. I had to give an x:Name to each of the internal borders and then put this in the code behind:

    Constructor:

    DependencyPropertyDescriptor.FromProperty(ActualWidthProperty, typeof(Border))
        .AddValueChanged(OuterBar, Child_ActualWidthChanged);
    DependencyPropertyDescriptor.FromProperty(ActualWidthProperty, typeof(Border))
        .AddValueChanged(InnerBar, Child_ActualWidthChanged);
    

    Handler:

    private void Child_ActualWidthChanged(object sender, EventArgs e)
    {
        var outerRect = new Rect(OuterBar.RenderSize);
        outerRect.Inflate(5, 5);
        var outer = new RectangleGeometry(outerRect);
    
        var corner = InnerBar.CornerRadius.TopLeft;
        var inner = new RectangleGeometry(new Rect(InnerBar.RenderSize), corner, corner);
    
        OuterBar.Clip = new GeometryGroup()
        {
            Children = { outer, inner }
        };
    }
    

    The basic idea is to start with a rectangle slightly larger than what the outer bar wants to draw, and then add a rectangle that exactly matches what the inner bar wants to draw -- this clips it out of the geometry. The whole is then used as a clip region for the outer bar so that it can't draw inside the inner bar's region.

    I originally tried to do this in XAML with the following, but it didn't work (the converter was not called when the width changed); I'm not sure why, but just for posterity:

    <Border.Clip>
        <GeometryGroup>
            <RectangleGeometry Rect="{Binding ElementName=OuterBar, Path=RenderSize,
                Converter={StaticResource BoundsConverter}, ConverterParameter=5.0}" />
            <RectangleGeometry Rect="{Binding ElementName=InnerBar, Path=RenderSize,
                Converter={StaticResource BoundsConverter}}" RadiusX="4" RadiusY="4" />
        </GeometryGroup>
    </Border.Clip>
    

    (Here the converter would take the RenderSize and make a Rect, with optional inflation, similar to the code above.)