MVVM zum Zweiten: Farbräume RGB und HSV

1. Aufgabe

Im ersten Teil dieser Fallstudie zum MVVM-Entwurfsmuster stand das Anbinden einer View an Eigenschaften des ViewModels – und damit letzten Endes – an die Daten des unterlagerten Modells im Mittelpunkt. Ändern sich die Daten im ViewModel (bzw. im Modell), passt die View ihre Darstellung automatisch an. Es ist aber auch der umgekehrte Weg einer interaktiven View vorstellbar: Werden in einer View bestimmte Werte geändert, so können Auswirkungen auf den Datenbestand die Folge sein. In diesem Fall müssen Views mit der so genannten bidirektionalen Datenbindung (two-way databinding) arbeiten, um das unterlagerte ViewModel (sowie das nochmals unterlagerte Modell) synchron zu halten.

Wir studieren die bidirektionale Datenbindung am Beispiel eines Color-Viewers mit den Darstellungsarten RGB (Red, Green und Blue) und HSV (Farbwert Hue, Farbsättigung Saturation und Dunkelstufe Value). Ändert sich einer der Farbanteile Rot, Grün oder Blau, ändert sich automatisch die Farbe selbst wie auch die Werte des HSV-Farbraums (Hue, Saturation und Value) und umgekehrt. In der Anwendung in Abbildung 1 lassen sich die Farbanteile des RGB- oder HSV-Farbraums durch Schiebebalken einstellen. Änderungen eines Farbwertanteils führen unmittelbar zu Änderungen in den Farbanteilen des anderen Farbraums wie auch in der dargestellten Farbe selbst.

Darstellung von Farben in einer MVVM-Anwendung mit bidirektionaler Datenbindung.

Abbildung 1. Darstellung von Farben in einer MVVM-Anwendung mit bidirektionaler Datenbindung.


Entwickeln Sie für die Anwendung aus Abbildung 1 geeignet eine ViewModel- und eine View-Klasse. Welche Eigenschaften der View müssen Sie auf Basis der Datenbindung mit dem ViewModel unidirektional, welche bidirektional verbinden?

2. Lösung

Quellcode: Siehe auch https://github.com/peterloos/Wpf_MVVM_RgbHsvColorConverter.

Die Anwendung im zweiten Teil dieser Trilogie besteht nur aus einer View und einem ViewModel. Eine dritte Komponente (das Modell) spielt zur Illustration der bidirektionalen Datenbindung keine Rolle. Wir beginnen dieses Mal mit der Betrachtung der View in Listing 1:

01: <Window x:Class="Wpf_MVVM_02_RgbHsvSample.MainWindow"
02:         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
03:         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
04:         xmlns:local="clr-namespace:Wpf_MVVM_02_RgbHsvSample"
05:         Title="RGB/HSV Color Viewer" Height="500" Width="700">
06:     
07:     <Window.Resources>
08:         <local:HsvRgbViewModel x:Key="MyViewModel" Red="0" Green="127" Blue="255"/>
09:     </Window.Resources>
10: 
11:     <DockPanel LastChildFill="True" DataContext="{StaticResource MyViewModel}">
12:         <GroupBox Header="RGB Model" DockPanel.Dock="Top" Margin="5">
13:             <DockPanel LastChildFill="True">
14:                 <DockPanel LastChildFill="True" DockPanel.Dock="Top" Margin="5">
15:                     <Label DockPanel.Dock="Left" Width="60">Red</Label>
16:                     <TextBox Text="{Binding Red}"
17:                         DockPanel.Dock="Right" Width="80" TextAlignment="Right"/>
18:                     <Slider Value="{Binding Red, Mode=TwoWay}"
19:                         DockPanel.Dock="Top" Minimum="0" Maximum="255"/>
20:                 </DockPanel>
21:                 <DockPanel LastChildFill="True" DockPanel.Dock="Top" Margin="5">
22:                     <Label DockPanel.Dock="Left" Width="60">Green</Label>
23:                     <TextBox Text="{Binding Green}"
24:                         DockPanel.Dock="Right" Width="80" TextAlignment="Right"/>
25:                     <Slider Value="{Binding Green, Mode=TwoWay}"
26:                         DockPanel.Dock="Top" Minimum="0" Maximum="255"/>
27:                 </DockPanel>
28:                 <DockPanel LastChildFill="True" DockPanel.Dock="Top" Margin="5">
29:                     <Label DockPanel.Dock="Left" Width="60">Blue</Label>
30:                     <TextBox Text="{Binding Blue}"
31:                         DockPanel.Dock="Right" Width="80" TextAlignment="Right"/>
32:                     <Slider Value="{Binding Blue, Mode=TwoWay}"
33:                         DockPanel.Dock="Top" Minimum="0" Maximum="255"/>
34:                 </DockPanel>
35:             </DockPanel>
36:         </GroupBox>
37: 
38:         <GroupBox Header="HSV Model" DockPanel.Dock="Bottom" Margin="5">
39:             <DockPanel LastChildFill="True">
40:                 <DockPanel LastChildFill="True" DockPanel.Dock="Top" Margin="5">
41:                     <Label DockPanel.Dock="Left" Width="100">Hue</Label>
42:                     <TextBox Text="{Binding Hue}"
43:                         DockPanel.Dock="Right" Width="80" TextAlignment="Right"/>
44:                     <Slider Value="{Binding Hue, Mode=TwoWay}"
45:                         DockPanel.Dock="Top" Minimum="0" Maximum="360"/>
46:                 </DockPanel>
47:                 <DockPanel LastChildFill="True" DockPanel.Dock="Top" Margin="5">
48:                     <Label DockPanel.Dock="Left" Width="100">Saturation</Label>
49:                     <TextBox Text="{Binding Saturation}"
50:                         DockPanel.Dock="Right" Width="80" TextAlignment="Right"/>
51:                     <Slider Value="{Binding Saturation, Mode=TwoWay}"
52:                         DockPanel.Dock="Top" Minimum="0" Maximum="100"/>
53:                 </DockPanel>
54:                 <DockPanel LastChildFill="True" DockPanel.Dock="Top" Margin="5">
55:                     <Label DockPanel.Dock="Left" Width="100">Value</Label>
56:                     <TextBox Text="{Binding Value}"
57:                         DockPanel.Dock="Right" Width="80" TextAlignment="Right"/>
58:                     <Slider Value="{Binding Value, Mode=TwoWay}"
59:                         DockPanel.Dock="Top" Minimum="0" Maximum="100"/>
60:                 </DockPanel>
61:             </DockPanel>
62:         </GroupBox>
63: 
64:         <GroupBox Header="Color" Margin="5">
65:             <Canvas>
66:                 <Canvas.Background>
67:                     <SolidColorBrush Color="{Binding Color}"/>
68:                 </Canvas.Background>
69:             </Canvas>
70:         </GroupBox>
71:     </DockPanel>
72: </Window>

Beispiel 1. Implementierung einer View: Klasse MainWindow.


Folgende Stellen in Listing 1 sind hervorzuheben: In den Zeilen 18, 25 und 32 bzw. 44, 51 und 58 finden wir die bidirektionale Datenbindung an unterschiedlichen Steuerelementen vor, sie sieht so aus:

Value="{Binding Saturation, Mode=TwoWay}"

Man kann allgemein sagen, dass Änderungen an den Schiebebalken das ViewModel tangieren. Änderungen im ViewModel wiederum hat modifizierte Werte der anderen Schiebebalken zur Folge. Aus diesem Grund ist hier die bidirektionale Datenbindung erforderlich. Des Weiteren findet in Zeile 8 die Initialisierung des ViewModels statt. Ein Setzen der drei Farbanteile Red, Green und Blue zieht im ViewModel eine Initialisierung aller Farbanteils-Eigenschaften sowie der Color-Eigenschaft nach sich. Sehr wohl besitzt in Listing 1 auch die unidirektionale Datenbindung ihre Rechtfertigung. Alle TextBox-Objekte, die die Daten des ViewModels nur lesend darstellen wie auch das Canvas-Objekt in Zeile 65 kommen mit der unidirektionalen Datenbindung aus, da sie keine Änderungen am ViewModels vornehmen.

In Listing 2 finden Sie nun das ViewModel der Anwendung vor:

001: class HsvRgbViewModel : INotifyPropertyChanged
002: {
003:     private int red;         // 0 .. 255
004:     private int green;       // 0 .. 255
005:     private int blue;        // 0 .. 255
006: 
007:     private int hue;         // 0 .. 360
008:     private int saturation;  // 0 .. 100
009:     private int value;       // 0 .. 100
010: 
011:     private Color color;
012: 
013:     public event PropertyChangedEventHandler PropertyChanged;
014: 
015:     public HsvRgbViewModel()
016:     {
017:         this.Color = Colors.Black;
018:     }
019: 
020:     // public properties
021:     public int Hue
022:     {
023:         set
024:         {
025:             if (this.hue != value)
026:             {
027:                 this.hue = value;
028:                 this.OnPropertyChanged("Hue");
029:                 this.UpdateRgbFromHsv();
030:                 this.UpdateColor();
031:             }
032:         }
033:         get
034:         {
035:             return hue;
036:         }
037:     }
038: 
039:     public int Saturation
040:     {
041:         set
042:         {
043:             if (this.saturation != value)
044:             {
045:                 this.saturation = value;
046:                 this.OnPropertyChanged("Saturation");
047:                 this.UpdateRgbFromHsv();
048:                 this.UpdateColor();
049:             }
050:         }
051:         get
052:         {
053:             return saturation;
054:         }
055:     }
056: 
057:     public int Value
058:     {
059:         set
060:         {
061:             if (this.value != value)
062:             {
063:                 this.value = value;
064:                 this.OnPropertyChanged("Value");
065:                 this.UpdateRgbFromHsv();
066:                 this.UpdateColor();
067:             }
068:         }
069:         get
070:         {
071:             return value;
072:         }
073:     }
074: 
075:     public int Red
076:     {
077:         set
078:         {
079:             if (this.red != value)
080:             {
081:                 this.red = value;
082:                 this.OnPropertyChanged("Red");
083:                 this.UpdateHsvFromRgb();
084:                 this.UpdateColor();
085:             }
086:         }
087:         get
088:         {
089:             return this.red;
090:         }
091:     }
092: 
093:     public int Green
094:     {
095:         set
096:         {
097:             if (this.green != value)
098:             {
099:                 this.green = value;
100:                 this.OnPropertyChanged("Green");
101:                 this.UpdateHsvFromRgb();
102:                 this.UpdateColor();
103:             }
104:         }
105:         get
106:         {
107:             return this.green;
108:         }
109:     }
110: 
111:     public int Blue
112:     {
113:         set
114:         {
115:             if (this.blue != value)
116:             {
117:                 this.blue = value;
118:                 this.OnPropertyChanged("Blue");
119:                 this.UpdateHsvFromRgb();
120:                 this.UpdateColor();
121:             }
122:         }
123:         get
124:         {
125:             return this.blue;
126:         }
127:     }
128: 
129:     public Color Color
130:     {
131:         set
132:         {
133:             if (this.color != value)
134:             {
135:                 this.color = value;
136:                 this.OnPropertyChanged("Color");
137:             }
138:         }
139: 
140:         get
141:         {
142:             return color;
143:         }
144:     }
145: 
146:     // private helper methods
147:     private void UpdateColor()
148:     {
149:         this.Color = Color.FromRgb((byte) this.red, (byte) this.green, (byte) this.blue);
150:     }
151: 
152:     private void UpdateHsvFromRgb()
153:     {
154:         Rgb rgb = new Rgb() { R = this.red, G = this.green, B = this.blue };
155:         Hsv hsv = rgb.To<Hsv>();
156: 
157:         if (this.hue != (int) hsv.H)
158:         {
159:             this.hue = (int) hsv.H;
160:             this.OnPropertyChanged("Hue");
161:         }
162: 
163:         if (this.saturation != (int) (100.0 * hsv.S))
164:         {
165:             this.saturation = (int) (100.0 * hsv.S);
166:             this.OnPropertyChanged("Saturation");
167:         }
168: 
169:         if (this.value != (int) (100.0 * hsv.V))
170:         {
171:             this.value = (int) (100.0 * hsv.V);
172:             this.OnPropertyChanged("Value");
173:         }
174:     }
175: 
176:     private void UpdateRgbFromHsv()
177:     {
178:         Hsv hsv = new Hsv() { H = this.hue, S = this.saturation / 100.0, V = this.value / 100.0 };
179:         Rgb rgb = hsv.To<Rgb>();
180: 
181:         if (this.red != (int)rgb.R)
182:         {
183:             this.red = (int)rgb.R;
184:             this.OnPropertyChanged("Red");
185:         }
186: 
187:         if (this.green != (int)rgb.G)
188:         {
189:             this.green = (int)rgb.G;
190:             this.OnPropertyChanged("Green");
191:         }
192: 
193:         if (this.blue != (int)rgb.B)
194:         {
195:             this.blue = (int)rgb.B;
196:             this.OnPropertyChanged("Blue");
197:         }
198:     }
199: 
200:     protected virtual void OnPropertyChanged(String propertyName)
201:     {
202:         if (this.PropertyChanged != null)
203:         {
204:             this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
205:         }
206:     }
207: }

