From 1ede98f196e4deda9c52e7d2597c5e3159356272 Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Fri, 21 Aug 2026 00:09:21 -0700 Subject: [PATCH] feat(DialogHost): Allow awaiting visual state transitions Introduces `WaitForOpened` and `WaitForClosed` methods on `DialogHost` to enable asynchronous waiting for the completion of the dialog's visual state transitions. This provides more precise control for executing logic precisely after the dialog has fully opened or closed, rather than just when events are dispatched. Updates the `DialogSession` to expose the parent `DialogHost` instance, facilitating access to these new awaitable methods. --- src/MainDemo.Wpf/Dialogs.xaml.cs | 8 +++- src/MaterialDesign3.Demo.Wpf/Dialogs.xaml.cs | 9 +++- src/MaterialDesignThemes.Wpf/DialogHost.cs | 40 +++++++++++++---- src/MaterialDesignThemes.Wpf/DialogSession.cs | 24 +++++----- .../Internal/VisualStateMonitor.cs | 45 +++++++++++++++++++ .../MaterialDesignTheme.DialogHost.xaml | 26 +++++------ 6 files changed, 115 insertions(+), 37 deletions(-) create mode 100644 src/MaterialDesignThemes.Wpf/Internal/VisualStateMonitor.cs diff --git a/src/MainDemo.Wpf/Dialogs.xaml.cs b/src/MainDemo.Wpf/Dialogs.xaml.cs index f8816bb7df..be028a3edc 100644 --- a/src/MainDemo.Wpf/Dialogs.xaml.cs +++ b/src/MainDemo.Wpf/Dialogs.xaml.cs @@ -42,8 +42,12 @@ private void Sample1_DialogHost_OnDialogClosed(object sender, DialogClosedEventA } // Used for DialogHost.DialogClosingAttached - private void Sample2_DialogHost_OnDialogClosing(object sender, DialogClosingEventArgs eventArgs) - => Debug.WriteLine($"SAMPLE 2: Closing dialog with parameter: {eventArgs.Parameter ?? string.Empty}"); + private async void Sample2_DialogHost_OnDialogClosing(object sender, DialogClosingEventArgs eventArgs) + { + Debug.WriteLine($"{DateTime.Now.TimeOfDay} SAMPLE 2: Closing dialog with parameter: {eventArgs.Parameter ?? string.Empty}"); + await eventArgs.Session.DialogHost.WaitForClosed(); + Debug.WriteLine($"{DateTime.Now.TimeOfDay} SAMPLE 2: Closed dialog with parameter: {eventArgs.Parameter ?? string.Empty}"); + } private void Sample2_DialogHost_OnDialogClosed(object sender, DialogClosedEventArgs eventArgs) => Debug.WriteLine($"SAMPLE 2: Closed dialog with parameter: {eventArgs.Parameter ?? string.Empty}"); diff --git a/src/MaterialDesign3.Demo.Wpf/Dialogs.xaml.cs b/src/MaterialDesign3.Demo.Wpf/Dialogs.xaml.cs index 454e84665f..7b3adceb30 100644 --- a/src/MaterialDesign3.Demo.Wpf/Dialogs.xaml.cs +++ b/src/MaterialDesign3.Demo.Wpf/Dialogs.xaml.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Threading; using MaterialDesign3Demo.Domain; using MaterialDesignThemes.Wpf; @@ -27,8 +28,12 @@ private void Sample1_DialogHost_OnDialogClosing(object sender, DialogClosingEven } // Used for DialogHost.DialogClosingAttached - private void Sample2_DialogHost_OnDialogClosing(object sender, DialogClosingEventArgs eventArgs) - => Debug.WriteLine($"SAMPLE 2: Closing dialog with parameter: {eventArgs.Parameter ?? string.Empty}"); + private async void Sample2_DialogHost_OnDialogClosing(object sender, DialogClosingEventArgs eventArgs) + { + Debug.WriteLine($"{DateTime.Now.TimeOfDay} SAMPLE 2: Closing dialog with parameter: {eventArgs.Parameter ?? string.Empty}"); + await eventArgs.Session.DialogHost.WaitForClosed(); + Debug.WriteLine($"{DateTime.Now.TimeOfDay} SAMPLE 2: Closed dialog with parameter: {eventArgs.Parameter ?? string.Empty}"); + } private void Sample5_DialogHost_OnDialogClosing(object sender, DialogClosingEventArgs eventArgs) { diff --git a/src/MaterialDesignThemes.Wpf/DialogHost.cs b/src/MaterialDesignThemes.Wpf/DialogHost.cs index 99f88d49db..554bf9a657 100644 --- a/src/MaterialDesignThemes.Wpf/DialogHost.cs +++ b/src/MaterialDesignThemes.Wpf/DialogHost.cs @@ -1,9 +1,12 @@ +using System.Diagnostics; using System.Runtime.InteropServices; using System.Security; +using System.Threading; using System.Windows.Data; using System.Windows.Interop; using System.Windows.Media; using System.Windows.Threading; +using MaterialDesignThemes.Wpf.Internal; namespace MaterialDesignThemes.Wpf; @@ -31,13 +34,18 @@ public enum DialogHostOpenDialogCommandDataContextSource [TemplatePart(Name = PopupPartName, Type = typeof(Popup))] [TemplatePart(Name = PopupPartName, Type = typeof(ContentControl))] [TemplatePart(Name = ContentCoverGridName, Type = typeof(Grid))] -[TemplateVisualState(GroupName = "PopupStates", Name = OpenStateName)] -[TemplateVisualState(GroupName = "PopupStates", Name = ClosedStateName)] +[TemplatePart(Name = RootContentPartName, Type = typeof(FrameworkElement))] +[TemplateVisualState(GroupName = VisualStateGroupName, Name = OpenStateName)] +[TemplateVisualState(GroupName = VisualStateGroupName, Name = ClosedStateName)] public class DialogHost : ContentControl { + public const string VisualStateGroupName = "PopupStates"; + public const string PopupPartName = "PART_Popup"; public const string PopupContentPartName = "PART_PopupContentElement"; public const string ContentCoverGridName = "PART_ContentCoverGrid"; + public const string RootContentPartName = "PART_DialogHostRoot"; + public const string OpenStateName = "Open"; public const string ClosedStateName = "Closed"; @@ -57,6 +65,9 @@ public class DialogHost : ContentControl private DialogClosedEventHandler? _asyncShowClosedEventHandler; private TaskCompletionSource? _dialogTaskCompletionSource; + private VisualStateMonitor? _visualStateMonitor; + + private Popup? _popup; private ContentControl? _popupContentControl; private Grid? _contentCoverGrid; @@ -395,7 +406,8 @@ private static void IsOpenPropertyChangedCallback(DependencyObject dependencyObj //https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/187 //totally not happy about this, but on immediate validation we can get some weird looking stuff...give WPF a kick to refresh... - Task.Delay(300).ContinueWith(t => dialogHost.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => { + Task.Delay(300).ContinueWith(t => dialogHost.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { CommandManager.InvalidateRequerySuggested(); //Delay focusing the popup until after the animation has some time, Issue #2912 UIElement? child = dialogHost.FocusPopup(); @@ -628,9 +640,26 @@ public override void OnApplyTemplate() VisualStateManager.GoToState(this, GetStateName(), false); + if (GetTemplateChild(RootContentPartName) is FrameworkElement root && + VisualStateManager.GetVisualStateGroups(root) is [VisualStateGroup stateGroup, ..]) + { + var stateNames = stateGroup.States.OfType().Select(x => x.Name).ToList(); + if (stateNames.Contains(OpenStateName) && stateNames.Contains(ClosedStateName)) + { + _visualStateMonitor = new(stateGroup); + } + } base.OnApplyTemplate(); } + public Task WaitForOpened(CancellationToken cancellationToken = default) + => _visualStateMonitor?.WaitForState(OpenStateName, cancellationToken) + ?? throw new InvalidOperationException("Unable to locate visual states for the DialogHost, cannot wait for state transitions"); + + public Task WaitForClosed(CancellationToken cancellationToken = default) + => _visualStateMonitor?.WaitForState(ClosedStateName, cancellationToken) + ?? throw new InvalidOperationException("Unable to locate visual states for the DialogHost, cannot wait for state transitions"); + #region restore focus properties public static readonly DependencyProperty RestoreFocusElementProperty = DependencyProperty.RegisterAttached( @@ -967,11 +996,6 @@ private void OnLoaded(object sender, RoutedEventArgs routedEventArgs) } } - private void OnPreviewGotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) - { - - } - [SecurityCritical] [DllImport("user32.dll", EntryPoint = "SetFocus", SetLastError = true)] private static extern IntPtr SetFocus(IntPtr hWnd); diff --git a/src/MaterialDesignThemes.Wpf/DialogSession.cs b/src/MaterialDesignThemes.Wpf/DialogSession.cs index 7c4aec531f..63374d4d6b 100644 --- a/src/MaterialDesignThemes.Wpf/DialogSession.cs +++ b/src/MaterialDesignThemes.Wpf/DialogSession.cs @@ -7,16 +7,16 @@ namespace MaterialDesignThemes.Wpf; /// public class DialogSession { - private readonly DialogHost _owner; - internal DialogSession(DialogHost owner) - => _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + => DialogHost = owner ?? throw new ArgumentNullException(nameof(owner)); + + public DialogHost DialogHost { get; } /// - /// Indicates if the dialog session has ended. Once ended no further method calls will be permitted. + /// Indicates if the dialog session has ended. Once ended no further method calls will be permitted. /// /// - /// Client code cannot set this directly, this is internally managed. To end the dialog session use . + /// Client code cannot set this directly, this is internally managed. To end the dialog session use . /// public bool IsEnded { get; internal set; } @@ -28,7 +28,7 @@ internal DialogSession(DialogHost owner) /// /// Gets the which is currently displayed, so this could be a view model or a UI element. /// - public object? Content => _owner.DialogContent; + public object? Content => DialogHost.DialogContent; /// /// Update the current content in the dialog. @@ -36,11 +36,11 @@ internal DialogSession(DialogHost owner) /// public void UpdateContent(object? content) { - _owner.AssertTargetableContent(); - _owner.DialogContent = content; - _owner.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + DialogHost.AssertTargetableContent(); + DialogHost.DialogContent = content; + DialogHost.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => { - _owner.FocusPopup(); + DialogHost.FocusPopup(); })); } @@ -52,7 +52,7 @@ public void Close() { if (IsEnded) throw new InvalidOperationException("Dialog session has ended."); - _owner.InternalClose(null); + DialogHost.InternalClose(null); } /// @@ -64,6 +64,6 @@ public void Close(object? parameter) { if (IsEnded) throw new InvalidOperationException("Dialog session has ended."); - _owner.InternalClose(parameter); + DialogHost.InternalClose(parameter); } } diff --git a/src/MaterialDesignThemes.Wpf/Internal/VisualStateMonitor.cs b/src/MaterialDesignThemes.Wpf/Internal/VisualStateMonitor.cs new file mode 100644 index 0000000000..7a4b07c1d2 --- /dev/null +++ b/src/MaterialDesignThemes.Wpf/Internal/VisualStateMonitor.cs @@ -0,0 +1,45 @@ +using System.Threading; + +namespace MaterialDesignThemes.Wpf.Internal; + +internal sealed class VisualStateMonitor +{ + private readonly VisualStateGroup _visualStateGroup; + + public VisualStateMonitor(VisualStateGroup visualStateGroup) + { + _visualStateGroup = visualStateGroup ?? + throw new ArgumentNullException(nameof(visualStateGroup)); + } + + public Task WaitForState(string state, CancellationToken cancellationToken) + { + string currentState = _visualStateGroup.CurrentState.Name; + if (currentState == state) return Task.CompletedTask; + + TaskCompletionSource tcs = new(); + cancellationToken.Register(() => tcs.TrySetCanceled()); + + EventHandler stateChanged = null!; + stateChanged = (sender, e) => + { + if (e.NewState.Name == state) + { + _visualStateGroup.CurrentStateChanged -= stateChanged; + tcs.TrySetResult(state); + } + }; + + _visualStateGroup.CurrentStateChanged += stateChanged; + + currentState = _visualStateGroup.CurrentState.Name; + if (currentState == state) + { + _visualStateGroup.CurrentStateChanged -= stateChanged; + + return Task.CompletedTask; + } + + return tcs.Task; + } +} diff --git a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.DialogHost.xaml b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.DialogHost.xaml index 0261ae11cd..489d77ca0d 100644 --- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.DialogHost.xaml +++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.DialogHost.xaml @@ -25,11 +25,11 @@ - + - + - + @@ -68,7 +68,7 @@ - + @@ -111,7 +111,7 @@ - + - + @@ -152,7 +152,7 @@ @@ -265,11 +265,11 @@ - + - + - + @@ -308,7 +308,7 @@ - + @@ -351,7 +351,7 @@ - + - +