Einstellungen mit Tabs: TabView und Verbindungstest (Teil 22)

Eine Einstellungsseite wächst mit der Zeit. Irgendwann ist ein scrollbares Layout mit vielen Abschnitten unübersichtlich. Der übliche Ausweg ist eine Tab-Struktur: Jedes Tab hat einen klar abgegrenzten Themenbereich. WinUI 3 bietet dafür das TabView-Steuerelement, das eine vollständige Tab-Leiste inklusive optionaler Schließ-Buttons und verschiedener Breiteneinstellungen liefert. In diesem Teil zeige ich, wie EchoPlay die Einstellungsseite mit Tabs strukturiert, einen Verbindungstest per Command-Pattern umsetzt und die Testergebnisse sauber in der Oberfläche darstellt.

TabView in WinUI 3

Ein TabView enthält eine beliebige Anzahl von TabViewItem-Elementen. Jedes Item hat einen Header (die sichtbare Beschriftung) und einen Content (den Seiteninhalt). Die Lokalisierung des Headers funktioniert über das bereits bekannte x:Uid-Muster: x:Uid="TabAllgemein" lädt aus der Ressourcendatei den Schlüssel TabAllgemein.Header.

<TabView IsAddTabButtonVisible="False" TabWidthMode="Equal">
    <TabViewItem x:Uid="TabAllgemein" IsClosable="False">
        <ScrollViewer Padding="24,16,24,32">
            <!-- Inhalt des ersten Tabs -->
        </ScrollViewer>
    </TabViewItem>
    <TabViewItem x:Uid="TabOnline" IsClosable="False">
        <!-- ... -->
    </TabViewItem>
</TabView>

IsAddTabButtonVisible="False" entfernt den Plus-Button, der normalerweise neue Tabs hinzufügen würde. TabWidthMode="Equal" verteilt alle Tabs gleichmäßig über die verfügbare Breite. IsClosable="False" verhindert, dass der Benutzer einzelne Tabs schließen kann. Ein wichtiger technischer Hinweis: Die TabViewItem-Elemente müssen als direkte Kinder des TabView geschrieben werden. Die Property-Element-Syntax <TabView.TabItems> funktioniert mit dem WinUI-3-XAML-Compiler nicht zuverlässig.

Verbindungstest mit Command-Pattern und IServiceScopeFactory

Der Online-Tab enthält einen Button zum Testen der API-Verbindung. Das ViewModel hält dafür ein ICommand-Property, das einen asynchronen Test auslöst. Der Test selbst öffnet einen eigenen DI-Scope über die IServiceScopeFactory und holt sich dort den passenden API-Client.

public async Task TestConnectionAsync()
{
    if (_isTestingConnection) return;

    IsTestingConnection = true;

    try
    {
        using IServiceScope scope = _scopeFactory.CreateScope();

        if (ActiveProvider == ProviderType.Spotify)
        {
            ISpotifyApiClient client = scope.ServiceProvider.GetRequiredService<ISpotifyApiClient>();
            await client.SearchArtistsAsync("test", 1);
        }
        else
        {
            IAppleMusicSearchClient client = scope.ServiceProvider.GetRequiredService<IAppleMusicSearchClient>();
            await client.SearchArtistsAsync("test", 1);
        }

        ConnectionTestSuccess    = true;
        ConnectionTestResultText = "Verbindung erfolgreich";
    }
    catch (Exception ex)
    {
        ConnectionTestSuccess    = false;
        ConnectionTestResultText = $"Verbindung fehlgeschlagen: {ex.Message}";
    }
    finally
    {
        IsTestingConnection = false;
    }
}

IServiceScopeFactory ist hier notwendig, weil das ViewModel als Transient registriert ist, während ISpotifyApiClient und IAppleMusicSearchClient als Scoped leben. Einen Scoped-Service direkt in ein Transient zu injizieren würde eine Warnung erzeugen — ein frischer Scope pro Operation ist die saubere Lösung.

Button während laufendem Test deaktivieren

Damit der Benutzer den Test nicht mehrfach gleichzeitig auslösen kann, wird der Button deaktiviert, während der Test läuft. Das funktioniert über die SetEnabled-Methode des RelayCommand.

