Implementierung eines einfachen Tacho-Control (Gauge-Control) in WPF Teil 1


dw1

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.

 

GaugeOverview
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.

01_projectStructureStart
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: public static class 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:     public static double 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:     public static double 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:     public static double 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:     public static double RemapValue(double value, 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: <UserControl x: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:     <Grid x: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:         <Polygon x: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:         <Border Background="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:

  1. Minimum: Gibt die Untergrenze des einzustellenden Wertes an.
  2. Maximum: Gibt die Obergrenze des einzustellenden Wertes an.
  3. Value: Gibt den aktuell eingestellten Wert an oder legt diesen fest.
  4. TickFrequency: Gibt an, wie viele Ticks im Bereich von Minimum bis Maximum platziert werden sollen.
  5. IsSnapToTickEnabled: Legt fest, ob der Zeiger ausschließlich auf Werte der TickFrequency gesetzt werden kann.
  6. 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: public bool 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: public static readonly 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: public int 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: public static readonly 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: private static void 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: private int 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: public static readonly 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: public double 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: public static readonly 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: private static void 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: public double 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: public static readonly 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: public double 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: public static readonly 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: private bool 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: private void 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: private void 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: private void 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!

Kommentare sind geschlossen