Nachgefragt – Nicht-Blockende Dialoge in WPF

Jeder Entwickler kommt irgendwann an den Punkt, an dem er seine 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.

Möchte man dies z.B. in einer Windows Forms-Anwendung realisieren (um z.B. eine Datei zu öffnen), gibt es den recht komfortablen OpenFileDialog. In der Windows Presentation Foundation (WPF) gibt es diesen und ähnliche Dialoge nicht. Der übliche Tipp in WPF für den eben genannten Anwendungsfall lautet, auf das System.Windows.Forms-Assembly referenzieren und dann den OpenFileDialog verwenden. Wie sieht es aber aus, wenn man eine Eingabe benötigt, für den es keinen vorgefertigten Dialog gibt oder die Standarddialoge nicht ausreichen?

Folgt man dem MVVM-Designmuster in WPF, ist es eigentlich ganz einfach einen Dialog zu entwickeln: Ein Window (als View) verwenden und die Elemente per XAML an die Eigenschaften eines zugehörigen View Models binden – anschließend alles per Window.Show() anzeigen. So weit so gut, jedoch liegt auch hier die Herausforderung wieder im Detail. Was ist zum Beispiel…

  • … wenn der Dialog/ das Warten auf die Eingabe die übrige Anwendung 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)?

Zudem finde ich es persönlich sehr viel eleganter lediglich mittels ShowDialog-Methode (eines View Models) sowohl den View zu öffnen, als auch auf die Bestätigung durch den Nutzer zu warten – anstatt den View „separat“ zu instanziieren und dann z.B. über irgendwelche Event Handler auf die Nutzereingaben reagieren zu müssen.

Um nicht bei jedem Projekt wieder von vorn beginnen zu müssen, habe ich mir ein wieder verwendbares Dialog-Framework gebastelt (das auch Bestandteil meiner WPF-Basisklassenarchitektur ist):

Abgeleitet von einer View Model(-Basisklasse) – die u.a. INotifyPropertyChanged implementiert – ist das „Herzstück“ der Implementierung die DialogViewModelBase-Klasse, die eine öffentliche Funktion namens ShowDialog() bereitstellt.

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

In dieser Methode passieren drei Dinge, die den Großteil der eben genannten Probleme mit der Arbeit mit Dialogen abdecken:

  1. Der zugehörige View wird mittels ShowView() angezeigt
  2. Es wird darauf gewartet, dass der Nutzer den View auch wieder schließt – entweder in Form einer Eingabebestätigung oder eines Abbruchs
  3. Nach dem Schließen des View wird ein Boolean zurückgegeben, der anschließend im aufrufenden ViewModel ausgewertet werden kann

Für den ersten Punkt, stellt die DialogViewModelBase-Klasse zwei zu überschreibende Methoden bereit:

Protected MustOverride Sub ShowView()

Protected MustOverride Sub CloseView()

Ist man sich sicher, dass man lediglich Windows zur Darstellung der Nutzeroberfläche verwendet, kann man diese beiden Methoden auch weglassen und DialogViewModelbase eine entsprechende Eigenschaft mitgeben bzw. das Öffnen und Schließen des View direkt in die Methoden Showdialog() und Close() einfließen lassen. Da ich mich in meiner Basisinfrastruktur dazu nicht zu sehr festlegen (und später z.B. auch UserControls verwenden möchte), habe ich mich für diese generische Lösung entschieden.

Die Methode namens Close(), setzt nicht nur die DialogResult-Eigenschaft, sondern ruft ebenfalls die eben beschriebene CloseView()-Methode auf.

Protected Sub Close(ByVal result As Boolean)

            'set the dialog result
            Me.DialogResult = result

            'close the view
            CloseView()

            'release the blocking
            If Not IsNothing(_myBlock) Then

                _myBlock.Set()

            End If

End Sub

Nun aber das große Geheimnis des NICHT-blockens: Die WaitForEvent()-Methode. Nachdem ich einige (mehr oder minder erfolgreiche) Lösungsansätze probiert habe, bin ich in diesem Artikel auf eine sehr elegante Implementierung gestoßen.

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

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. Gleichzeitig wartet die Anwendung auf die Rückmeldung aus dem Dialog und führt nachgelagerte Operationen erst aus, nachdem ShowDialog() eine Antwort zurückgegeben hat.

Was ist nun aber mit dem kleinen Kreuzchen rechts oben in der Titelleiste des Dialogfensters? Normalerweise ist dieses Kreuzchen (bei einem Window) dafür gedacht, das Fenster zu schließen – tut es auch, leider schließt es dabei aber nicht den „Warte“-Thread, was in der Praxis bedeutet, dass die Anwendung nicht sauber geschlossen wird.

Um dieses Problem zu lösen, gibt es jedoch einen recht einfachen Trick, den ich in diesem Artikel gefunden habe: Mithilfe eines Behaviors wird ein delegated Command (in meinem ViewModelReleayCommand) 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.

Den gesamten Code sowie das Beispielprojekt findet ihr auf Github.