Nachgefragt. Nicht-blockende Dialoge in WPF

Ein nicht-blockender Dialog, in .net, basierend auf dem MVVM-Designmuster. Geht das? Ja, klar!

Genau genommen, ist dies ein Post aus dem letzten Jahr – jedoch aktualisiert im Rahmen des Release meines BYTES.NET-Projekts. Der Quellcode der Beispielanwendung ist im zugehörigen Github-Repository zu finden und dient als Basis für diesen Artikel:

Jeder Entwickler kommt irgendwann an den Punkt, an dem er den Anwender nach weiteren Angaben oder Eingaben fragen muss. Sofern es sich nicht um eine „Single Page“- oder „inline“- Anwendung handelt, gehört es zum üblichen Vorgehen, einen Dialog zu öffnen, auf die Eingabe (und deren Bestätigung) zu warten und die Informationen anschließend (weiter) zu verarbeiten.

In einer Windows Forms-Anwendung gibt es den recht komfortablen OpenFileDialog um z.B. eine Datei zu öffnen. In Windows Presentation Foundation (WPF) gibt es diesen und ähnliche Dialoge standardmäßig nicht. Der übliche Tipp für WPF und den soeben genannten Anwendungsfall lautet daher häufig „bitte auf das System.Windows.Forms-Assembly referenzieren und dann den OpenFileDialog verwenden“.

Wie geht man aber damit um, wenn man eine Eingabe benötigt, für die es keinen vorgefertigten Dialog gibt oder wenn die Standarddialoge nicht ausreichen?

Entsprechend des MVVM-Designmusters, besteht ein Dialog aus

  • Einem Window, das als View verwendet wird und in dem die notwendigen Controls per XAML definiert werden.
  • Die Eigenschaften der Controls werden an die entsprechenden Eigenschaften eines View Models gebunden.
  • Anschließend wird der Dialog über Window.Show() anzeigen.

So weit so gut, jedoch liegt auch hier – wie allzu häufig – die Herausforderung im Detail. Was ist zum Beispiel…

  • … wenn der Dialog bzw. das Warten auf die Eingabe die übrigen Anwendungsfunktionen nicht blockieren darf (weil beispielsweise asynchrone Prozesse im Hintergrund ausgeführt werden)?
  • … wenn man häufig verschiedene Dialoge benötigt und die Codebase möglichst schlank halten möchte (z.B. auch vor dem Hintergrund des Bugfixings und der Wartbarkeit)?

Gleichzeitig ist es (aus meiner Sicht) deutlich eleganter – ähnlich wie im OpenFileDialog eine ShowDialog()-Methode über das View Model aufzurufen, anstatt eine neue Instanz der Window-Klasse zu erstellen und diese dann aufzurufen.

Die Lösung dafür bietet eine eigene DialogViewModel-Basisklasse, die von BYTES.NET.WPF.MVVM.ViewModel abgeleitet wird und somit auch INotifyPropertyChanged implementiert. Diese Klasse stellt eine Methode namens ShowDialog() bereit, in der gleich drei Dinge passieren:

  • Zum Ersten wird das zugehörige View (= Window) über die Methode ShowView() angezeigt.
  • Es wird darauf gewartet, dass der Anwender den View wieder schließt – entweder in Form einer Eingabebestätigung oder eines Abbruchs
  • Nach dem Schließen des View wird ein Boolean zurückgegeben, der in der aufrufenden Klasse dann ausgewertet werden kann:
Public Function ShowDialog() As Boolean

    'show the view
    ShowView()

    'wait for user input
    _myBlock = New EventWaitHandle(False, EventResetMode.ManualReset)
    WaitForEvent(_myBlock, New TimeSpan(24, 0, 0))

    'remove the wait handle
    _myBlock.Dispose()

    'return the dialog closing indicator
    Return Me.DialogResult

End Function

Ein Beispiel für eine daraus abgeleitete View Model-Klasse ist BYTES.NET.Sample.ViewModels.DialogVM auf Github. Durch die beiden überschreibbaren Methoden ShowView() und CloseView() ist es möglich nicht nur Windows als Views zu verwenden, sondern ggf. auch eigene GUI-Elemente wie z.B. Benutzersteuerelemente. Zudem lässt sich bei Bedarf noch während des Schließens der Wert von DialogResult manipulieren.

Kommen wir nun zum Verhindern des Blockens der Anwendung, beim Warten auf die Eingabebestätigung, WaitForEvent(). Nachdem ich einige (mehr oder minder erfolgreiche) Lösungsansätze probiert habe, bin ich in diesem Artikel auf eine sehr elegante Lösung zu dieser Problematik gestoßen: Die Grundidee beruht darauf einen zusätzlichen Thread zu verwenden um asynchron auf ein Ereignis zu warten – wodurch der Hauptthread (und mit ihm auch die Oberfläche) nicht blockiert wird. Zeitlich achgelagerte Operationen werden erst dann ausgeführt, wenn ShowDialog() eine Antwort zurückgegeben hat.

Private Function WaitForEvent(eventHandle As EventWaitHandle, Optional timeout As TimeSpan = Nothing) As Boolean

    Dim didWait As Boolean = False
    Dim frame = New DispatcherFrame()
    Dim start As New ParameterizedThreadStart(Sub()

                                                didWait = eventHandle.WaitOne(timeout)
                                                frame.[Continue] = False

                                              End Sub)

    Dim thread = New Thread(start)
    thread.Start()
    Dispatcher.PushFrame(frame)
    Return didWait

End Function

Schön und gut, was passiert nun aber, wenn der Anwender – statt auf z.B. einen Button zu drücken – das Dialogfenster über das kleine Kreuzchen rechts oben im Header schließt? Das Fenster wird zwar geschlossen, aber der „Warte“-Thread wird in der Regel nicht automatisch beendet und die Anwendung wird nicht sauber geschlossen.

Um auch dieses Problem zu lösen, gibt es einen recht einfachen Trick, den ich wiederum in diesem Artikel gefunden habe: Mithilfe eines Behaviors wird ein delegated Command (in meinem Fall ViewModelRelayCommand) an das ClosedEvent des Window gebunden, so dass – wie bei jedem anderen Command auch – die Close()-Methode (mit einem Negativwert für das DialogResult) aufgerufen werden kann. Als Teil eines XAML-definierten Window sieht das Ganze dann so aus:

<Window x:Class="Views.MVVM.DialogView"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:BYTES.NET.SAMPLE.Views.MVVM"
        
        xmlns:Behaviors="clr-namespace:BYTES.NET.WPF.Behaviors;assembly=BYTES.NET"
        Behaviors:WindowClosingBehavior.Closed="{Binding Path=Commands[CancelCmd]}"
        
        mc:Ignorable="d"
        Title="{Binding Title, Mode=OneWay}" Height="300" Width="500" ResizeMode="NoResize">

    ...

</Window>

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.