Kladde.Desktop zeigt nach W5.1.1 eine leere Vorlage. In dieser Lektion bekommt das Programm sein Gerüst: eine Navigationsleiste links, zwei Seiten (Termine und Kontakte), eine Liste, einen Dialog zum Löschen und ein Erscheinungsbild, das in hellem und dunklem Thema und in Windows' Kontrastmodus lesbar bleibt. Die Daten sind feste Beispieldaten im Code. Die Logik steht in dieser Lektion im Code-Behind. Das ist Absicht, denn MVVM kommt in W5.2.1: Dort holen Sie die Logik aus den Seiten heraus, in die Bibliothek Kladde.Desktop.Logic, und testen sie ohne Fenster. Wo diese Lektion Code-Behind schreibt, der später umzieht, sagt sie es.

Stand der Prüfung. Die XAML- und C#-Zeilen dieser Lektion stammen aus Referenz und Beispielen auf Microsoft Learn oder sind gegen sie gelesen und auf die Kladde zugeschnitten. Sie wurden nicht gegen ein echtes WinUI-Projekt ausgeführt, übersetzt oder gestartet. Die Beschreibungen, was das Fenster zeigt, beruhen auf Learn und nicht auf einer Messung. Wo Ihre Beobachtung abweicht, gilt sie; wo Learn zu etwas nichts sagt, steht „Learn nennt dazu nichts, prüfen Sie …“. Die Vorgehensweise zu den Übungen: Bauen Sie nach jedem Abschnitt, bevor Sie weitermachen.

Der Kurs W2 .NET MAUI kennt XAML ebenfalls. Wer ihn kennt, findet vieles wieder; die Namen unterscheiden sich aber an vielen Stellen (zum Beispiel Page und NavigationView statt ContentPage und Shell, ListView ohne Zellen, ContentDialog statt DisplayAlert). Die Lektion setzt W2 nicht voraus und vergleicht nur dort, wo es hilft.

XAML: die Grundlagen

XAML ist eine deklarative Sprache, die Objekte anlegt und ihre Eigenschaften setzt. Sichtbare Oberflächenelemente beschreiben Sie damit als Markup; zu jeder XAML-Datei gehört eine Code-Behind-Datei, die auf Ereignisse reagiert und die Objekte verändert. Das ist die Aufteilung, die Sie aus W5.1.1 kennen: Die XAML-Datei deklariert mit x:Class eine Klasse, und die Code-Behind-Datei ist der zweite Teil derselben partial class.

Eine Seite aus der Vorlage „Blank Page“ sieht in ihrem Kern so aus (die Namensraumzeilen im Kopf fügt die Vorlage ein; Sie müssen sie nicht von Hand schreiben):

xaml
<Page
    x:Class="Kladde.Desktop.Pages.ContactsPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:models="using:Kladde.Desktop.Models">

    <Grid>
        <TextBlock Text="Kontakte" />
    </Grid>
</Page>

Zu den Namensräumen: Der Standardnamensraum (xmlns="…") enthält die Steuerelemente von WinUI, xmlns:x die XAML-eigenen Schlüsselwörter (x:Class, x:Name, x:Key, x:Bind, x:DataType). Eigene Typen binden Sie mit xmlns:präfix="using:Namensraum" ein; Learn schreibt das zum Beispiel für xmlns:muxc="using:Microsoft.UI.Xaml.Controls". In dieser Lektion heißt das Präfix models und verweist auf Kladde.Desktop.Models. Die Vorlage legt Seiten in dem Namensraum an, den Ihr Ordner ergibt; Ihre Seite kann also Kladde.Desktop statt Kladde.Desktop.Pages tragen. Halten Sie x:Class und die Code-Behind-Datei gleich, sonst findet der Compiler die Klasse nicht.

Die Schreibweisen im Überblick:

Schreibweise Beispiel Wann
Attribut <TextBlock Text="Kontakte" FontSize="24" /> einfache Werte
Eigenschaftselement <Grid.RowDefinitions> … </Grid.RowDefinitions> Werte, die selbst Objekte oder Listen sind
angehängte Eigenschaft Grid.Row="1" eine Eigenschaft, die ein Elternelement für sein Kind definiert
Markuperweiterung {x:Bind Name}, {StaticResource Key}, {ThemeResource Key} ein Wert wird nachgeschlagen oder gebunden
Name x:Name="NavView" das Element im Code-Behind ansprechen
Ereignis Click="Loeschen_Click" Name einer Methode im Code-Behind

Wichtig für die Praxis: x:Name macht ein Element im Code-Behind als Feld verfügbar. Ein Ereignis-Handler braucht in der Code-Behind-Datei eine Methode mit der Signatur des Ereignisses, zum Beispiel private void Loeschen_Click(object sender, RoutedEventArgs e). Beides ist die Ursache vieler Fehlermeldungen, wenn ein Name in XAML und Code nicht übereinstimmt. Nach W5.1.1 wissen Sie, dass neue Handler im Code-Behind einen Neustart statt Hot Reload verlangen.

Eine Einschränkung vorweg: x:Bind und {Binding}

In dieser Lektion kommt {x:Bind} an einer Stelle vor, in Datenvorlagen für Listen. Die ganze Bindung (Modi, INotifyPropertyChanged, Befehle) lehrt W5.2.1. Zwei Dinge brauchen Sie jetzt: {x:Bind} ist nach Learn die kompilierte Bindung, schneller und mit weniger Code als {Binding}, und sie wird zur Bauzeit geprüft. Ihr Standardmodus ist OneTime: Der Wert wird einmal gelesen. Für feste Beispieldaten genügt das; wo Werte sich ändern sollen, setzen Sie den Modus ausdrücklich. Und: In einer DataTemplate gilt der Pfad nicht für die Seite, sondern für das Datenobjekt, und die Vorlage muss dazu den Typ mit x:DataType angeben:

xaml
<DataTemplate x:DataType="models:ContactResponse">
    <TextBlock Text="{x:Bind Name}" />
</DataTemplate>

Layouts

Ein Layout entsteht aus Panels: Behältern, die ihre Kinder anordnen und in der Größe bestimmen. Learn zählt als eingebaute Panels RelativePanel, StackPanel, Grid, VariableSizedWrapGrid und Canvas auf. Bei der Wahl zählt, wie das Panel die Kinder positioniert und in der Größe bestimmt, wie überlappende Kinder geschichtet werden und wie viele verschachtelte Panels Sie brauchen. Learn sagt außerdem, dass die Wahl zwischen Grid, StackPanel und RelativePanel normalerweise keine Frage der Geschwindigkeit ist: Nehmen Sie das Panel, das Ihrem Entwurf am nächsten kommt.

Zwei Begriffe gehören dazu. Größen in XAML sind effektive Pixel, nicht physische Bildpunkte (dazu im Abschnitt zur Skalierung). Und ein Layout soll fließen: Die meisten Größen sind Auto (so groß wie der Inhalt) oder proportional (*), feste Maße sind die Ausnahme, damit die Seite bei anderen Fenstergrößen nicht abgeschnitten wird.

Grid

Grid ordnet Elemente in Zeilen und Spalten an, die Sie über RowDefinitions und ColumnDefinitions festlegen. Ein Element setzen Sie mit den angehängten Eigenschaften Grid.Row und Grid.Column in eine Zelle, Grid.RowSpan und Grid.ColumnSpan lassen es mehrere Zellen überspannen. Die Größen einer Zeile oder Spalte haben drei Formen:

Wert Bedeutung
Auto so groß, wie der Inhalt es braucht
* oder n* proportional: Nachdem die Auto-Zeilen berechnet sind, teilt sich der Rest nach dem Gewicht auf; 5* neben * ist fünfmal so breit
eine Zahl, etwa 44 feste Größe in effektiven Pixeln

Die Standardbreite einer Spalte ist *; eine Spalte ohne Width braucht also keine Angabe. Learn zeigt ein Grid mit vier Spalten (Auto, *, 44, 2*):

