WPF/XAML: Eine runde Fortschrittsanzeige

Mit diesen Beispiel möchte ich zeigen wie man eine runde Fortschrittsanzeige als eigenes Control in WPF erstellt.

Aufbau des Controls

Um das Control so schlank wie möglich zu halten verwenden wir kein klassisches UserControl mit XAML und Code-Behind, sondern erweitern die Control Klasse und erstellen ein Default-Template für das CustomControl in der Themes/Generic.xaml. Visual Studio bietet ein Projekt-Template für diese Aufgabe an. ( Datei -> Neu -> Projekt -> Benutzerdefinierte WPF-Steuerelementbibliothek )

Das Control besteht im wesentlichen aus übereinander liegenden Kreissegmenten. Folgende Grafik veranschaulicht den Aufbau:

radialprogressbar_signed_midsize

 

Kreise zeichnen mit der ArcSegment Klasse

Die beste Möglichkeit um mit WPF Kreise und Kreisbögen zu zeichnen ist die ArcSegment Klasse.  Dabei ist ArcSegment der Supertyp eines PathSegments und beschreibt wie der Name schon sagt ein “Wegstück”. Um diese Wegbeschreibung grafisch darzustellen wird das Path Control verwendet. Dazu werden ein oder mehrere Wegsteücke (PathSegments) zu einer 2D Abbildung (Pathfigure) zusammengefasst. Das Path Control kann mehrere solcher Pathfigures aufnehmen und rendern. Diese Verschachtelung sieht in XAML so aus:

<Path>
    <Path.Data>
        <PathGeometry>
            <PathFigure>
                <ArcSegment />
            </PathFigure>
        </PathGeometry>
    </Path.Data>
</Path>

Ein PathFigure Objekt beschreibt also einen Linienzug. Dabei wird der Startpunkt vom PathFigure-Object festgelegt und der Verlauf durch die Point-Eigenschaften der untergeordneten PathSegment-Objekte. Während ein PathSegment eine gerade Verbindung zwischen zwei Punkten beschreibt, kann mit der ArcSegment klasse eine Kurve gezeichnet werden. Um den Kreisbogen für die Fortschrittsanzeige zu zeichnen benötigen wir also ein PathFigure-Objekt das den Startpunkt des Bogens definiert und ein untergeordnetes ArcSegment-Objekt um den Endpunkt und die Krümmung zu beschreiben. Wie der Bogen der die beiden Punkte verbindet verläuft hängt dabei von den folgenden Eigenschaften ab:

  • PathFigure.StartPoint (System.Windows.Point) : 2D-Koordinaten des Startpunktes
  • ArcSegment.Point (System.Windows.Point) : 2D-Koordinaten des Endpunktes
  • ArcSegment.Size (System.Windows.Size) : Radius in X- und in Y-Richtung des Bogens
  • ArcSegment.SweepDirection (System.Wifndows.Media.SweepDirection) : Gibt an ob der Bogen gegen oder im Uhrzeigersinn gezeichnet werden soll
  • ArcSegment.IsLargeArc (bool) : In vielen Fällen können durch die genannten Eigenschaften ein großer oder ein kleinerer Kreis beschrieben werden. IsLargeArc gibt an welcher davon gezeichnet werden soll. Zum Zeichnen des Fortschrittsbalkens muss dies bei einen Winkel größer 180° beachtet werden.

Eine genauere Erklärung der Radien, Punkte und anderer ArcSegment-Eigenschaften findet ihr hier. Um einen Kreisbogen zu zeichnen müssen X- und Y-Radius gleich gewählt werden. Bei gegebenen Startpunkt können wir dann mit Hilfe des Radius den Endpunkt berechnen.

Zunächst also etwas Methematik

Der Radius ergibt sich aus der Größe des Controls. Damit der Bogen nicht direkt den Rand berührt wählen wir den Radius 10% kleiner als die halbe Breite. Der Mittelpunkt des Kreises soll immer in der Mitte des Controls liegen.

offset_mp_radius

Somit sind alle Eigenschaften des Kreisbogens abhängig von der Breite des Container-Controls. Wenn sich die Größe des Containers ändert müssen wir Mittelpunkt, Startpunkt, Radius und Endpunkt neu berechnen.

rootGrid.SizeChanged += (s, e) => CalculateNewDemensions(e.NewSize.Width, e.NewSize.Height);

private void CalculateNewDemensions(double width, double height)
{
    // 1) Mittelpunkt neu berechnen
    mMiddle = new Point(width / 2.0, height / 2.0);

    // 2) Radius 10% kleiner als halbe Breite
    mRadius = mMiddle.X - (mMiddle.X / 10.0);
    mValueArc.Size = new Size(mRadius, mRadius);

    // 3) Startpunkt neu berechnen
    mValuePathFigure.StartPoint = new Point
        (Math.Cos(ANGLE_COORDINATE_ROTATION - ANGLE_OFFSET) * mRadius + mMiddle.X,
        (-Math.Sin(ANGLE_COORDINATE_ROTATION - ANGLE_OFFSET) * mRadius) + mMiddle.Y);

    // 4) Endpunkt für Hintergrund ArcSegment neu berechnen
    mBackgroundArc.Point = CalculateEndpoint(100.0);

    // 5) Value-Arc Punkt neu berechnen
    OnValueChanged(Value);
}

Der Startpunkt des Kreisbogens errechnet sich aus dem Radius und einen Offset-Winkel von der Y-Achse (siehe Skizze oben). Der Winkel beträgt 270° – Offset, wobei die 270° die Drehung des Koordinatensystem weg von der positiven X-Achse beschreiben. Der Offset kann bliebig gewählt werden. Im diesem Beispiel wählen wir 30°.

private const double ANGLE_OFFSET = (Math.PI / 180.0) * 30.0;
private const double ANGLE_COORDINATE_ROTATION = (Math.PI / 180.0) * 270.0;

Dependency Properties und Winkelberechnung

Damit die Fortschrittsanzeige auch wie gewünscht funktioniert muss der Endpunkt des Bogens abhängig von einem prozentualen Wert berechnet werden. Dazu spendieren wir den Control die Eigenschaften Min, Max und Value. TIPP: “propdp” eingeben und 2x TAB drücken fügt das Template eines DependencyProperties ein. Dadurch gelingt das Erstellen dieser Properties wesentlich schneller.

public double Min
{
    get { return (double)GetValue(MinProperty); }
    set { SetValue(MinProperty, value); }
}
public static readonly DependencyProperty MinProperty =
    DependencyProperty.Register("Min", typeof(double),
    typeof(RadialProgressBar),
    new PropertyMetadata(0d));

public double Max
{
    get { return (double)GetValue(MaxProperty); }
    set { SetValue(MaxProperty, value); }
}
public static readonly DependencyProperty MaxProperty =
    DependencyProperty.Register("Max", typeof(double),
    typeof(RadialProgressBar),
    new PropertyMetadata(100d, OnMinOrMaxOrValueChanged));

public double Value
{
    get { return (double)GetValue(ValueProperty); }
    set { SetValue(ValueProperty, value); }
}
public static readonly DependencyProperty ValueProperty =
    DependencyProperty.Register("Value",
    typeof(double),
    typeof(RadialProgressBar),
    new PropertyMetadata(64.2d, OnMinOrMaxOrValueChanged));

private static void OnMinOrMaxOrValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    RadialProgressBar pg = d as RadialProgressBar;
    if (pg != null && pg.IsLoaded)
        pg.OnValueChanged((double)e.NewValue);
}

Aus diesen Eigenschaften kann der Prozentsatz für den Fortschritt des Kreisbogens berechnet werden. Somit müssen wir immer wenn sich eine dieser Eigenschaften ändert den Endpunkt neu berechnen. Der Standardwert für Min ist 0 und für Max ist 100. Dadurch kann Value standardmäßig  als Prozentsatz angegeben werden. Die Berechnung erfolgt in der OnValueChangedLocal(double newValue) Methode.

public void OnValueChanged(double newValue)
{
    double percentage = 100 * (newValue - Min) / (Max - Min); 
   
    UpdateTextBlock(newValue, percentage);

    mValueArc.Point = CalculateEndpoint(percentage);
}

Nach der Berechnung des Prozentsatzes wird mit UpdateTextBlock(newValue, percentage) der Inhalt des Textfeldes gesetzt. Der spannendere Teil passiert in der CalculateEndpoint()-Methode. Folgende Skizze veranschaulicht die Berechnung des Endpunkt-Winkels alpha.

template_angle

Der Endpunkt-Winkel kann ähnlich  wie der Startpunkt-Winkel über die Koordinatensystemdrehung, den Offset und den prozentualen Bogenwinkel bestimmt werden. Mit Hilfe von Alpha kann der Endpunkt dann über die trigonometrischen Funktionen berechnet werden.

private const double FULL_ANGLE = (2 * Math.PI) - (2 * ANGLE_OFFSET);
 
private Point CalculateEndpoint(double percentage)
{
    double angle = (percentage / 100.0) * FULL_ANGLE;

    // Ab einen Winkel von 180 Grad muss der
    // große Bogen verwendet werden
    mValueArc.IsLargeArc = angle > Math.PI;

    // Offset und Drehung des Koordinatensystems berücksichtigen
    angle = ANGLE_COORDINATE_ROTATION - ANGLE_OFFSET - angle;

    return new Point(
            Math.Cos(angle) * mRadius + mMiddle.X,
           -Math.Sin(angle) * mRadius + mMiddle.Y);
}

Das Minus vorm Sinus kommt davon dass die Y-Achse des Path-Koordinatensystems nach unten zeigt. Die IsLargeArc-Eigenschaft des ArcSegments ist standardmäßig auf False gesetzt. Das bedeutet es wird immer der kurze Bogen gezeichnet. Bis zu einen Winkel von 180° (Math.PI) passt das für unseren Zweck recht gut. Für Winkel größer 180° wird IsLArgeArc = true gesetzt um den vollen Kreisbogen zu zeichnen.

Weniger Mathe, mehr Farbe!

Um der Fortschrittsanzeige ein nettes Aussehen zu verleihen wird hinter dem ValueArc ein Kreisbogen hinterlegt der den vollen Wertebereich andeutet. Der Startpunkt und die Radien des Hintergrund-Bogen (BackgroundArc) sind dabei identisch mit den des ValueArc. Diese Eigenschaften können mit einen ElementBinding übersichtlich direkt im XAML-Template verknüpft werden.

<!-- BACKGROUND ARC -->
<Path Stroke="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=BackArcBrush}" StrokeThickness="10">
    <Path.Data>
        <PathGeometry>
            <PathFigure StartPoint="{Binding ElementName=ValuePathFigure, Path=StartPoint, Mode=OneWay}"  >
                <ArcSegment x:Name="BackgroundArc"
                            IsLargeArc="True"             
                            SweepDirection="Clockwise"
                            Size="{Binding ElementName=ValueArc, Path=Size, Mode=OneWay}">
                </ArcSegment>
            </PathFigure>
        </PathGeometry>
    </Path.Data>
</Path>

Da der Endpunkt des BackgroundArc immer bei 100% liegt muss er nur neu berechnet werden wenn sich die Größe des Controls ändert.

mBackgroundArc.Point = CalculateEndpoint(100.0);

Nun aber zur Farbe! Um den ValueArc, BackgroundArc und den Text frei einfärben zu können fügen wir den Control drei Brush-Eigenschaften hinzu:

public Brush ValueArcBrush
{
    get { return (Brush)GetValue(ValueArcBrushProperty); }
    set { SetValue(ValueArcBrushProperty, value); }
}
public static readonly DependencyProperty ValueArcBrushProperty =
    DependencyProperty.Register("ValueArcBrush",
    typeof(Brush),
    typeof(RadialProgressBar),
    new PropertyMetadata(Brushes.OrangeRed));

public Brush BackArcBrush
{
    get { return (Brush)GetValue(BackArcBrushProperty); }
    set { SetValue(BackArcBrushProperty, value); }
}
public static readonly DependencyProperty BackArcBrushProperty =
    DependencyProperty.Register("BackArcBrush",
    typeof(Brush),
    typeof(RadialProgressBar),
    new PropertyMetadata(new SolidColorBrush(Color.FromRgb(77, 77, 77))));

public Brush NumericBrush
{
    get { return (Brush)GetValue(NumericBrushProperty); }
    set { SetValue(NumericBrushProperty, value); }
}
public static readonly DependencyProperty NumericBrushProperty =
    DependencyProperty.Register("NumericBrush",
    typeof(Brush),
    typeof(RadialProgressBar),
    new PropertyMetadata(Brushes.OrangeRed));

Über das {RelativeSource TemplatedParent} Binding können die entsprechenden Eigenschaften mit den Brush-DependencyProperties verknüpft werden.

 Stroke="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=BackArcBrush}"

Einen Leuchteffekt einbauen

Um das Design weiter zu verfeinern kann relativ leicht ein cooler Leuchteffekt eingebaut werden. Dazu platzieren wir ein drittes verschwommenes ArcSegment (BlurArc) direkt hinter das ValueArc. Durch die Überlagerung des scharf gezeichneten ValueArc über dem verschwommenen BlurArc entsteht der Glüheffekt. Da die beiden Kreisbögen bis auf den Blur-Effekt identisch sind können alle Eigenschaften des BlurArc vom ValueArc übernommen werden. Zum verschmieren wird dem Pfad das BlurArc einfach ein BlurEffekt-Objekt hinzugefügt:

<!-- BLUR ARC -->
<Path Stroke="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=ValueArcBrush, Mode=OneWay}"
      StrokeThickness="10"
      Visibility="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Glow, Converter={StaticResource ResourceKey=boolToVisConverter}}">
    <Path.Effect>
        <BlurEffect KernelType="Gaussian" Radius="15" RenderingBias="Performance"  />
    </Path.Effect>
    <Path.Data>
        <PathGeometry>
            <PathFigure StartPoint="{Binding ElementName=ValuePathFigure, Path=StartPoint, Mode=OneWay}">
                <ArcSegment
                            SweepDirection="Clockwise"
                            IsLargeArc="{Binding ElementName=ValueArc, Path=IsLargeArc, Mode=OneWay}"
                            Point="{Binding ElementName=ValueArc, Path=Point, Mode=OneWay}"
                            Size="{Binding ElementName=ValueArc, Path=Size, Mode=OneWay}">
                </ArcSegment>
            </PathFigure>
        </PathGeometry>
    </Path.Data>
</Path>

Um den Glüheffekt ausschalten zu können fügen wir dem Control noch eine Glow-Option vom Typ bool hinzu. Durch einen einfachen BoolToVisibility-Converter kann die Sichtbarkeit des BlurArc über das TemplatedParent-Binding mit der Glow-Eigenschaft verknüpft werden.

public bool Glow
{
    get { return (bool)GetValue(GlowProperty); }
    set { SetValue(GlowProperty, value); }
}
public static readonly DependencyProperty GlowProperty =
    DependencyProperty.Register("Glow",
    typeof(bool),
    typeof(RadialProgressBar),
    new PropertyMetadata(false));

Verschiedene Formatierungen der Wertanzeige

Zuletzt wollen wir noch eine Möglichkeit einbauen die numerische Anzeige des Wertes auf verschiedene Weise zu formatieren. Der aktuelle Wert soll mit auswählbarer Anzahl an Nachkommastellen und als Prozentsatz oder Absolutwert angezeigt werden können. Am einfachsten implementieren wir diese Funtionalität indem wir eine Enum mit den verschiedenen Formatierungen zur Verfügung stellen. Das ist zwar etwas unflexibel, aber der Fokus dieses Posts liegt nicht bei ContentPresentern, DataTemplates und co. Um die Formatierung einstallbar zu machen spendieren wir dem Control eine letztes DependencyProperty.

public enum NumericStyle
{
    Percentage_0_DecimalPlaces,
    Percentage_1_DecimalPlaces,
    Percentage_2_DecimalPlaces,
    AbsoluteValue_0_DecimalPlaces,
    AbsoluteValue_1_DecimalPlaces,
    AbsoluteValue_2_DecimalPlaces,
}
public NumericStyle DigitStyle
{
    get { return (NumericStyle)GetValue(DigitStyleProperty); }
    set { SetValue(DigitStyleProperty, value); }
}
public static readonly DependencyProperty DigitStyleProperty =
    DependencyProperty.Register("DigitStyle",
    typeof(NumericStyle),
    typeof(RadialProgressBar),
    new PropertyMetadata(NumericStyle.Percentage_0_DecimalPlaces));

Wie bereits oben bei der Winkelberechnung zu sehen wird in OnValueChanged(…) die Methode UpdateTextBlock(…) aufgerufen. In dieser Funktion wird die Wertanzeige abhängig von der eingestellten Formatierung aktualisiert.

private void UpdateTextBlock(double value, double percentage)
{
    switch (DigitStyle)
    {
        case NumericStyle.AbsoluteValue_0_DecimalPlaces:
            mValueText.Text = ((int)value).ToString();
            break;
        case NumericStyle.AbsoluteValue_1_DecimalPlaces:
            mValueText.Text = value.ToString("0.0");
            break;
        case NumericStyle.AbsoluteValue_2_DecimalPlaces:
            mValueText.Text = value.ToString("0.00");
            break;
        case NumericStyle.Percentage_1_DecimalPlaces:
            mValueText.Text = percentage.ToString("0.0") + "%";
            break;
        case NumericStyle.Percentage_2_DecimalPlaces:
            mValueText.Text = percentage.ToString("0.00") + "%";
            break;
        case NumericStyle.Percentage_0_DecimalPlaces:
        default:
            mValueText.Text = (int)percentage + "%";
            break;
    }
}

Das wars! Die Fortschrittsanzeige ist fertig. Naben dem Glüheffekt können verschiedene Farben und Formatierungen eingestellt werden. Nachdem Hinzufügen der Bibliothek kann die runde Fortschrittsanzeige ganz leicht in jedes Element eingebaut werden:

<Window x:Class="RadialProgressbar.Client.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:controls="clr-namespace:RadialProgressbar;assembly=RadialProgressbar"
        Background="#333333"
        Title="Radial progressbar samples">
    <Grid>
        <controls:RadialProgressBar 
                Margin="10"
                Grid.Row="0"
                Grid.Column="1"
                Value="{Binding ElementName=value_slider, Path=Value}"
                DigitStyle="Percentage_0_DecimalPlaces" 
                BackArcBrush="#444444"
                FontSize="60"
                ValueArcBrush="OrangeRed"
                NumericBrush="OrangeRed"
                Glow="True" />
    </Grid>
</Window>

 

Ich hoffe der Post war hilfreich! Das komplette Projekt findet ihr auf Github unter folgender Link: https://github.com/robibobi/radial-progress-bar

 

 

Leave a Comment

Your email address will not be published. Required fields are marked *