Renderização de Indicadores de Progresso Circulares no WPF

Para construir um indicador de progresso circular no WPF, a estratégia recomendada envolve substituir o modelo de controle padrão por um ControlTemplate personalizado e vincular a propriedade Value a um conversor de valores. Esse conversor traduz o dado numérico em instruções de desenho vetorial, retornando um DrawingBrush que é aplicado diretamente ao fundo do controle. O núcleo da implementação reside no cálculo geométrico do setor circular e na mainpulação direta da DrawingContext.

O trecho XAML abaixo define o template e estabelece o binding com o conversor, passando o tamanho desejado como parâmetro:

<ProgressBar Name="CircularBar" Minimum="0" Maximum="100">
    <ProgressBar.Template>
        <ControlTemplate TargetType="ProgressBar">
            <Border Background="{TemplateBinding Value, Converter={StaticResource RingProgressConverter}, ConverterParameter=200}"/>
        </ControlTemplate>
    </ProgressBar.Template>
</ProgressBar>

O IValueConverter intercepta o valor do progresso e o parâmetro de container. Com base nisso, calcula o raio e o ponto central, delegando a renderização a uma rotina específica. A classe DrawingContext não permite instanciação direta; ela é exposta via DrawingGroup.Open(), que gerencia um buffer de instruções gráficas até que Close() seja invocado. Dentro desse contexto, métodos como DrawEllipse e DrawGeometry são enfileirados para composição visual.

public class CircularProgressConverter : IValueConverter
{
    private const double RingWidth = 24;
    private const int FontSize = 60;
    private readonly double _verticalOffset = 14;
    private Point _centerCoordinate;
    private double _baseRadius;
    private readonly Brush _trackFill = new SolidColorBrush(Color.FromRgb(110, 135, 170));
    private readonly Brush _progressFill = new SolidColorBrush(Colors.White);
    private readonly Typeface _fontStyle = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal);
    private string _percentLabel;

    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (value is double progress && parameter is string sizeStr && double.TryParse(sizeStr, out double containerSize))
        {
            _baseRadius = containerSize * 0.5;
            _centerCoordinate = new Point(_baseRadius, _baseRadius);
            return RenderRing(progress, 100.0, _baseRadius, _baseRadius, RingWidth);
        }
        throw new ArgumentException("Parâmetros de conversão inválidos.");
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException();

    private Brush RenderRing(double current, double max, double rx, double ry, double thickness)
    {
        var drawingGroup = new DrawingGroup();
        using (var ctx = drawingGroup.Open())
        {
            ComposeVisual(ctx, current, max, rx, ry, thickness);
            ctx.Close();
        }
        return new DrawingBrush(drawingGroup);
    }

    private void ComposeVisual(DrawingContext ctx, double current, double max, double rx, double ry, double thickness)
    {
        double radiusOuter = rx + (thickness * 0.5);
        double radiusInner = rx - (thickness * 0.5);
        double ratio = Math.Min(1.0, Math.Max(0.0, current / max));
        _percentLabel = $"{ratio * 100:0.##}%";

        ctx.DrawEllipse(null, new Pen(_trackFill, thickness), _centerCoordinate, rx, ry);

        PathGeometry sectorPath = ConstructArcPath(_centerCoordinate, radiusOuter, radiusInner, ratio * 360.0, ratio > 0.5);
        ctx.DrawGeometry(_progressFill, null, sectorPath);

        FormattedText textLayout = new FormattedText(
            _percentLabel,
            CultureInfo.CurrentCulture,
            FlowDirection.LeftToRight,
            _fontStyle,
            FontSize,
            _progressFill);

        Point textOrigin = new Point(
            _centerCoordinate.X - (textLayout.Width * 0.5),
            _centerCoordinate.Y - (textLayout.Height * 0.5) - _verticalOffset);

        ctx.DrawText(textLayout, textOrigin);
    }

    private PathGeometry ConstructArcPath(Point center, double bigR, double smallR, double angleDeg, bool isLarge)
    {
        Point p1 = ResolvePolarCoordinate(center, bigR, 0);
        Point p2 = ResolvePolarCoordinate(center, bigR, angleDeg);
        Point p3 = ResolvePolarCoordinate(center, smallR, 0);
        Point p4 = ResolvePolarCoordinate(center, smallR, angleDeg);

        PathFigure figure = new PathFigure { StartPoint = p1, IsClosed = true };
        figure.Segments.Add(new ArcSegment(p2, new Size(bigR, bigR), 0, isLarge, SweepDirection.Clockwise, true));
        figure.Segments.Add(new LineSegment(p4, true));
        figure.Segments.Add(new ArcSegment(p3, new Size(smallR, smallR), 0, isLarge, SweepDirection.Counterclockwise, true));

        return new PathGeometry(new[] { figure });
    }

    private Point ResolvePolarCoordinate(Point origin, double r, double degrees)
    {
        double radians = degrees * Math.PI / 180.0;
        return new Point(
            origin.X + (Math.Sin(radians) * r),
            origin.Y - (Math.Cos(radians) * r)
        );
    }
}

A geração do arco depende da montagem de um PathFigure contendo dois segmentos de arco (ArcSegment) e um segmento de linha (LineSegment). A função trigonométrica mapeia o ângulo proporcional ao progresso para coordenadas cartesianas, iniciando no topo do círculo (0°). O atributo IsLargeArc é acionado quando o valor excede 50%, garantindo que o motor gráfico rendeirze o segmento maior do círculo. A exibição textual utiliza FormattedText para controlar tipografia e é posicionada centralmente com um ajuste fino de offset vertical para neutralizar a métrica da linha de base do sistema.

Tags: WPF ivalueconverter drawingcontext XAML CSharp

Publicado em 9-7 21:00