xaml
<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="Auto"/>
        <ColumnDefinition/>
        <ColumnDefinition Width="44"/>
        <ColumnDefinition Width="2*"/>
    </Grid.ColumnDefinitions>
    <TextBlock Text="Column 1 sizes to its content." FontSize="24"/>
</Grid>

Die zweite Spalte ist halb so breit wie die vierte. Die Größe eines Elements zur Laufzeit lesen Sie mit ActualWidth und ActualHeight, nicht mit Width und Height.

StackPanel

StackPanel stapelt seine Kinder in einer Richtung (Orientation="Vertical" oder Horizontal). Der Abstand zwischen den Kindern heißt Spacing; Learn zeigt ihn in Beispielen als <StackPanel Spacing="8">. Ein StackPanel gibt seinen Kindern in Stapelrichtung unbegrenzten Platz. Das ist praktisch für kurze Reihen aus Beschriftung und Eingabe, aber ungeeignet als Behälter für eine lange, scrollbare Liste: dazu gleich bei ListView.

RelativePanel

RelativePanel ordnet Elemente nach ihrer Lage zueinander und zum Panel an. Ohne Angabe sitzt ein Element oben links. Die angehängten Eigenschaften gibt es in drei Gruppen: zum Panel (AlignTopWithPanel, AlignBottomWithPanel und weitere), zu einem Geschwisterelement (AlignTopWith und weitere) und die Lage zu einem Geschwisterelement (Above, Below, RightOf, LeftOf). Learn kombiniert RelativePanel mit VisualStateManager und AdaptiveTrigger, um die Oberfläche für verschiedene Fenstergrößen umzuordnen. Ein Formular mit zwei Spalten sieht in Learns Beispiel so aus:

xaml
<RelativePanel>
    <StackPanel x:Name="Customer" Margin="20">
        <!--Content-->
    </StackPanel>
    <StackPanel x:Name="Associate" Margin="20" RelativePanel.RightOf="Customer">
        <!--Content-->
    </StackPanel>
    <StackPanel x:Name="Save" Orientation="Horizontal" RelativePanel.Below="Customer">
        <!--Save and Cancel buttons-->
    </StackPanel>
</RelativePanel>

Der Bezug (RightOf="Customer") ist der x:Name des Geschwisterelements. Auf Fensterbreite reagiert eine Seite mit einem AdaptiveTrigger (Eigenschaft MinWindowWidth) in den Zuständen des VisualStateManager; Learns NavigationView-Beispiel unten zeigt das Muster für NavigationView mit einer Leiste, die bei breitem Fenster oben und bei schmalem links steht. Der Kurs nutzt es nur als Hinweis; die Übungen kommen ohne aus.

Listen: ListView und ItemsRepeater

ListView

ListView zeigt eine Sammlung gestapelt an. Es bringt Darstellung und Bedienung mit: Auswahl, Tastatur, Scrollen. Sie geben die Daten mit ItemsSource und die Darstellung eines Eintrags mit einer DataTemplate in ItemTemplate an. Ohne Vorlage zeigt ListView die Zeichenfolgendarstellung des Objekts.

xaml
<ListView ItemsSource="{x:Bind Customers}">
    <ListView.ItemTemplate>
        <DataTemplate x:DataType="local:Customer">
            <TextBlock Text="{x:Bind Name}"/>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

Das ist Learns Beispiel („Display customers in a list“) mit einer ObservableCollection<Customer> im Code-Behind der Seite. Die Eigenschaft muss öffentlich auf der Seite stehen, damit {x:Bind} sie findet. Wie sich Änderungen an der Sammlung in der Liste zeigen, lehrt W5.2.1; die ObservableCollection<T> meldet Änderungen, eine List<T> nicht.

Wie ein Klick wirkt, stellen Sie mit zwei Eigenschaften ein:

Wirkung Einstellung Ereignis Ergebnis lesen
keine Interaktion SelectionMode="None", IsItemClickEnabled="False" – –
Einzelauswahl (Standard) SelectionMode="Single" SelectionChanged SelectedItem, SelectedIndex
Mehrfachauswahl SelectionMode="Multiple" oder "Extended" SelectionChanged SelectedItems
Klick SelectionMode="None", IsItemClickEnabled="True" ItemClick ItemClickEventArgs.ClickedItem

Der Standard ist Einzelauswahl, IsItemClickEnabled ist standardmäßig False. Die Eigenschaft für den Klick am Argument (ClickedItem) nennt Learn auf den gelesenen Seiten nicht ausdrücklich; schlagen Sie sie in der Referenz zu ItemClickEventArgs nach, wenn Sie den Klick-Modus benutzen. Wird ItemClick zusammen mit einer Auswahl benutzt, kommt ItemClick zuerst; navigiert der Handler zu einer anderen Seite, wird SelectionChanged unter Umständen nicht mehr ausgelöst.

Ein Eintrag richtet seinen Inhalt standardmäßig links aus (HorizontalContentAlignment des ListViewItem ist Left). Sollen nebeneinanderliegende Elemente die ganze Breite füllen, setzen Sie HorizontalContentAlignment auf Stretch, über einen Stil für den Container, den Learn so zeigt:

xaml
<ListView.ItemContainerStyle>
    <Style TargetType="ListViewItem">
        <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
    </Style>
</ListView.ItemContainerStyle>

Eine Falle bei großen Listen. ListView erzeugt nur Einträge für den sichtbaren Bereich (UI-Virtualisierung). Das klappt nur, wenn die Liste in Scrollrichtung begrenzt ist. Steht sie in einem Behälter mit unbegrenztem Platz, etwa einem StackPanel oder einem ScrollViewer, kann sie nicht erkennen, was sichtbar ist, und erzeugt alle Einträge. Learn empfiehlt eine Grid-Zeile mit fester Höhe oder mit * als Behälter. In der Lektion steht jede Liste deshalb in einer *-Zeile. Learn nennt für eigene Panels in einer Liste dazu: StackPanel, WrapGrid und VariableSizedWrapGrid virtualisieren nicht; ItemsStackPanel und ItemsWrapGrid tun es.

ItemsRepeater

ItemsRepeater ist ein Baustein, kein fertiges Steuerelement. Er hat keine Standarddarstellung und keine Regeln für Fokus, Auswahl und Bedienung. Er ist ein datengetriebenes Panel: Sie geben Daten (ItemsSource), eine Vorlage (ItemTemplate) und ein Layout an, und er erzeugt daraus die Elemente. Learn empfiehlt ihn, wenn die Oberfläche mit den vorhandenen Listensteuerelementen nicht zu bauen ist. Das Layout bestimmt ein Layout-Objekt, das die Virtualisierung ermöglicht; Learn nennt StackLayout (eine Zeile oder Spalte, mit Spacing; Standard ist vertikal) und UniformGridLayout (umbrechend, alle Elemente gleich groß).

xaml
<ItemsRepeater ItemsSource="{x:Bind Tags}">
    <ItemsRepeater.Layout>
        <StackLayout Orientation="Horizontal" Spacing="8"/>
    </ItemsRepeater.Layout>
    <ItemsRepeater.ItemTemplate>
        <DataTemplate x:DataType="x:String">
            <Border Padding="8,2" CornerRadius="4" Background="{ThemeResource KladdeCardBrush}">
                <TextBlock Text="{x:Bind}" />
            </Border>
        </DataTemplate>
    </ItemsRepeater.ItemTemplate>
</ItemsRepeater>

Dieses Muster ist an Learns Beispiel für StackLayout angelehnt; Tags ist hier eine öffentliche Eigenschaft der Seite mit einer Liste von Zeichenfolgen. KladdeCardBrush ist eine Brush, die Sie im Abschnitt Themen weiter unten selbst definieren. {x:Bind} ohne Pfad steht für das Datenobjekt selbst, hier die Zeichenfolge.