private readonly RelayCommand _testConnectionCommand;

public bool IsTestingConnection
{
    get => _isTestingConnection;
    private set
    {
        if (SetProperty(ref _isTestingConnection, value))
        {
            // RelayCommand.SetEnabled löst CanExecuteChanged aus –
            // WinUI 3 deaktiviert den gebundenen Button automatisch
            _testConnectionCommand.SetEnabled(!value);
        }
    }
}

public ICommand TestConnectionCommand => _testConnectionCommand;

Der Button im XAML bindet sich dann direkt ans Command. WinUI 3 überwacht das CanExecuteChanged-Event des Commands und setzt IsEnabled am Button automatisch auf den Rückgabewert von CanExecute.

<Button x:Uid="TestConnectionButton" Command="{x:Bind ViewModel.TestConnectionCommand}"/>

Testergebnis mit Visibility-Properties anzeigen

Das Ergebnis des Tests — Erfolg oder Fehler — wird durch drei Visibility-typed Properties gesteuert. Das ist das bewährte Muster aus WinUI 3: kein Converter, stattdessen berechnete Properties im ViewModel.

public bool? ConnectionTestSuccess
{
    get => _connectionTestSuccess;
    private set
    {
        if (SetProperty(ref _connectionTestSuccess, value))
        {
            OnPropertyChanged(nameof(ConnectionTestSuccessVisibility));
            OnPropertyChanged(nameof(ConnectionTestFailureVisibility));
            OnPropertyChanged(nameof(ConnectionTestResultVisibility));
        }
    }
}

public Visibility ConnectionTestSuccessVisibility =>
    _connectionTestSuccess == true ? Visibility.Visible : Visibility.Collapsed;

public Visibility ConnectionTestFailureVisibility =>
    _connectionTestSuccess == false ? Visibility.Visible : Visibility.Collapsed;

public Visibility ConnectionTestResultVisibility =>
    _connectionTestSuccess.HasValue ? Visibility.Visible : Visibility.Collapsed;

ConnectionTestSuccess ist ein bool? (nullable). null bedeutet: kein Test durchgeführt. Der gesamte Ergebnisbereich ist damit unsichtbar, solange noch kein Test gelaufen ist. Erst nach dem ersten Test zeigt ConnectionTestResultVisibility das Ergebnispanel an, und je nach Ergebnis ist entweder das grüne Häkchen oder das rote Kreuz sichtbar.

<StackPanel
    Orientation="Horizontal"
    Spacing="8"
    Visibility="{x:Bind ViewModel.ConnectionTestResultVisibility, Mode=OneWay}">
    <FontIcon
        Glyph="&#xE73E;"
        Visibility="{x:Bind ViewModel.ConnectionTestSuccessVisibility, Mode=OneWay}"/>
    <FontIcon
        Glyph="&#xE711;"
        Visibility="{x:Bind ViewModel.ConnectionTestFailureVisibility, Mode=OneWay}"/>
    <TextBlock
        Text="{x:Bind ViewModel.ConnectionTestResultText, Mode=OneWay}"/>
</StackPanel>

Die Glyphen stammen aus der Segoe Fluent Icons-Schriftart: E73E ist ein Häkchen, E711 ein X.

Fakes in Tests vollständig halten

Wenn ein ViewModel erweitert wird und neue Services braucht, müssen die dazugehörigen Tests nachgezogen werden. Fehlt ein registrierter Service im DI-Container des Tests, wirft der Scope zur Laufzeit eine InvalidOperationException. Das Muster zum Beheben ist immer gleich: das fehlende Interface im ServiceCollection des Test-Builders registrieren.

services.AddScoped<IEpisodeDataService>(_ => new FakeEpisodeDataService());
services.AddScoped<IPlaybackStateDataService>(_ => new FakePlaybackStateDataService());

Für neue Interfaces wie IConfirmationDialogService wird ein eigener Fake angelegt, der das Ergebnis konfigurierbar macht — entweder „Benutzer hat bestätigt“ oder „Benutzer hat abgebrochen“. So bleibt der Test-DI-Container synchron mit dem produktiven Container, und die Tests brechen nicht durch fehlende Registrierungen.

Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu