In diesem Artikel möchte ich euch zeigen, wie wir ein einfaches Tacho-Control als User Control in WPF implementieren können (siehe Abb. 1). Insgesamt wird sich die Implementierung des Controls in zwei Teile gliedern. Im ersten Teil geht es darum, die Grundfunktionalität für das Tacho-Control zu herzustellen. D.h. wir werden unserm Control ein Userinterface verpassen, sowie alle Notwendigen Eigenschaften und Methoden für die Nutzerinteraktion. Im 2. Teil werden wir uns detaillierter mit Custom Panels beschäftigen und wie wir mit dessen Hilfe eine Skala für unser Control erstellen können. Um die Schritte und den Quellcode besser nachvollziehen zu können, könnt ihr das komplette Beispiel unter https://github.com/d-wolf/GaugeUserControlWPF einsehen bzw. herunterladen.
![]() |
Abb. 1: Vorschau des fertigen Tacho-Control mit gebundenem Slider |
Als erstes legen Wir uns ein Projekt an und erstellen eine WPF User Control Library mit der Struktur aus Abb. 2.
![]() |
Abb. 2: Struktur der WPF User Control Library |
Danach legen wir uns ein Klasse mit allen Mathematischen Hilfsmethoden an, welche wir für unser Tacho-Control benötigen werden. Für unsere kleine Bibliothek erstellen wir eine statische Klasse GeoMath im Ordner Common. Der vollständige Quellcode der Klasse ist in Abb. 3 zusehen.
1:publicstaticclass GeoMath 2: { 3:/// <summary> 4:/// Berechnet den Winkel zwischen zwei Punkten 5:/// </summary> 6:/// <param name="origin">Startpunkt</param> 7:/// <param name="target">Endpunkt</param> 8:/// <returns>Winkel zwischen 2 Punkten in Grad</returns> 9:publicstaticdouble AngleBetween(Point origin, Point target) 10: { 11:return RadianToDegree(Math.Atan2(origin.Y - target.Y, origin.X - target.X)); 12: } 13: 14:/// <summary> 15:/// Wandelt grad in Bogenmaß um 16:/// </summary> 17:/// <param name="angle">Winkel in Grad</param> 18:/// <returns>Winkel als Bogenmaß</returns> 19:publicstaticdouble DegreeToRadian(double angle) 20: { 21:return Math.PI * angle / 180.0; 22: } 23: 24:/// <summary> 25:/// Wandelt Bogenmaß in Grad um 26:/// </summary> 27:/// <param name="angle">Winkel als Bogenmaß</param> 28:/// <returns>Winkel in Grad</returns> 29:publicstaticdouble RadianToDegree(double angle) 30: { 31:return angle * (180.0 / Math.PI); 32: } 33: 34:/// <summary> 35:/// Bildet wert auf neuen Wertebereich ab 36:/// </summary> 37:/// <param name="value">Abzubildender Wert</param> 38:/// <param name="oldMin">altes Minimum</param> 39:/// <param name="oldMax">altes Maximum</param> 40:/// <param name="newMin">neues Minimum</param> 41:/// <param name="newMax">neues maximum</param> 42:/// <returns>auf neuen Bereich Abgebildeter Wert</returns> 43:publicstaticdouble RemapValue(doublevalue, double oldMin, double oldMax, double newMin, double newMax) 44: { 45:return (value - oldMin) / (oldMax - oldMin) * (newMax - newMin) + newMin; 46: } 47: } |
Abb. 3: XAML-Code für unser Tacho-Control |
Nachdem wir unsere Hilfsklasse angelegt haben, können wir uns nun der Benutzeroberfläche unseres Tacho-Control widmen. Dieses soll vorerst aus einem einfachen Zeiger bestehen (siehe Abb. 4).
1:<UserControlx:Class="GaugeUserControlLib.GaugeControl" 2:xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 3:xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 4:xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 5:xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 6:xmlns:local="clr-namespace:GaugeUserControlLib" 7:mc:Ignorable="d" 8:d:DesignHeight="300"d:DesignWidth="300"> 9:<!--Container für Control. Behandelt den UserInput zum einstellen des Zeigers--> 10:<Gridx:Name="LayoutRoot"MouseMove="LayoutRoot_MouseMove"MouseDown="LayoutRoot_MouseDown"MouseUp="LayoutRoot_MouseUp"Background="Transparent"> 11:<!--repräsentiert den Zeiger, dünne Seite repräsentiert aktuellen Wert--> 12:<Polygonx:Name="Needle"VerticalAlignment="Stretch"HorizontalAlignment="Stretch"Points="0,3 0,4 7,7, 7.15,3.5 7,0"Height="4"Stroke="Black"Fill="Black"Width="{Binding ElementName=LayoutRoot, Path=ActualWidth}"Stretch="Fill"RenderTransformOrigin="0.5, 0.5"> 13:</Polygon> 14:<!--kennzeichnet Mittelpunkt des Tacho--> 15:<BorderBackground="LightGray"Width="5"Height="5"HorizontalAlignment="Center"VerticalAlignment="Center"CornerRadius="2"/> 16:</Grid> 17:</UserControl> |
Abb 4: XAML für das Tacho-Control |
Nachdem wir das Userinterface definiert haben, müssen wir uns Gedanken machen, passende Eigenschaften für unser Control als Dependency Properties zu definieren. Diese sollen sich wie folgt gestalten:
- Minimum: Gibt die Untergrenze des einzustellenden Wertes an.
- Maximum: Gibt die Obergrenze des einzustellenden Wertes an.
- Value: Gibt den aktuell eingestellten Wert an oder legt diesen fest.
- TickFrequency: Gibt an, wie viele Ticks im Bereich von Minimum bis Maximum platziert werden sollen.
- IsSnapToTickEnabled: Legt fest, ob der Zeiger ausschließlich auf Werte der TickFrequency gesetzt werden kann.
- TickCount: Enthält die Anzahl der anzuzeigenden Ticks, berechnet aus Minimum, Maximum und TickFrequency. Diese Eigenschaft wird erst später für die Anzeige der Skala benötigt.
In Abb. 5 sehen wir die Implementierung aller zuvor aufgelisteten Dependency Properties sowie deren Callback Handler.
1:/// <summary> 2:/// aktiviert das automatische snappen an Tciks im Ziffernblatt 3:/// </summary> 4:publicbool IsSnapToTickEnabled 5: { 6: get { return (bool)GetValue(IsSnapToTickEnabledProperty); } 7: set { SetValue(IsSnapToTickEnabledProperty, value); } 8: } 9: 10:// Using a DependencyProperty as the backing store for IsSnapToTickEnabled. This enables animation, styling, binding, etc... 11:publicstaticreadonly DependencyProperty IsSnapToTickEnabledProperty = 12: DependencyProperty.Register("IsSnapToTickEnabled", typeof(bool), typeof(GaugeControl), new PropertyMetadata(false)); 13: 14: 15:/// <summary> 16:/// Frequenz für Skala des Messgerätes 17:/// </summary> 18:publicint TickFrequency 19: { 20: get 21: { 22:return (int)GetValue(TickFrequencyProperty); 23: } 24: set 25: { 26: SetValue(TickFrequencyProperty, value); 27: } 28: } 29: 30:/// <summary> 31:/// Dependency Property für TickFrequency 32:/// </summary> 33:publicstaticreadonly DependencyProperty TickFrequencyProperty = 34: DependencyProperty.Register("TickFrequency", typeof(int), typeof(GaugeControl), new PropertyMetadata(12, OnTickFrequencyChanged)); 35: 36:/// <summary> 37:/// Callback für Änderung der Tickfrequenz 38:/// berechnet neue Tickfrequenz abhängig vom Minimum und Maximum 39:/// </summary> 40:/// <param name="d">Modifizierte DependencyObject</param> 41:/// <param name="e">Infos über geänderte Attribute</param> 42:privatestaticvoid OnTickFrequencyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) 43: { 44: var t = d as GaugeControl; 45: 46:if (t != null) 47: { 48:double newTicks = ((t.Maximum - t.Minimum) / (int)e.NewValue); 49: t.TickCount = Convert.ToInt32(newTicks); 50: } 51: } 52: 53:/// <summary> 54:/// Enthält die berechnete Anzahl der anzuzeigenden Ticks aus Tickfrequency, Minimum und Maximum 55:/// </summary> 56:privateint TickCount 57: { 58: get { return (int)GetValue(TickCountProperty); } 59: set { SetValue(TickCountProperty, value); } 60: } 61: 62:// Using a DependencyProperty as the backing store for TickCount. This enables animation, styling, binding, etc... 63:publicstaticreadonly DependencyProperty TickCountProperty = 64: DependencyProperty.Register("TickCount", typeof(int), typeof(GaugeControl), new PropertyMetadata(null)); 65: 66: 67: 68:/// <summary> 69:/// Aktueller Anzeigewert des Messgerätes 70:/// </summary> 71:publicdouble Value 72: { 73: get { return (double)GetValue(ValueProperty); } 74: set { SetValue(ValueProperty, value); } 75: } 76: 77:/// <summary> 78:/// Dependency Property für Aktuellen Anzeigewert 79:/// </summary> 80:publicstaticreadonly DependencyProperty ValueProperty = 81: DependencyProperty.Register("Value", typeof(double), typeof(GaugeControl), new PropertyMetadata(0.0, OnValueChanged)); 82: 83:/// <summary> 84:/// Anpassung der Rotation des Zeigers unter Einbezug des Minimum und Maximum falls eine Wertänderung vorliegt 85:/// </summary> 86:/// <param name="d"></param> 87:/// <param name="e"></param> 88:privatestaticvoid OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) 89: { 90: var t = d as GaugeControl; 91: 92:if (t != null) 93: { 94:double newValue = (double)e.NewValue; 95:double newAngle = MathExtensions.RemapValue(newValue, t.Minimum, t.Maximum, 0, 360); 96: t.Needle.RenderTransform = new RotateTransform(newAngle); 97: } 98: } 99: 100:/// <summary> 101:/// Untere Grenze des Wertebereichs für das Messgerät 102:/// </summary> 103:publicdouble Minimum 104: { 105: get { return (double)GetValue(MinimumProperty); } 106: set { SetValue(MinimumProperty, value); } 107: } 108: 109:/// <summary> 110:/// Dependency Property für Minimum 111:/// </summary> 112:publicstaticreadonly DependencyProperty MinimumProperty = 113: DependencyProperty.Register("Minimum", typeof(double), typeof(GaugeControl), new PropertyMetadata(0.0)); 114: 115:/// <summary> 116:/// Obere Grenze des Wertebereichs für das Messgerät 117:/// </summary> 118:publicdouble Maximum 119: { 120: get { return (double)GetValue(MaximumProperty); } 121: set { SetValue(MaximumProperty, value); } 122: } 123: 124:/// <summary> 125:/// Dependency Property für Maximum 126:/// </summary> 127:publicstaticreadonly DependencyProperty MaximumProperty = 128: DependencyProperty.Register("Maximum", typeof(double), typeof(GaugeControl), new PropertyMetadata(360.0)); |
Abb. 5: Implementierung aller Dependency Properties in der GaugeControl.xaml.cs |
Nun ist die Basis für unser Tacho-Control schon so gut wie fertig. Zum Schluss müssen wir nur noch die Nutzerinteraktion für unseren Zeiger behandeln, damit der aktuelle Wert auch mithilfe von diesem eigestellt werden kann. Dafür müssen wir lediglich den Code aus Abb. 5 unserer Klasse GaugeControl.xaml.cs hinzufügen.
1:/// <summary> 2:/// Flag um Zustand MousePressed festzuhalten 3:/// </summary> 4:privatebool mousePressed = false; 5: 6:/// <summary> 7:/// Iteraktionslogik für das Einstellen eines neuen Wertes mittels Maus 8:/// </summary> 9:/// <param name="sender">Auslöser</param> 10:/// <param name="e">MouseEvent Argumente</param> 11:privatevoid LayoutRoot_MouseMove(object sender, MouseEventArgs e) 12: { 13:// zustand Maus gedrückt 14:if (mousePressed) 15: { 16: FrameworkElement fe = sender as FrameworkElement; 17: 18:if (fe != null) 19: { 20: Point startPoint = new Point(fe.ActualWidth / 2, fe.ActualHeight / 2); 21: Point endPoint = Mouse.GetPosition(fe); 22: 23:// Berechnung des Winkels zwischen Zentrum und aktueller Mausposition 24:double newAngle = MathExtensions.AngleBetween(startPoint, endPoint); 25: newAngle = newAngle < 0 ? newAngle + 360 : newAngle; 26: 27:if (this.IsSnapToTickEnabled) 28: { 29:// Berechnung des Versatzen/Abstand zwischen Elementen anhand der Anzahl der Elemente 30:double degreesOffset = 360.0 / this.TickCount; 31: 32:for (int i = 0; i < this.TickCount; i++) 33: { 34:double leftAngle = degreesOffset * i; 35:double rightAngle = leftAngle + degreesOffset; 36: 37:if (newAngle >= leftAngle && newAngle <= rightAngle) 38: { 39:double distleft = Math.Abs(leftAngle - newAngle); 40:double distright = Math.Abs(rightAngle - newAngle); 41: 42:if (distleft <= distright) 43: newAngle = leftAngle; 44:else 45: newAngle = rightAngle; 46: } 47: } 48: } 49: 50:// Abbilden des Winkels auf Wertebereich 51:double remappedValue = MathExtensions.RemapValue(newAngle, 0, 360, this.Minimum, this.Maximum); 52: 53:this.Value = remappedValue; 54: } 55: } 56: 57: e.Handled = true; 58: } 59: 60:/// <summary> 61:/// Initialisiert die Wertänderung des Controls mittels Maus 62:/// </summary> 63:/// <param name="sender">Auslöser</param> 64:/// <param name="e">MouseEvent Argumente</param> 65:privatevoid LayoutRoot_MouseDown(object sender, MouseButtonEventArgs e) 66: { 67:this.mousePressed = true; 68: var element = e.Source as IInputElement; 69: Mouse.Capture(element); 70: e.Handled = true; 71: } 72: 73:/// <summary> 74:/// Schließt den Vorgang der Wertänderung mittels Maus ab 75:/// </summary> 76:/// <param name="sender">Auslöser</param> 77:/// <param name="e">MouseEvent Argumente</param> 78:privatevoid LayoutRoot_MouseUp(object sender, MouseButtonEventArgs e) 79: { 80:this.mousePressed = false; 81: Mouse.Capture(null); 82: e.Handled = true; 83: } |
Abb. 5: Eventbehandlung zum einstellen des Zeigers |
Nachdem wir alle Schritte umgesetzt haben, sollte nun die Grundfunktionalität für unser Control gewährleistet sein. Wir können Minimum und Maximum einstellen, den Wert setzen bzw. an andere Controls wie z.B. einen Slider binden, eine Tick-Frequenz angeben und Einstellen ob nur Werte nach Tick-Frequenz erlaubt sein sollen. Im nachfolgenden Teil werden wir unser Control mit einem Custom Panel ausstatten, um eine Skala anzuzeigen.
Viel Spaß damit!