Wann welches Steuerelement? ListView für Listen mit Auswahl und Tastaturbedienung, ItemsRepeater für Anzeigen, die kein Listensteuerelement hergibt. Daneben nennt Learn ItemsView, das auf ItemsRepeater aufbaut; der Kurs bleibt bei ListView.

Eine WinUI-Anwendung navigiert, indem ein Frame Seiten (Page) lädt. Das Frame ist ein Behälter mit einem Navigationsverlauf, eine Page ist der Inhalt, zu dem es navigiert. Frame.Navigate(typeof(ContactsPage)) lädt die Seite; CanGoBack und GoBack() gehören zum Verlauf. Eine Seite bekommt Bescheid, wenn man zu ihr navigiert, indem sie OnNavigatedTo überschreibt (Learn zeigt es in der Methode mit NavigationEventArgs). Einen Parameter gibt man als zweites Argument mit, Learns Beispiel navigiert mit Frame.Navigate(typeof(NotePage), args.InvokedItem); wie die Zielseite ihn liest, zeigen die Lernprogramme unter „Weiterlesen“.

Die NavigationView ist die Navigationsleiste. Wichtig ist ein Satz aus Learn: Sie navigiert nicht von selbst. Tippt jemand einen Eintrag an, zeigt sie ihn als gewählt und löst ItemInvoked aus; wurde dabei ein neuer Eintrag gewählt, folgt SelectionChanged. Die Navigation selbst schreiben Sie in den Handler: mit einem Frame als Inhalt der NavigationView.

Learn zeigt ein vollständiges Beispiel; es speichert im Tag jedes NavigationViewItem den vollständigen Typnamen der Seite, löst ihn im Handler zu einem Type auf und navigiert. Das hat einen Vorteil, den Learn nennt: Sie können einen Test schreiben, der prüft, dass die Werte der Tags gültige Typen sind. Für die Kladde sieht der Rahmen so aus. Er steht in der Datei, die Ihre Vorlage beim Start zeigt (MainWindow.xaml, oder bei Vorlagen mit Seite MainPage.xaml); das Beispiel nennt sie im Folgenden die Shell:

xaml
<NavigationView x:Name="NavView"
                IsSettingsVisible="False"
                Loaded="NavView_Loaded"
                ItemInvoked="NavView_ItemInvoked">
    <NavigationView.MenuItems>
        <NavigationViewItem Content="Termine" Tag="Kladde.Desktop.Pages.AppointmentsPage" />
        <NavigationViewItem Content="Kontakte" Tag="Kladde.Desktop.Pages.ContactsPage" />
    </NavigationView.MenuItems>

    <Frame x:Name="ContentFrame"
           IsTabStop="True"
           NavigationFailed="ContentFrame_NavigationFailed" />
</NavigationView>

Der Code der Shell folgt Learns Muster:

C#
using Kladde.Desktop.Pages;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;

// In MainWindow.xaml.cs (oder MainPage.xaml.cs): dieser Code ist Code-Behind
// und zieht in W5.2.1 teilweise in ein ViewModel um.
private void NavView_Loaded(object sender, RoutedEventArgs e)
{
    // Die NavigationView lädt von sich aus keine Seite.
    ContentFrame.Navigated += ContentFrame_Navigated;
    NavView.SelectedItem = NavView.MenuItems[0];
    ContentFrame.Navigate(typeof(AppointmentsPage));
}

private void NavView_ItemInvoked(NavigationView sender, NavigationViewItemInvokedEventArgs args)
{
    if (args.InvokedItemContainer?.Tag is string typeName
        && Type.GetType(typeName) is Type pageType
        && ContentFrame.CurrentSourcePageType != pageType)
    {
        ContentFrame.Navigate(pageType, null, args.RecommendedNavigationTransitionInfo);
    }
}

private void ContentFrame_Navigated(object sender, NavigationEventArgs e)
{
    // Die Auswahl der Leiste folgt der Seite, auch wenn eine Seite im Code navigiert.
    NavView.SelectedItem = NavView.MenuItems
        .OfType<NavigationViewItem>()
        .FirstOrDefault(item => item.Tag is string tag && tag == e.SourcePageType.FullName);
}

private void ContentFrame_NavigationFailed(object sender, NavigationFailedEventArgs e)
{
    throw new InvalidOperationException("Failed to load Page " + e.SourcePageType.FullName);
}

Die Zeile Type.GetType(typeName) nimmt den Namen mit Namensraum; steht eine Seite in einem anderen Namensraum als im Tag, kommt null zurück, und es passiert nichts. Learn nennt zu Type.GetType und zum Beschneiden (Trimming) oder Vorabkompilieren nichts; ist das für Ihr Paket ein Thema, prüfen Sie es in W5.3.2. Eine Alternative ohne Typnamen als Zeichenfolge ist eine switch-Anweisung auf den Tag, so wie Learns kürzeres Beispiel mit switch (args.InvokedItemContainer.Name) und ContentFrame.NavigateToType(typeof(Section1Page), null, navOptions).

Weitere Eigenschaften, die Learn für die NavigationView nennt: PaneDisplayMode (verschiedene Stile der Leiste: links, oben und weitere), IsBackButtonVisible mit dem Ereignis BackRequested (für einen Zurück-Knopf, den die Anwendung selbst mit ContentFrame.CanGoBack und GoBack() bedient), AutoSuggestBox für die Suche in der Anwendung und ein Eintrag für die Einstellungen, den IsSettingsVisible="False" ausblendet (die Kladde braucht ihn noch nicht). Symbole für die Einträge (Icon="Home" oder ein FontIcon mit Glyph) zeigt Learn in seinem Beispiel; die Kladde beginnt ohne, damit nicht Glyphen im Weg stehen, die Sie nicht geprüft haben. Welche Symbole es gibt, zeigt die WinUI 3 Gallery.

Wie das Zusammenspiel von Shell, OnLaunched und der Vorlage aussieht, hängt von Ihrer Vorlage ab (W5.1.1): Das Fenster, das OnLaunched erzeugt, muss am Ende die Shell zeigen. Zeigt die Vorlage MainWindow mit eigenem Inhalt, ersetzen Sie diesen Inhalt durch die NavigationView. Zeigt OnLaunched ein Frame mit MainPage, setzen Sie die NavigationView in MainPage. Ändern Sie OnLaunched nur, wenn es nötig ist; Learn warnt davor, die umgebende Page oder das Frame zu ersetzen, wenn die Vorlage sie verwendet.

Ein Dialog: ContentDialog und XamlRoot

ContentDialog zeigt eine modale Abfrage über dem Fenster. Learn beschreibt sie mit Titel, Inhalt und bis zu drei Schaltflächen: PrimaryButtonText, SecondaryButtonText und CloseButtonText. ShowAsync() kehrt zurück, sobald jemand eine Schaltfläche drückt, und liefert ein ContentDialogResult: Learns Beispiel prüft result == ContentDialogResult.Primary und tut sonst nichts. Die Schaltfläche zum Schließen (Esc, Zurück-Taste, Schließen-Knopf) liefert ein anderes Ergebnis als Primary.

Zwei Regeln aus Learn:

  1. In WinUI 3 müssen Sie XamlRoot setzen, bevor Sie ShowAsync() aufrufen; sonst löst der Dialog zur Laufzeit eine Ausnahme aus. Aus einer Page nehmen Sie this.XamlRoot. Window hat keine Eigenschaft XamlRoot: Zeigen Sie einen Dialog aus einem Window, nehmen Sie die XamlRoot des Wurzelelements im Fenster (Learn zeigt rootPanel.XamlRoot für ein Grid x:Name="rootPanel"). Die Seiten der Kladde sind Elemente im Baum; dort genügt this.XamlRoot.
  2. Es darf nur ein ContentDialog offen sein. Learn beschreibt es einmal „pro Fenster“ und einmal „pro Thread“ und sagt beide Male: Ein zweiter löst eine Ausnahme aus. Zeigen Sie nie zwei gleichzeitig. Wenn ein Dialog durch ein Ereignis ausgelöst wird, das mehrfach feuern kann, sperren Sie den Auslöser, solange der Dialog offen ist.

