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.