Beispiel 2. Implementierung des ViewModels: Klasse HsvRgbViewModel.


Für die Umrechnung von Farben aus dem RGB-Farbraum in den HSV-Farbraum und umgekehrt kommt eine externe Klassenbibliothek namens „ColorMine“ zum Einsatz, siehe dazu auch http://colormine.org/. Mit Hilfe des NuGet-Paketmanagers ist die entsprechende Klassenbibliothek dem Projekt hinzuzufügen. Die entsprechende using-Anweisung macht den Namensraum ColorMine.ColorSpaces bekannt.

Exemplarisch betrachtet sieht eine Umrechnung der Farbe Rot vom RGB- in den HSV-Farbraum mit Hilfe von ColorMine nun so aus:

Rgb rgb = new Rgb() { R = 255, G = 0, B = 0 };
Hsv hsv = rgb.To<Hsv>();

In den Zeilen 3 bis 9 von Listing 2 finden Sie in den Kommentaren zu den Farbanteilen deren Wertebereiche vor. Für RGB ist es der bekannte Bereich von 0 bis einschließlich 255. Bzgl. des HSV-Farbraums lege ich für die beiden Anteile Farbsättigung (Instanzvariable saturation) und Dunkelstufe (Instanzvariable value) den Bereich von 0 bis 100 zu Grunde. Die „ColorMine“-Klassen arbeiten hier mit double-Werten innerhalb des Bereichs 0.0 bis 1.0. Dieser Bereich eignet sich zur Darstellung auf einem Schiebebalken nicht so gut, deshalb die Umrechnung auf eine Skalierung im Bereich von 0 bis 100. Die Farbsättigung wird meist im Bereich von 0.0 bis 360.0 spezifiziert.

Natürlich muss das ViewModel bei Änderungen eines Farbanteils die einzelnen Werte des anderen Farbraums nachjustieren. Dazu gibt es im ViewModel die beiden Hilfsmethoden UpdateHsvFromRgb (Zeilen 152 bis 174) und UpdateRgbFromHsv (Zeilen 176 bis 198). Die drei öffentlichen Eigenschaften Red, Green und Blue bewirken mit dem Aufruf von UpdateHsvFromRgb, dass die Farbanteile des korrespondierenden Farbraums auf dem Laufenden bleiben, und umgekehrt. Alle sechs Farbanteile zusammen haben den Wunsch, dass die siebte öffentliche Eigenschaft Color aktuell bleibt und rufen deshalb in den set-Methoden die UpdateColor-Methode auf.

Zum Abschluss wiederholen wir den Gebrauch der INotifyPropertyChanged-Schnittstelle. Zum Zwecke der Datenbindung muss jede Eigenschaft des ViewModels, die ihren Wert ändert, dies durch ein Auslösen des PropertyChanged-Ereignisses kund tun. Diesem Zweck dienen die OnPropertyChanged-Methodenaufrufe, die in Listing 2 zahlreich auftreten.