Eine dritte Regel betrifft die Gestaltung: Jeder Dialog soll mindestens eine sichere Schaltfläche haben, die nichts ausführt, zum Beispiel „Abbrechen“ oder „Schließen“. Learns Beispiel für das Löschen einer Datei zeigt, wie das aussieht:

C#
private async void DisplayDeleteFileDialog()
{
    ContentDialog deleteFileDialog = new ContentDialog
    {
        XamlRoot = this.XamlRoot,
        Title = "Delete file permanently?",
        Content = "If you delete this file, you won't be able to recover it. Do you want to delete it?",
        PrimaryButtonText = "Delete",
        CloseButtonText = "Cancel"
    };

    ContentDialogResult result = await deleteFileDialog.ShowAsync();

    // Delete the file if the user clicked the primary button.
    /// Otherwise, do nothing.
    if (result == ContentDialogResult.Primary)
    {
        // Delete the file.
    }
}

Für komplexere Dialoge legen Sie einen eigenen Dialog an: die Elementvorlage Content Dialog erzeugt eine XAML-Datei und eine Code-Behind-Datei, deren Klasse von ContentDialog erbt (nicht von Page). Gezeigt wird er mit new MeinDialog { XamlRoot = this.XamlRoot } und await dialog.ShowAsync(). Die Kladde braucht in dieser Lektion nur den einfachen Dialog im Code.

Stile, Ressourcen und Themen

Eine Ressource ist ein Objekt, das Sie mit einem Schlüssel (x:Key) in einem ResourceDictionary ablegen und woanders nachschlagen: Pinsel, Stile, Vorlagen, Maße. Wörterbücher gibt es auf mehreren Ebenen: an einem Element (Page.Resources, Grid.Resources), in App.xaml für die ganze Anwendung und in eigenen Dateien (die Elementvorlage Resource Dictionary), die Sie mit MergedDictionaries zusammenführen. Ein Stil (Style) sammelt Eigenschaftswerte für einen Elementtyp mit Settern; mit einem TargetType und ohne Schlüssel gilt er für alle Elemente dieses Typs im Gültigkeitsbereich.

Zwei Markuperweiterungen schlagen eine Ressource nach, und der Unterschied zählt:

  • {StaticResource Key} wird einmal ausgewertet, wenn das XAML geladen wird, und ändert sich danach nicht. Learn vergleicht es mit einem Suchen-und-Ersetzen auf den Wert zum Start der Anwendung.
  • {ThemeResource Key} wird beim Laden und jedes Mal ausgewertet, wenn das Thema wechselt: bei einer Änderung der Windows-Einstellungen oder durch eine Änderung im Programm.

Themen: Hell, Dunkel, hoher Kontrast

WinUI kennt drei Themen: „Light“, „Dark“ und „HighContrast“. Eine Anwendung läuft standardmäßig in dem Thema, das die Anwendung in den Windows-Einstellungen (Personalisierung, Farben, Standard-App-Modus) hat. Die Systemsteuerelemente passen sich selbst an, weil sie mit {ThemeResource} arbeiten; Ihre eigenen Pinsel müssen es auch tun. Learn gibt für eigene Ressourcen diese Regeln:

  1. Legen Sie Themenwörterbücher für „Light“ und „Dark“ und zusätzlich für „HighContrast“ an. Ein Wörterbuch mit dem Schlüssel „Default“ geht auch; es ist besser, die drei Namen ausdrücklich zu verwenden.
  2. Benutzen Sie {ThemeResource} in Stilen, Settern, Steuerelementvorlagen, Eigenschaftssettern und Animationen.
  3. Benutzen Sie nicht {ThemeResource} in den Definitionen in den ThemeDictionaries, sondern {StaticResource}. Ausnahme: themenunabhängige Ressourcen wie die Akzentfarbe (SystemAccentColor) und Systemfarben mit dem Präfix SystemColor (zum Beispiel SystemColorButtonFaceColor).

Wer sich nicht daran hält, bekommt nach Learn unerwartetes Verhalten beim Themenwechsel. Ein Beispiel für die Kladde, ein Hintergrund für Karten, in App.xaml in das vorhandene ResourceDictionary eingefügt (nicht an dessen Stelle):

xaml
<ResourceDictionary.ThemeDictionaries>
    <ResourceDictionary x:Key="Light">
        <SolidColorBrush x:Key="KladdeCardBrush" Color="#FFF3F3F3" />
    </ResourceDictionary>
    <ResourceDictionary x:Key="Dark">
        <SolidColorBrush x:Key="KladdeCardBrush" Color="#FF2B2B2B" />
    </ResourceDictionary>
    <ResourceDictionary x:Key="HighContrast">
        <SolidColorBrush x:Key="KladdeCardBrush" Color="{ThemeResource SystemColorButtonFaceColor}" />
    </ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>

Die HighContrast-Zeile benutzt {ThemeResource}, weil sie unter die Ausnahme der Systemfarben fällt: Die Nutzerin oder der Nutzer wählt die Farben des Kontrastthemas in Windows, und die Werte stehen in den SystemColor…-Ressourcen. Die Farbwerte „#FFF3F3F3“ und „#FF2B2B2B“ sind Beispielwerte aus der Kladde, keine Vorgaben von Learn; das Kontrastverhältnis zum Text prüfen Sie selbst. Benutzt wird der Pinsel mit {ThemeResource}:

xaml
<Border Background="{ThemeResource KladdeCardBrush}" Padding="12" CornerRadius="4">
    <TextBlock Text="Beratung Hoffmann" />
</Border>

Noch zwei Eigenschaften zum Thema, mit zwei Eigenheiten, die Learn betont:

  • Application.RequestedTheme überschreibt das Thema der Anwendung, kann aber nur beim Start gesetzt werden. Wer es zur Laufzeit setzt, bekommt eine Ausnahme. Wer der Nutzerin oder dem Nutzer die Wahl lässt, speichert sie und wendet sie beim nächsten Start im Konstruktor von App an. Dann sollte es auch die Option geben, zum Windows-Standard zurückzukehren.
  • FrameworkElement.RequestedTheme gilt für ein Element und seine Kinder und kann zur Laufzeit geändert werden. Learn nennt als Beispiel Menüs und Flyouts, die ein anderes Thema haben dürfen als die Anwendung. Für einen Schalter „hell/dunkel“ im Fenster ist es das Mittel der Wahl: RootGrid.RequestedTheme = ElementTheme.Dark;.
  • Beide werden im hohen Kontrast ignoriert. Der Wechsel dorthin kommt von Windows (die Tastenkombination Alt+Umschalt+Druck, die Learn nennt, wechselt Kontrastthemen), und die Ressourcen werten sich dann neu aus.

Wie Learn die Lage beschreibt, ist sie also: Sie liefern die Werte für Hell, Dunkel und Kontrast, Windows und die Steuerelemente wählen sie.

Skalierung und DPI

