Zum Inhalt
wannacode

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 Klasse MainWindow, mit partial in 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
App.xaml
<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:

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>
  • Grid teilt den Platz in Zeilen und Spalten. Hier gibt es drei Zeilen: Auto ist so viel, wie der Inhalt braucht, und * ist alles, was übrig bleibt. Ein Steuerelement sagt mit Grid.Row="1", in welcher Zeile es steht.
  • StackPanel reiht die Steuerelemente nacheinander auf, senkrecht oder mit Orientation="Horizontal" waagerecht.
  • TextBlock ist Text zum Lesen, TextBox ein Feld zum Tippen, Button ein Button (eine Schaltfläche) und ListBox eine 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
  • Width und Height sind 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 Grid und StackPanel gibt es DockPanel (Steuerelemente werden oben, unten oder an den Seiten „angedockt“, und das letzte nimmt die Mitte) und WrapPanel (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, und PriceBox ist null.
  • Ein Handler hat immer zwei Parameter. sender ist das Steuerelement, das das Event ausgelöst hat (hier der Button), und e trägt die Details dazu.
  • PriceBox.Text ist der Text im Feld. decimal.TryParse macht 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, und TotalText.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 Click haben Steuerelemente Dutzende Events: TextChanged an einer TextBox bei jedem Buchstaben, SelectionChanged an einer ListBox, wenn eine Zeile ausgewählt wird, Loaded am Fenster, wenn es erscheint, und Closing, 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 void und verwendet await: Das Fenster bleibt bedienbar, während es wartet. async void ist 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" />

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. Wenn Total einen neuen Wert bekommt, löst OnPropertyChanged("Total") das Event aus, und WPF liest die Eigenschaft neu. ?.Invoke bedeutet „ruf es auf, wenn jemand zuhört“: Ohne Zuhörer ist das Event null.
  • ObservableCollection<T>: eine Liste, die von selbst meldet, wenn ein Eintrag hinein- oder herauskommt. Eine ListBox mit ItemsSource="{Binding Items}" beobachtet sie, und jede Zeile zeigt das ToString() 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
  1. DataContext = cart: Die ListBox liest Items und der TextBlock liest Total. Die Pfeile sind die Bindungen.
  2. Add(Espressobohnen): Items.Add. Die ObservableCollection meldet die neue Zeile, und die ListBox zeigt sie an.
  3. Total wird 24.90. Der Setter speichert den Wert und ruft OnPropertyChanged("Total") auf.
  4. Das Event erreicht den TextBlock: WPF liest Total neu, und der Text wird zu „Summe: 24.90 €“.
  5. Add(Pfefferminze): Items.Add. Die ObservableCollection meldet die neue Zeile, und die ListBox zeigt sie an.
  6. Total wird 29.10. Der Setter speichert den Wert und ruft OnPropertyChanged("Total") auf.
  7. Das Event erreicht den TextBlock: WPF liest Total neu, und der Text wird zu „Summe: 29.10 €“.
  8. 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 aus cart lesen.
  • ItemsList.SelectedItem is CartItem item prüft, ob eine Zeile ausgewählt ist, und gibt sie dir als CartItem.
  • Items.Count im XAML ist ebenfalls gebunden: Die ObservableCollection meldet 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). Cart ist ein ViewModel: das Model, für den Bildschirm vorbereitet. Buttons werden nicht mit Click gebunden, sondern mit Commands: Command="{Binding AddCommand}", wobei AddCommand eine Eigenschaft vom Typ ICommand ist. 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 ist OnPropertyChanged(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.

Ü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

1. Wo beschreibst du, wie ein WPF-Fenster aussieht?
2. Was verbindet Click="AddButton_Click" in XAML?
3. Die Eigenschaft Total ändert sich, aber ein TextBlock mit {Binding Total} zeigt den alten Wert. Was fehlt?
4. Warum nimmst du für die Einträge einer ListBox eine ObservableCollection und keine List?
5. Auf welchen Systemen läuft ein WPF-Programm?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·