Fenster mit WPF
Kurz gesagt: WPF ist das .NET-Framework für Desktopprogramme unter Windows: Das Fenster wird in XAML beschrieben, der Code dahinter ist C#, und die Datenbindung aktualisiert den Bildschirm von selbst.
Problem
Die Kasse im Laden
Die Person an der Kasse ist Verkäuferin, keine Programmiererin. Eine Konsole mit „Preis eingeben:“ reicht da nicht: Du brauchst Felder, Buttons, eine Liste der Produkte auf dem Kassenbon und die Summe in großer Schrift. Das Programm muss auf dem Kassencomputer starten, auf dem Windows läuft, und auch ohne Internet funktionieren.
Eine Webseite könnte das, aber sie braucht einen Browser und einen Server. Für ein Programm, das auf einem Computer lebt, gibt es Desktop-Frameworks. In .NET für Windows ist das wichtigste WPF (Windows Presentation Foundation).
Die Idee
Das Fenster ist XAML, das Verhalten ist C#
Ein WPF-Programm hat zwei Seiten:
- Wie es aussieht, wird in XAML beschrieben: XML mit Tags für die Steuerelemente.
<Button Content="Hinzufügen" />ist ein Button mit einer Beschriftung. Die Tags sind verschachtelt: Das Fenster enthält ein Layout, und das Layout enthält Steuerelemente. - Was es tut, steht in C#, im Code-Behind:
MainWindow.xaml.cs. Die beiden Dateien sind ein und dieselbe KlasseMainWindow, mitpartialin zwei Teile geteilt.
Ein neues Projekt mit dotnet new wpf -n Shop hat vier Dateien:
| Datei | Was sie ist |
|---|---|
App.xaml |
die Anwendung; StartupUri sagt, welches Fenster geöffnet wird |
App.xaml.cs |
der Code der Anwendung (hier leer) |
MainWindow.xaml |
das Fenster: die Steuerelemente und ihr Layout |
MainWindow.xaml.cs |
der Code hinter dem Fenster: die Handler |
<Application x:Class="Shop.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" StartupUri="MainWindow.xaml"></Application>xmlns und xmlns:x am Anfang sagen dem Compiler, woher die Tags kommen: Das erste sind die Steuerelemente von WPF, das zweite die besonderen Attribute wie x:Class und x:Name.
WPF funktioniert nur unter Windows. Du öffnest das Projekt in Visual Studio und drückst F5, oder du tippst dotnet run in seinem Ordner. Die Programme in dieser Lektion wurden kompiliert, aber nicht ausgeführt: Die Prüfung der Website läuft unter Linux, wo sich kein Fenster öffnen kann. Das Kompilieren findet Fehler im C# und im XAML, etwa einen Handler, den es nicht gibt, aber nicht, wie das Fenster aussieht.
Genauer, für Neugierige
- Andere Desktop-Frameworks in .NET: WinForms (das ältere, ebenfalls nur für Windows, bei dem du das Fenster in einem Designer zeichnest), WinUI 3 (das neue für Windows) sowie .NET MAUI und Avalonia, die unter Windows, macOS und Linux laufen. Avalonia verwendet XAML fast wie WPF.
- XAML gibt es nicht nur für WPF: WinUI, MAUI und Avalonia verwenden dieselbe Sprache. Was du hier lernst, gilt also auch dort.
Sieh es dir an
Steuerelemente und Layout
Die Kasse: oben Felder für Produkt und Preis mit einem Button, in der Mitte die Liste, unten die Summe und ein Button zum Entfernen eines Eintrags. MainWindow.xaml:
<Window x:Class="Shop.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Kasse" Width="440" Height="360"> <Grid Margin="12"> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> <RowDefinition Height="Auto" /> </Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal"> <TextBlock Text="Produkt:" VerticalAlignment="Center" /> <TextBox x:Name="NameBox" Width="150" Margin="6,0" /> <TextBlock Text="Preis:" VerticalAlignment="Center" /> <TextBox x:Name="PriceBox" Width="60" Margin="6,0" /> <Button Content="Hinzufügen" Click="AddButton_Click" /> </StackPanel>
<ListBox x:Name="ItemsList" Grid.Row="1" Margin="0,10" />
<StackPanel Grid.Row="2" Orientation="Horizontal"> <TextBlock x:Name="TotalText" Text="Summe: 0.00 €" FontWeight="Bold" VerticalAlignment="Center" /> <Button Content="Auswahl entfernen" Click="RemoveButton_Click" Margin="16,0,0,0" /> </StackPanel> </Grid></Window>Gridteilt den Platz in Zeilen und Spalten. Hier gibt es drei Zeilen:Autoist so viel, wie der Inhalt braucht, und*ist alles, was übrig bleibt. Ein Steuerelement sagt mitGrid.Row="1", in welcher Zeile es steht.StackPanelreiht die Steuerelemente nacheinander auf, senkrecht oder mitOrientation="Horizontal"waagerecht.TextBlockist Text zum Lesen,TextBoxein Feld zum Tippen,Buttonein Button (eine Schaltfläche) undListBoxeine Liste, aus der du eine Zeile auswählen kannst.Margin="6,0"ist der leere Raum um das Steuerelement: 6 links und rechts, 0 oben und unten. Mit vier Zahlen ist es links, oben, rechts, unten.x:Name="PriceBox"gibt dem Steuerelement einen Namen. Im Code hinter dem Fenster ist es ein Feld mit diesem Namen.
Genauer, für Neugierige
WidthundHeightsind in Einheiten von 1/96 Zoll angegeben, nicht in Pixeln. Auf einem Bildschirm mit 150 % Skalierung ist das Fenster in Pixeln größer, sieht aber gleich aus. Deshalb ist ein WPF-Fenster auf jedem Bildschirm scharf.- Neben
GridundStackPanelgibt esDockPanel(Steuerelemente werden oben, unten oder an den Seiten „angedockt“, und das letzte nimmt die Mitte) undWrapPanel(wie Text: Wenn eine Zeile voll ist, geht es in der nächsten weiter). - Visual Studio zeigt das Fenster, während du XAML schreibst, und du kannst Steuerelemente hineinziehen. Das XAML bleibt aber Text, den du lesen und in git vergleichen kannst.
Die Idee
Der Button und der Handler
Click="AddButton_Click" am Button sagt: „Wenn ich gedrückt werde, ruf die Methode AddButton_Click auf.“ Das Drücken ist ein Event, und die Methode, die es behandelt, ist ein Event-Handler. Der Code hinter dem Fenster:
using System.Windows;
namespace Shop;
public partial class MainWindow : Window{ private readonly List<decimal> prices = new List<decimal>();
public MainWindow() { InitializeComponent(); }
// die Handler folgen unten} private void AddButton_Click(object sender, RoutedEventArgs e) { if (!decimal.TryParse(PriceBox.Text, out decimal price) || price <= 0) { MessageBox.Show("Gib einen Preis ein, zum Beispiel 4,50."); return; } ItemsList.Items.Add(NameBox.Text + ": " + price.ToString("0.00") + " €"); prices.Add(price); ShowTotal(); NameBox.Clear(); PriceBox.Clear(); }InitializeComponent()erzeugt die Steuerelemente aus dem XAML. Ohne diesen Aufruf ist das Fenster leer, undPriceBoxistnull.- Ein Handler hat immer zwei Parameter.
senderist das Steuerelement, das das Event ausgelöst hat (hier der Button), undeträgt die Details dazu. PriceBox.Textist der Text im Feld.decimal.TryParsemacht daraus eine Zahl und sagt dir, ob das geklappt hat, ohne Exception. Die Verkäuferin könnte „abc“ tippen, und das Programm darf nicht abstürzen.ItemsList.Items.Add(...)fügt der Liste eine Zeile hinzu, undTotalText.Text = ...ändert die Summe. Das Fenster zeichnet sich selbst neu.
TryParse liest die Zahl nach den Regionaleinstellungen von Windows. Auf einem Computer mit deutschen Einstellungen tippt die Verkäuferin 4,50 mit Komma, mit US-Einstellungen 4.50 mit Punkt.
Genauer, für Neugierige
- Neben
Clickhaben Steuerelemente Dutzende Events:TextChangedan einerTextBoxbei jedem Buchstaben,SelectionChangedan einerListBox, wenn eine Zeile ausgewählt wird,Loadedam Fenster, wenn es erscheint, undClosing, bevor es sich schließt (dort fragst du „Änderungen speichern?“). - Der Handler läuft im Thread des Fensters. Solange er läuft, reagiert das Fenster nicht. Wenn du Daten von einem Server holen musst, wird der Handler
async voidund verwendetawait: Das Fenster bleibt bedienbar, während es wartet.async voidist nur für Event-Handler in Ordnung. - In C# kannst du einen Handler auch selbst im Code anhängen:
AddButton.Click += AddButton_Click;. Das Attribut in XAML tut dasselbe.
Probier es aus
Verbinde den Button
Welches Attribut verbindet das Drücken des Buttons mit der Methode?
<TextBox x:Name="PriceBox" Width="60" Margin="6,0" />
<Button Content="Hinzufügen" ="AddButton_Click" />Click="AddButton_Click". Der Name der Methode muss genau passen, sonst kompiliert das Projekt nicht: "'MainWindow' does not contain a definition for 'AddButton_Click'".
Die Idee
Binding: Die Daten kommen von selbst auf den Bildschirm
In der Kasse muss jeder Handler daran denken, die Liste zu aktualisieren, dann die Summe, dann vielleicht einen Zähler. Vergisst er eins, lügt der Bildschirm. Bei fünf solchen Stellen ist ein Fehler nur eine Frage der Zeit.
Der bessere Weg: Die Daten leben in einer eigenen Klasse, und das XAML sagt „zeig hier Total“. Wenn sich Total ändert, aktualisiert sich der Bildschirm von selbst. Das heißt Datenbindung (Data Binding):
<ListBox x:Name="ItemsList" Grid.Row="1" Margin="0,10" ItemsSource="{Binding Items}" /><TextBlock Text="{Binding Total, StringFormat='Summe: {0:0.00} €'}" FontWeight="Bold" VerticalAlignment="Center" /><TextBlock Text="{Binding Items.Count, StringFormat=' ({0} Artikel)'}" VerticalAlignment="Center" />{Binding Total} liest die Eigenschaft Total des Objekts, das der DataContext des Fensters ist. StringFormat zeigt sie mit zwei Nachkommastellen. Die Klasse mit den Daten:
public class CartItem{ public string Name { get; set; } public decimal Price { get; set; }
public override string ToString() => Name + ": " + Price.ToString("0.00") + " €";}
public class Cart : INotifyPropertyChanged{ private decimal total;
public ObservableCollection<CartItem> Items { get; } = new ObservableCollection<CartItem>();
public decimal Total { get { return total; } private set { total = value; OnPropertyChanged("Total"); } }
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged(string name) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); }
public void Add(CartItem item) { Items.Add(item); Total = Items.Sum(i => i.Price); }
public void Remove(CartItem item) { Items.Remove(item); Total = Items.Sum(i => i.Price); }}Zwei Dinge machen die Bindung lebendig:
INotifyPropertyChanged: ein Interface mit einem Event,PropertyChanged. WennTotaleinen neuen Wert bekommt, löstOnPropertyChanged("Total")das Event aus, und WPF liest die Eigenschaft neu.?.Invokebedeutet „ruf es auf, wenn jemand zuhört“: Ohne Zuhörer ist das Eventnull.ObservableCollection<T>: eine Liste, die von selbst meldet, wenn ein Eintrag hinein- oder herauskommt. EineListBoxmitItemsSource="{Binding Items}"beobachtet sie, und jede Zeile zeigt dasToString()des Eintrags.
{ get; } = new ... ist eine schreibgeschützte Eigenschaft mit einem Wert von Anfang an.
Wie die Änderung auf den Bildschirm kommt, mit einer kürzeren Schreibweise derselben Eigenschaft Total:
public decimal Total{ get => total; private set { total = value; OnPropertyChanged("Total"); }}
public void Add(CartItem item){ Items.Add(item); Total = Items.Sum(i => i.Price);}Schritt für Schritt, in Worten
- DataContext = cart: Die ListBox liest Items und der TextBlock liest Total. Die Pfeile sind die Bindungen.
- Add(Espressobohnen): Items.Add. Die ObservableCollection meldet die neue Zeile, und die ListBox zeigt sie an.
- Total wird 24.90. Der Setter speichert den Wert und ruft OnPropertyChanged("Total") auf.
- Das Event erreicht den TextBlock: WPF liest Total neu, und der Text wird zu „Summe: 24.90 €“.
- Add(Pfefferminze): Items.Add. Die ObservableCollection meldet die neue Zeile, und die ListBox zeigt sie an.
- Total wird 29.10. Der Setter speichert den Wert und ruft OnPropertyChanged("Total") auf.
- Das Event erreicht den TextBlock: WPF liest Total neu, und der Text wird zu „Summe: 29.10 €“.
- Add ändert nur die Daten. Keine Zeile im Code fasst die ListBox oder den TextBlock an.
Sieh es dir an
Die Kasse mit Bindung
Die Handler rufen jetzt nur noch Cart auf. An den Bildschirm denken sie nicht:
public partial class MainWindow : Window{ private readonly Cart cart = new Cart();
public MainWindow() { InitializeComponent(); DataContext = cart; }
private void AddButton_Click(object sender, RoutedEventArgs e) { if (!decimal.TryParse(PriceBox.Text, out decimal price) || price <= 0) { MessageBox.Show("Gib einen Preis ein, zum Beispiel 4,50."); return; } cart.Add(new CartItem { Name = NameBox.Text, Price = price }); NameBox.Clear(); PriceBox.Clear(); }
private void RemoveButton_Click(object sender, RoutedEventArgs e) { if (ItemsList.SelectedItem is CartItem item) cart.Remove(item); }}DataContext = cart;sagt allen{Binding ...}im Fenster, dass sie auscartlesen.ItemsList.SelectedItem is CartItem itemprüft, ob eine Zeile ausgewählt ist, und gibt sie dir alsCartItem.Items.Countim XAML ist ebenfalls gebunden: DieObservableCollectionmeldet auch, wenn sich die Anzahl ändert.
Eine Bindung an eine TextBox wirkt in beide Richtungen: <TextBox Text="{Binding Euros, UpdateSourceTrigger=PropertyChanged}" /> schreibt bei jedem Buchstaben in die Eigenschaft, und eine Änderung der Eigenschaft im Code ändert den Text im Feld. So funktioniert der Umrechner in der Übung.
Genauer, für Neugierige
- Das Muster, bei dem das Fenster fast keinen Code hat und Daten und Aktionen in einer eigenen Klasse liegen, heißt MVVM (Model-View-ViewModel).
Cartist ein ViewModel: das Model, für den Bildschirm vorbereitet. Buttons werden nicht mitClickgebunden, sondern mit Commands:Command="{Binding AddCommand}", wobeiAddCommandeine Eigenschaft vom TypICommandist. Dann ist der Code hinter dem Fenster leer, und das ViewModel lässt sich ohne Fenster testen. - Der Name in
OnPropertyChanged("Total")ist Text, und ein Tippfehler fällt nicht auf. Sicherer istOnPropertyChanged(nameof(Total))oder das Attribut[CallerMemberName]am Parameter, das den Namen von selbst übergibt. - Ein Fehler in
{Binding ...}, etwa{Binding Totl}, stoppt den Build nicht. Das Programm startet, das Feld ist leer, und die Warnung steht im Ausgabefenster (Output) von Visual Studio.
Probier es aus
Die Liste wächst nicht
Die Summe aktualisiert sich, aber die neuen Produkte erscheinen nicht in der Liste. In welcher Zeile ist der Fehler?
Wähle die Zeile mit dem Fehler.
List<CartItem> sagt niemandem, dass sie sich geändert hat. Die ListBox hat die Liste gelesen, als sie leer war, und weiß nicht, dass später Produkte dazugekommen sind. Mit ObservableCollection<CartItem> löst jedes Add und Remove ein Event aus, und die ListBox aktualisiert sich. Die Summe funktioniert, weil Total PropertyChanged auslöst.
Übung
Drei kleine Programme
Erstelle sie mit dotnet new wpf und starte sie unter Windows. Die Lösungen auf der Website wurden durch Kompilieren geprüft.
1. Eine Rechnung mit Trinkgeld. Felder für die Rechnung, den Trinkgeld-Prozentsatz (standardmäßig 10) und die Zahl der Personen, ein Button „Berechnen“ und ein Ergebnis. Für eine Rechnung von 46,80 €, 10 % und 3 Personen steht dort „Trinkgeld: 4.68 €, gesamt: 51.48 €, pro Person: 17.16 €“. Bei einer ungültigen Zahl, einem negativen Prozentsatz oder 0 Personen steht dort „Prüfe die Zahlen.“, statt dass das Programm abstürzt. Mach es mit einem Click-Handler.
2. Aufgaben für heute. Ein Feld, ein Button „Hinzufügen“, eine Liste und ein Button „Auswahl entfernen“. Eine leere Aufgabe oder eine, die schon da ist, wird nicht hinzugefügt. Die Aufgaben stehen in einer ObservableCollection<string>, die der DataContext des Fensters ist, und die ListBox hat ItemsSource="{Binding}": Ohne Pfad nimmt die Bindung den DataContext selbst. Ordne das Fenster mit einem DockPanel an: das Feld oben, den Entfernen-Button unten, die Liste in der Mitte.
3. Ein Währungsumrechner. Ein Feld für Euro und zwei Zeilen, die sich beim Tippen ändern: Dollar und Pfund, zu den Beispielkursen 1 € = 1,08 $ und 1 € = 0,85 £. Kein Button und kein Handler: Das Feld ist in beide Richtungen an Euros einer Klasse mit INotifyPropertyChanged gebunden, und Dollars und Pounds sind Eigenschaften, die aus Euros berechnet werden. Wenn sich Euros ändert, löst die Klasse PropertyChanged für alle drei aus.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Polymorphie · Interfaces · Typprüfungen · Listen, Dictionaries und LINQ
- Aufgaben zum Thema: Web und Desktop