Maße in XAML sind effektive Pixel. Learn erklärt sie am Beispiel einer Zeile mit 44 effektiven Pixeln Höhe; die Zahl bleibt gleich, wenn Windows den Anzeigemaßstab ändert, und die tatsächlichen Bildpunkte wachsen mit. Die übliche Skala nach Learns Seite zur Anzeige: 100 % entsprechen 96 DPI, 125 % 120 DPI, 150 % 144 DPI, 200 % 192 DPI, und die Nutzerin oder der Nutzer kann eigene Werte einstellen. Folgen:

  • Layouts mit Auto und * überstehen jeden Maßstab; feste Maße sind der Teil, der bei großem Maßstab abgeschnitten wird. Testen Sie bei 150 % und 200 %.
  • Bilder: Die Ressourcenverwaltung des Windows App SDK verwaltet Bilder getrennt nach Skalierung, Sprache und Kontrastvariante. Wie Dateinamen die Skalierung kodieren (die Benennung der Bilder), nennt die Seite zur Ressourcenverwaltung; sie ist hier nicht gelesen, und die Kladde benutzt noch keine eigenen Bilder.
  • Beim Test: Der Maßstab lässt sich in den Windows-Einstellungen bei laufender Anwendung ändern (System, Anzeige, Skalierung). Learns Win2D-Seite beschreibt dasselbe als Test, dass eine Anwendung die Änderung mitbekommt. Ob und wie die WinUI-Vorlage das DPI-Verhalten im Manifest einstellt, steht auf den gelesenen Seiten nicht: Learns Seite zur DPI-Ausrichtung von Desktop-Anwendungen sagt allgemein, dass Anwendungen ohne Angabe als „DPI-unaware“ gelten und von Windows gestreckt werden, was unscharf aussieht. Ob das auf Ihre Vorlage zutrifft, sagt Ihnen der Test: Wirkt das Fenster bei 150 % unscharf, sehen Sie nach, was Ihr Manifest dazu enthält.

Tastatur und Screenreader

Eine Windows-Anwendung muss ohne Maus bedienbar sein und mit einem Screenreader (Narrator, andere) lesbar. WinUI-Steuerelemente bringen viel mit; was fehlt, ergänzen Sie.

Tastatur

  • Tabulator-Reihenfolge. Ein Steuerelement ist ein Tabstopp, wenn IsEnabled und IsTabStop true sind. Standardmäßig folgt die Reihenfolge der Reihenfolge im XAML. Weicht die sichtbare Anordnung davon ab (Learn nennt Grid und Tabellen als häufige Ursache), ordnen Sie das XAML um oder setzen TabIndex. Testen Sie sie mit der Tabulatortaste, nicht im Kopf. IsTabStop="False" nimmt ein Element aus der Reihenfolge, das auf anderem Weg erreichbar bleibt; deaktivierte Elemente sind automatisch heraus.
  • Fokus. WinUI zeichnet ein Fokusrechteck, wenn ein Element den Fokus per Tastatur bekommt. Der erste Fokus gehört auf das sinnvollste Element der Seite.
  • Beschleuniger (KeyboardAccelerator). Ein Tastenkürzel für eine Aktion, mit oder ohne sichtbares Steuerelement: ein einzelner Tastendruck wie F1 bis F12, Esc, Entf, oder eine Kombination wie Strg+Umschalt+B. Beschleuniger wirken standardmäßig global. Learn zeigt sie an einer Schaltfläche: <Button.KeyboardAccelerators><KeyboardAccelerator Modifiers="Control" Key="…"/></Button.KeyboardAccelerators>.
  • Zugriffstasten (AccessKey). Alt plus ein oder zwei Zeichen, die den Fokus auf ein Steuerelement setzen: <Button Content="Accept" AccessKey="A" Click="…" />. Bei einem Zeichen funktioniert Alt+Taste sofort; beim Druck auf Alt zeigt Windows kleine Hinweisfelder (Keytips). Vermeiden Sie doppelte Zugriffstasten.

Screenreader und AutomationProperties

Ein Screenreader liest den Namen eines Elements, seine Rolle und seinen Wert. WinUI bildet daraus den Baum der UI-Automatisierung. Wo ein Name von selbst entsteht, wo nicht:

Element Name
TextBlock der sichtbare Text, automatisch
Button, Link der sichtbare Inhalt; bei einer Schaltfläche, die nur ein Bild zeigt, AutomationProperties.Name
Image kein Äquivalent zu alt: AutomationProperties.Name oder eine sichtbare Beschriftung über LabeledBy
TextBox nicht sein Text (der ist der Wert); beschriften Sie das Feld mit AutomationProperties.LabeledBy auf ein TextBlock
Panels meist kein Name; die Kinder tragen Name und Rolle

Der bevorzugte Weg für Formularfelder ist eine sichtbare Beschriftung in einem TextBlock, auf die das Eingabefeld mit LabeledBy zeigt, so dass der gesprochene Name dem sichtbaren Text entspricht:

xaml
<StackPanel Spacing="8">
    <TextBlock x:Name="SearchLabel" Text="Suche" />
    <TextBox x:Name="SearchBox"
             AutomationProperties.LabeledBy="{Binding ElementName=SearchLabel}" />
</StackPanel>

Das ist Learns Muster für Bild und Beschriftung, auf ein Eingabefeld übertragen; {Binding ElementName=…} ist hier die Schreibweise, die Learn in diesem Beispiel benutzt. Ergänzend gibt es AutomationProperties.HelpText für zusätzliche Erklärung (Narrator liest sie auf Anforderung, nach Learn mit CapsLock+F), und AutomationProperties.AcceleratorKey und AccessKey für Hinweise auf Kürzel. Letztere setzen kein Tastenverhalten: Das Verhalten bleibt KeyboardAccelerator und AccessKey, die Eigenschaften melden es nur an Hilfsmittel.

Was Learn zusätzlich betont:

  • Namen aus Daten. In Listen, die über eine Vorlage gefüllt werden, entstehen Namen erst nach der Bindung. Prüfen Sie, dass jeder erzeugte Eintrag einen sinnvollen Namen hat; manchmal muss man ihn im Code setzen.
  • Lokalisierung. Namen sind so zu lokalisieren wie sichtbarer Text, über Ressourcendateien (x:Uid); ein ausdrücklich gesetztes AutomationProperties.Name gehört ebenfalls in die Ressourcen. Dieser Kurs lokalisiert nicht, aber hartcodierte Namen sind eine Schuld, die Sie benennen.
  • Früh testen. Mit einem Screenreader, mit Accessibility Insights for Windows (prüft den Baum der Automatisierung) und mit AccScope (zeigt ihn). Learn empfiehlt, nicht erst vor der Auslieferung zu testen.
  • Textgröße und Kontrast. Windows lässt Nutzerinnen und Nutzer die Textgröße einstellen; alle WinUI-Textsteuerelemente unterstützen es nach Learn ohne Zusatz, wenn Ihr Layout es verträgt (also nicht auf feste Höhen setzt).

Übung 1

Die Datenmodelle und die Beispieldaten. Legen Sie die Typen und die festen Daten an, mit denen die Seiten arbeiten.

  1. Legen Sie in src/Kladde.Desktop einen Ordner Models an. Legen Sie dort ContactResponse und AppointmentResponse an. Die Felder entsprechen den Antworten der Web-API der Kladde aus K2.
  2. Legen Sie in derselben Datei oder in SampleData.cs die festen Daten an: sechs Kontakte und vier Termine auf dem heutigen Tag.
  3. Bauen Sie das Projekt.

So prüfen Sie: Das Projekt baut ohne Fehler. Die Typen sind einfache Datentypen ohne Bezug zu XAML. Das zählt später: In W5.2.1 ziehen sie in die Bibliothek um.

Lösung 1

Die Modelle. Die Felder entsprechen ContactResponse und AppointmentResponse aus K2:

C#
namespace Kladde.Desktop.Models;

public sealed record ContactResponse(
    int Id, string Name, string? Email, string? Company, IReadOnlyList<string> Tags)
{
    public string CompanyText => string.IsNullOrWhiteSpace(Company) ? "(keine Firma)" : Company;
}

public sealed record AppointmentResponse(
    string Title, DateTimeOffset Start, DateTimeOffset End, IReadOnlyList<int> ContactIds)
{
    public string TimeText => $"{Start.LocalDateTime:t} bis {End.LocalDateTime:t}";
}

Die zusätzlichen Eigenschaften CompanyText und TimeText sind Anzeigetext. Sie stehen hier, damit die Datenvorlagen nur Eigenschaften binden und keine Funktionen. Das ist eine Vereinfachung dieser Lektion: Anzeigetext gehört in ein ViewModel, und dort landet er in W5.2.1. Die Zeichenfolge :t formatiert die Uhrzeit nach der Kultur des Rechners.

Die festen Daten, mit Platzhaltern in der Form von Adressen unter example.com:

C#
namespace Kladde.Desktop.Models;

/// <summary>Fixed sample data for W5.1; the web API replaces it in W5.2.2.</summary>
public static class SampleData
{
    public static IReadOnlyList<ContactResponse> Contacts { get; } =
    [
        new(1, "Mira Hoffmann", "mira.hoffmann@example.com", "Hoffmann IT-Beratung", ["Kunde"]),
        new(2, "Jonas Weber", "jonas.weber@example.com", "Weber und Partner", ["Kunde", "Wartung"]),
        new(3, "Selin Aydin", "selin.aydin@example.com", "Aydin Logistik", ["Interessent"]),
        new(4, "Tom Brandt", null, null, []),
        new(5, "Lena Krüger", "lena.krueger@example.com", "Krüger Software", ["Partner"]),
        new(6, "Paul Neumann", "paul.neumann@example.com", "Neumann Bau", ["Kunde"]),
    ];

    public static IReadOnlyList<AppointmentResponse> TodaysAppointments { get; } =
    [
        new("Beratung Hoffmann", Today(9, 0), Today(10, 0), [1]),
        new("Rückruf Weber", Today(10, 30), Today(11, 0), [2]),
        new("Workshop Aydin", Today(13, 0), Today(14, 30), [3]),
        new("Jour fixe Team", Today(16, 0), Today(16, 30), []),
    ];

    private static DateTimeOffset Today(int hour, int minute) =>
        new(DateTime.Today.AddHours(hour).AddMinutes(minute));
}

Stehen die Dateien in einem Unterordner Models, vergibt die Vorlage den Namensraum Kladde.Desktop.Models; passen Sie ihn an, wenn Ihre IDE einen anderen vorschlägt. Ein Kontakt ohne E-Mail und ohne Firma (Tom Brandt) ist absichtlich dabei: Er prüft später die Anzeige leerer Werte.

Übung 2

Die Shell mit NavigationView. Bauen Sie die Navigationsleiste mit zwei Einträgen und leeren Seiten dahinter.

  1. Legen Sie in src/Kladde.Desktop den Ordner Pages an. Legen Sie dort mit der Elementvorlage Blank Page (oder dotnet new winui-page -n AppointmentsPage) zwei Seiten an: AppointmentsPage und ContactsPage. Geben Sie jeder ein TextBlock mit ihrem Titel.
  2. Setzen Sie die NavigationView mit dem Frame in die Datei, die Ihre Vorlage beim Start zeigt (die Shell), und ergänzen Sie den Code für NavView_Loaded, NavView_ItemInvoked, ContentFrame_Navigated und ContentFrame_NavigationFailed.
  3. Starten Sie die Anwendung.

So prüfen Sie: Nach dem Start steht „Termine“ links gewählt, die Seite zeigt ihren Titel. Ein Klick auf „Kontakte“ zeigt die zweite Seite, die Auswahl wechselt. Mit der Tastatur: Tab bis zur Leiste, Pfeiltasten, Eingabetaste. Ändern Sie testweise einen Tag auf einen Namen, der keine Seite ist: Ein Klick darauf tut nichts. Ändern Sie ihn zurück.

Lösung 2

Die Seiten (hier die erste; die zweite ist gleich mit dem Titel „Kontakte“):

xaml
<Page
    x:Class="Kladde.Desktop.Pages.AppointmentsPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <Grid Padding="24">
        <TextBlock Text="Termine" />
    </Grid>
</Page>

Die Shell: das XAML und der Code stehen oben im Abschnitt zur Navigation. Wichtig sind drei Dinge. Die Tag-Werte sind die vollständigen Typnamen der Seiten, mit Namensraum; entsprechen sie nicht dem Namensraum Ihrer Dateien, passen Sie sie an. Die Namen ContentFrame und NavView im Code müssen den x:Name in XAML gleichen. Und: Die Methode für Loaded hängt an der NavigationView, so wie Learn es im Beispiel zeigt (Loaded="NavView_Loaded"): Das Ereignis des Elements läuft, wenn die Leiste im Baum steht, ganz gleich, ob die Shell ein Window oder eine Page ist.

Wenn nach dem Start keine Seite erscheint: Ist ContentFrame.Navigate in NavView_Loaded erreicht? Learn weist darauf hin, dass die NavigationView von sich aus keine Seite lädt. Wenn der Fehler beim Navigieren auftritt, löst ContentFrame_NavigationFailed die Ausnahme aus und nennt den Typ; er ist meist ein falscher Name oder Namensraum.

Aufräumen: nichts.

Übung 3

Termine des Tages. Zeigen Sie die Termine auf der Seite „Termine“.

  1. Die Seite bekommt ein Grid mit zwei Zeilen: oben eine Auto-Zeile mit der Überschrift „Termine heute“, darunter eine *-Zeile mit einer ListView.
  2. Die Seite hat im Code-Behind eine öffentliche Eigenschaft Appointments, die SampleData.TodaysAppointments liefert. Die ListView bindet daran mit {x:Bind Appointments}.
  3. Die Datenvorlage zeigt je Termin den Titel und darunter die Zeit (TimeText). Verwenden Sie x:DataType.
  4. Zeigt die Liste keinen Eintrag (testweise Appointments leer), soll ein Text „Heute stehen keine Termine an.“ erscheinen.

So prüfen Sie: Vier Einträge erscheinen mit Titel und Zeit. Mit zu wenig Fensterhöhe scrollt die Liste. Mit leerer Eigenschaft erscheint der Hinweistext.

Lösung 3

xaml
<Page
    x:Class="Kladde.Desktop.Pages.AppointmentsPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:models="using:Kladde.Desktop.Models">

    <Grid Padding="24" RowSpacing="12">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>

        <TextBlock x:Name="HeadingText" Text="Termine heute" FontSize="24" />

        <ListView Grid.Row="1"
                  ItemsSource="{x:Bind Appointments}"
                  SelectionMode="None"
                  AutomationProperties.Name="Termine heute">
            <ListView.ItemTemplate>
                <DataTemplate x:DataType="models:AppointmentResponse">
                    <StackPanel Padding="0,8">
                        <TextBlock Text="{x:Bind Title}" />
                        <TextBlock Text="{x:Bind TimeText}" Opacity="0.7" />
                    </StackPanel>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>

        <TextBlock x:Name="EmptyText" Grid.Row="1" Text="Heute stehen keine Termine an."
                   Visibility="Collapsed" />
    </Grid>
</Page>
C#
using Kladde.Desktop.Models;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace Kladde.Desktop.Pages;

public sealed partial class AppointmentsPage : Page
{
    // Code-Behind für W5.1; in W5.2.1 wird daraus ein ViewModel.
    public IReadOnlyList<AppointmentResponse> Appointments { get; } = SampleData.TodaysAppointments;

    public AppointmentsPage()
    {
        InitializeComponent();
        EmptyText.Visibility = Appointments.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
    }
}

Zu den Einzelheiten. RowSpacing (und in Übung 4 ColumnSpacing) am Grid setzt einen Abstand zwischen Zeilen und Spalten; Learn nennt diese beiden Eigenschaften auf den gelesenen Seiten nicht (für StackPanel nennt es Spacing). Lehnt der Compiler sie ab, nehmen Sie Margin an den Kindern. SelectionMode="None" schaltet Auswahl und Klick ab, die Liste ist nur eine Anzeige. InitializeComponent() gehört in den Konstruktor jeder Seite, sonst gibt es die XAML-Elemente nicht. Der x:Bind auf Appointments sucht die Eigenschaft auf der Seite; sie muss öffentlich sein, und für einen festen Wert genügt der Standardmodus OneTime.

Der Hinweistext liegt in derselben Zeilenzelle wie die Liste und überdeckt sie, wenn er sichtbar ist. Das ist hier harmlos, weil die Liste dann leer ist. Collapsed nimmt ein Element ganz aus dem Layout, Visible bringt es zurück.

Aufräumen: nichts.

Übung 4

Kontakte mit Detailbereich, Suche und Löschen. Die Seite „Kontakte“ hat links die Liste, rechts den gewählten Kontakt, oben eine Suche und eine Schaltfläche „Löschen“ mit einem Dialog.

  1. Das Grid der Seite hat zwei Spalten (2* für die Liste, 3* für den Detailbereich) und zwei Zeilen (eine Auto-Zeile für Suche und Schaltfläche, darunter eine *-Zeile).
  2. Oben: ein TextBlock „Suche“ mit einem TextBox, der über AutomationProperties.LabeledBy zu ihm gehört, und eine Schaltfläche „Löschen“ mit einer Zugriffstaste und einem Beschleuniger (Entf).
  3. Links: eine ListView in der *-Zeile, deren Datenvorlage Name und Firma zeigt. Die Datenquelle ist eine ObservableCollection<ContactResponse>.
  4. Rechts: ein RelativePanel mit Name, Firma, E-Mail und einem ItemsRepeater für die Schlagwörter (Tags). Zeigen Sie die Daten des gewählten Kontakts im SelectionChanged-Handler an; ohne Auswahl ist die Schaltfläche „Löschen“ deaktiviert.
  5. Beim Drücken der Schaltfläche „Löschen“ fragt ein ContentDialog „Kontakt löschen?“ mit den Schaltflächen „Löschen“ und „Abbrechen“. Nur bei „Löschen“ verschwindet der Kontakt aus der Liste. Setzen Sie XamlRoot.
  6. Die Suche filtert die Liste nach Name oder Firma, bei jeder Änderung des Textes.

So prüfen Sie: Die Liste zeigt sechs Kontakte. Die Auswahl zeigt rechts die Daten; Tom Brandt zeigt „(keine Firma)“ und keine Schlagwörter. „Löschen“ öffnet den Dialog; „Abbrechen“ und Esc ändern nichts; „Löschen“ entfernt den Kontakt. Die Tabulatortaste führt von der Suche zur Schaltfläche und zur Liste; Entf löst die Schaltfläche aus. Ein zweiter Klick auf „Löschen“, solange der Dialog offen ist, ist durch den Dialog selbst blockiert (er ist modal).

Lösung 4

xaml
<Page
    x:Class="Kladde.Desktop.Pages.ContactsPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:models="using:Kladde.Desktop.Models">

    <Grid Padding="24" ColumnSpacing="24" RowSpacing="12">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="2*" />
            <ColumnDefinition Width="3*" />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>

        <StackPanel Orientation="Horizontal" Spacing="12">
            <TextBlock x:Name="SearchLabel" Text="Suche" VerticalAlignment="Center" />
            <TextBox x:Name="SearchBox" Width="240"
                     AutomationProperties.LabeledBy="{Binding ElementName=SearchLabel}"
                     TextChanged="SearchBox_TextChanged" />
            <Button x:Name="DeleteButton" Content="Löschen" AccessKey="L"
                    IsEnabled="False" Click="DeleteButton_Click">
                <Button.KeyboardAccelerators>
                    <KeyboardAccelerator Key="Delete" />
                </Button.KeyboardAccelerators>
            </Button>
        </StackPanel>

        <ListView x:Name="ContactList" Grid.Row="1"
                  ItemsSource="{x:Bind Contacts}"
                  SelectionChanged="ContactList_SelectionChanged"
                  AutomationProperties.Name="Kontakte">
            <ListView.ItemTemplate>
                <DataTemplate x:DataType="models:ContactResponse">
                    <StackPanel Padding="0,8">
                        <TextBlock Text="{x:Bind Name}" />
                        <TextBlock Text="{x:Bind CompanyText}" Opacity="0.7" />
                    </StackPanel>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>

        <TextBlock x:Name="EmptyText" Grid.Row="1" Text="Keine Kontakte vorhanden."
                   Visibility="Collapsed" />

        <Border Grid.Row="1" Grid.Column="1" Padding="16" CornerRadius="4"
                Background="{ThemeResource KladdeCardBrush}">
            <RelativePanel>
                <TextBlock x:Name="DetailName" FontSize="20" Text="Kein Kontakt gewählt" />
                <TextBlock x:Name="DetailCompany" RelativePanel.Below="DetailName" />
                <TextBlock x:Name="DetailEmail" RelativePanel.Below="DetailCompany" />
                <ItemsRepeater x:Name="TagRepeater" RelativePanel.Below="DetailEmail">
                    <ItemsRepeater.Layout>
                        <StackLayout Orientation="Horizontal" Spacing="8" />
                    </ItemsRepeater.Layout>
                    <ItemsRepeater.ItemTemplate>
                        <DataTemplate x:DataType="x:String">
                            <Border Padding="8,2" CornerRadius="4" Background="{ThemeResource KladdeCardBrush}">
                                <TextBlock Text="{x:Bind}" />
                            </Border>
                        </DataTemplate>
                    </ItemsRepeater.ItemTemplate>
                </ItemsRepeater>
            </RelativePanel>
        </Border>
    </Grid>
</Page>

Die Brush KladdeCardBrush stammt aus dem Abschnitt zu den Themen; haben Sie sie noch nicht angelegt, tragen Sie sie jetzt in App.xaml ein oder nehmen Sie vorläufig eine feste Farbe. Der Code-Behind (Code-Behind für W5.1, in W5.2.1 wird er ein ViewModel):

C#
using System.Collections.ObjectModel;
using Kladde.Desktop.Models;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace Kladde.Desktop.Pages;

public sealed partial class ContactsPage : Page
{
    private readonly List<ContactResponse> allContacts = [.. SampleData.Contacts];

    public ObservableCollection<ContactResponse> Contacts { get; }

    public ContactsPage()
    {
        Contacts = new ObservableCollection<ContactResponse>(allContacts);
        InitializeComponent();
        UpdateEmptyText();
    }

    private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
    {
        var term = SearchBox.Text.Trim();
        Contacts.Clear();
        foreach (var contact in allContacts.Where(c => Matches(c, term)))
        {
            Contacts.Add(contact);
        }
        UpdateEmptyText();
    }

    private static bool Matches(ContactResponse contact, string term) =>
        term.Length == 0
        || contact.Name.Contains(term, StringComparison.CurrentCultureIgnoreCase)
        || (contact.Company?.Contains(term, StringComparison.CurrentCultureIgnoreCase) ?? false);

    private void ContactList_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        var contact = ContactList.SelectedItem as ContactResponse;
        DeleteButton.IsEnabled = contact is not null;
        DetailName.Text = contact?.Name ?? "Kein Kontakt gewählt";
        DetailCompany.Text = contact?.CompanyText ?? "";
        DetailEmail.Text = contact?.Email ?? "";
        TagRepeater.ItemsSource = contact?.Tags;
    }

    private async void DeleteButton_Click(object sender, RoutedEventArgs e)
    {
        if (ContactList.SelectedItem is not ContactResponse contact)
        {
            return;
        }

        var dialog = new ContentDialog
        {
            XamlRoot = XamlRoot,
            Title = "Kontakt löschen?",
            Content = $"„{contact.Name}“ wird aus der Liste entfernt.",
            PrimaryButtonText = "Löschen",
            CloseButtonText = "Abbrechen"
        };

        if (await dialog.ShowAsync() == ContentDialogResult.Primary)
        {
            allContacts.Remove(contact);
            Contacts.Remove(contact);
            UpdateEmptyText();
        }
    }

    private void UpdateEmptyText() =>
        EmptyText.Visibility = Contacts.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
}

Zu den Einzelheiten: Contacts wird vor InitializeComponent() gefüllt, damit x:Bind die Eigenschaft beim Aufbau findet. Die ObservableCollection meldet Änderungen, mit einer List<T> bliebe die Anzeige nach Clear und Add stehen. Der Dialog bekommt XamlRoot = XamlRoot; ohne diese Zeile wirft WinUI 3 beim ShowAsync() eine Ausnahme. „Abbrechen“ ist die sichere Schaltfläche; Esc liefert nicht Primary, es wird nichts gelöscht. Wie sich die Auswahl verhält, wenn das gewählte Element verschwindet, nennt Learn nicht; beobachten Sie es. Der Screenreader liest die Schaltfläche über ihren sichtbaren Inhalt; das TextBox hat keinen Namen aus seinem Text, deshalb zeigt LabeledBy auf „Suche“; die Liste bekommt ihren Namen über AutomationProperties.Name. AccessKey="L" bedeutet Alt+L; Entf ist ein KeyboardAccelerator, der global gilt. Das kann stören, sobald die Seite ein Textfeld bekommt, in dem Entf Zeichen löscht; Learn nennt ScopeOwner, um den Geltungsbereich einzuschränken.

Aufräumen: nichts. Die Änderungen sind nur im Speicher; ein Neustart bringt die sechs Kontakte zurück.

Übung 5

Themen, hoher Kontrast und Skalierung prüfen. Legen Sie die Pinsel an, schalten Sie die Themen um und testen Sie den Maßstab.

  1. Tragen Sie die Pinsel KladdeCardBrush für „Light“, „Dark“ und „HighContrast“ in App.xaml ein, in das vorhandene ResourceDictionary.
  2. Starten Sie die Anwendung. Stellen Sie in den Windows-Einstellungen den App-Modus auf Hell und auf Dunkel, während die Anwendung läuft.
  3. Fügen Sie in der Shell eine Schaltfläche „Thema wechseln“ hinzu, die RequestedTheme am Wurzelelement der Shell zwischen ElementTheme.Light und ElementTheme.Dark umschaltet.
  4. Schalten Sie ein Kontrastthema ein (in den Windows-Einstellungen; Learn nennt für den Wechsel auch die Tastenkombination Alt+Umschalt+Druck, die Bezeichnungen der Einstellungsseiten nennt es nicht) und beobachten Sie die Kontaktseite.
  5. Stellen Sie den Anzeigemaßstab auf 150 % und auf 200 % und beobachten Sie Layout und Schärfe.
  6. Gehen Sie mit der Tabulatortaste durch die Seite „Kontakte“, mit Narrator (oder einem anderen Screenreader) in der Hand, und achten Sie auf Namen und Reihenfolge.

So prüfen Sie: Die Karten wechseln mit dem Thema die Farbe und sind in beiden lesbar. Im Kontrastthema übernimmt der Hintergrund die Systemfarbe. Bei 200 % ist nichts abgeschnitten; die Liste scrollt. Der Screenreader nennt „Suche“ beim Textfeld, „Löschen“ bei der Schaltfläche und „Kontakte“ bei der Liste. Alles andere, was Sie beobachten, notieren Sie.

Lösung 5

Zu Schritt 1 gilt die Regel aus dem Abschnitt Themen: {StaticResource} und Farbwerte in Hell und Dunkel, {ThemeResource} nur für die Systemfarbe im Kontrastwörterbuch. Zu Schritt 2: Die Systemsteuerelemente wechseln sofort, Ihre eigenen Pinsel nur, wenn Sie sie mit {ThemeResource} benutzen. Haben Sie {StaticResource KladdeCardBrush} benutzt, bleibt die Karte beim Wechsel in der alten Farbe stehen: Das ist der Fehler, den Learn für diese Regel nennt.

Schritt 3, ein Beispiel für den Umschalter. Der Name des Wurzelelements, RootGrid, steht für das äußerste Element der Shell; geben Sie ihm einen x:Name:

C#
private void ThemeButton_Click(object sender, RoutedEventArgs e)
{
    RootGrid.RequestedTheme = RootGrid.ActualTheme == ElementTheme.Dark
        ? ElementTheme.Light
        : ElementTheme.Dark;
}

ActualTheme ist eine Eigenschaft von FrameworkElement, die Learn auf den gelesenen Seiten nicht nennt; ersetzen Sie sie, wenn sie bei Ihnen fehlt, durch ein Feld, das den Zustand merkt. FrameworkElement.RequestedTheme kann zur Laufzeit geändert werden, anders als Application.RequestedTheme, die beim Start gesetzt werden muss. Beobachtung zum Kontrast: Die Eigenschaft wird im hohen Kontrast ignoriert, der Umschalter hat dort keine Wirkung.

Schritt 5: Learns Tabelle für die Skalierung nennt 100 %, 125 %, 150 % und 200 %. Was Sie sehen, hängt von der Vorlage ab. Wirkt das Fenster unscharf, ist die Ursache nach Learns Seite zur DPI-Ausrichtung von Desktop-Anwendungen oft eine Anwendung ohne DPI-Angabe im Manifest; ob das hier der Fall ist, prüfen Sie im Manifest. Wirkt es scharf, ist die Skalierung in Ordnung.

Schritt 6: Fehlen Namen, ergänzen Sie AutomationProperties.Name oder LabeledBy. Springt die Tabulatortaste in einer Reihenfolge, die der Anordnung widerspricht, ordnen Sie das XAML um oder setzen TabIndex. Hat die Liste keinen Namen, liest der Screenreader nur ihre Rolle.

Aufräumen: Stellen Sie die Windows-Einstellungen (Maßstab, Kontrastthema, App-Modus) auf Ihre gewohnten Werte zurück.

Die Kladde: Termine und Kontakte als Seiten

Nach dieser Lektion hat Kladde.Desktop zwei Seiten in einer NavigationView: Die Termine des Tages als Liste und die Kontakte mit Suche, Detailbereich und einem Dialog zum Löschen, noch mit festen Beispieldaten und mit der Logik im Code-Behind. Die Projekte aus K2 sind unverändert. Zwei Dinge sind bewusst vorläufig: Die Logik in den Seiten (Filter, Löschen, Anzeigetext) und die Daten im Code. W5.2.1 holt die Logik in ViewModels in der Bibliothek Kladde.Desktop.Logic und testet sie ohne Fenster, W5.2.2 ersetzt die Beispieldaten durch die Web-API der Kladde mit Anmeldung. Nehmen Sie Models, Pages, die Shell und die Änderungen an App.xaml ins Repository auf, im Branch feature/winui-app, mit einer Nachricht wie feat(desktop): Seiten für Termine und Kontakte mit NavigationView.

Was Sie jetzt können

  • Sie lesen und schreiben XAML mit Namensräumen, Namen, Ereignissen und Ressourcen und kennen den Unterschied zwischen {StaticResource} und {ThemeResource}.
  • Sie bauen Layouts mit Grid (Auto, *, feste Größen), StackPanel und RelativePanel und zeigen Listen mit ListView und ItemsRepeater, mit Datenvorlage und x:DataType.
  • Sie navigieren mit Frame, Page und NavigationView und wissen, dass die NavigationView nicht von selbst navigiert.
  • Sie zeigen einen ContentDialog mit gesetzter XamlRoot, mit sicherer Schaltfläche und ohne zwei Dialoge gleichzeitig.
  • Sie legen Ressourcen für Hell, Dunkel und hohen Kontrast an, wechseln das Thema zur Laufzeit am Element und prüfen den Anzeigemaßstab.
  • Sie machen Seiten mit der Tastatur bedienbar (Tabreihenfolge, Zugriffstasten, Beschleuniger) und für Screenreader lesbar (AutomationProperties).

Weiterlesen