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..71bae50614 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; @@ -216,6 +227,33 @@ public static void Close(object? dialogIdentifier, object? parameter) /// public static bool IsDialogOpen(object? dialogIdentifier) => GetDialogSession(dialogIdentifier)?.IsEnded == false; + + /// + /// Waits for the DialogHost to move into the Opened visual state. This is useful when you want to ensure that the dialog is fully opened before performing a UI action, such as focusing a control within the dialog. + /// + /// of the instance where the dialog should be closed. Typically this will match an identifier set in XAML. + /// A token to determine how long to wait. + /// A task that completes when the DialogHost has moved into the Opened visual state. + /// Throw when the DialogHost template does not contain the Opened visual state. + public static Task WaitForOpened(object? dialogIdentifier = null, CancellationToken cancellationToken = default) + { + var instance = GetInstance(dialogIdentifier); + return instance.WaitForOpened(cancellationToken); + } + + /// + /// Waits for the DialogHost to move into the Closed visual state. This is useful when you want to ensure that the dialog is fully closed before performing a UI action, such as focusing a control outside of the dialog. + /// + /// of the instance where the dialog should be closed. Typically this will match an identifier set in XAML. + /// A token to determine how long to wait. + /// A task that completes when the DialogHost has moved into the Closed visual state. + /// Throw when the DialogHost template does not contain the Closed visual state. + public static Task WaitForClosed(object? dialogIdentifier = null, CancellationToken cancellationToken = default) + { + var instance = GetInstance(dialogIdentifier); + return instance.WaitForClosed(cancellationToken); + } + private static DialogHost GetInstance(object? dialogIdentifier) { if (LoadedInstances.Count == 0) @@ -395,7 +433,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 +667,38 @@ 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(); } + /// + /// Waits for the DialogHost to move into the Opened visual state. This is useful when you want to ensure that the dialog is fully opened before performing a UI action, such as focusing a control within the dialog. + /// + /// A token to determine how long to wait. + /// A task that completes when the DialogHost has moved into the Opened visual state. + /// Throw when the DialogHost template does not contain the Opened visual state. + 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"); + + /// + /// Waits for the DialogHost to move into the Closed visual state. This is useful when you want to ensure that the dialog is fully closed before performing a UI action, such as focusing a control outside of the dialog. + /// + /// A token to determine how long to wait. + /// A task that completes when the DialogHost has moved into the Closed visual state. + /// Throw when the DialogHost template does not contain the Closed visual state. + 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 +1035,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..1d45ef5aa6 --- /dev/null +++ b/src/MaterialDesignThemes.Wpf/Internal/VisualStateMonitor.cs @@ -0,0 +1,45 @@ +using System.Threading; + +namespace MaterialDesignThemes.Wpf.Internal; + +internal sealed class VisualStateMonitor(VisualStateGroup visualStateGroup) +{ + private readonly 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(); + + EventHandler stateChanged = null!; + stateChanged = (sender, e) => + { + if (e.NewState.Name == state) + { + _visualStateGroup.CurrentStateChanged -= stateChanged; + tcs.TrySetResult(state); + } + }; + + cancellationToken.Register(() => + { + _visualStateGroup.CurrentStateChanged -= stateChanged; + tcs.TrySetCanceled(cancellationToken); + }); + + _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 @@ - + - + (""); + + await dialogHost.RemoteExecute(OpenAndWaitForCompletion); + await Assert.That(dialogHost.GetIsOpen()).IsTrue(); + + await dialogHost.RemoteExecute(CloseAndWaitForCompletion); + await Assert.That(dialogHost.GetIsOpen()).IsFalse(); + + static async Task OpenAndWaitForCompletion(DialogHost dialogHost) + { + CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); + Task wait = dialogHost.WaitForOpened(cts.Token); + dialogHost.IsOpen = true; + await wait; + } + + static async Task CloseAndWaitForCompletion(DialogHost dialogHost) + { + CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); + Task wait = dialogHost.WaitForClosed(cts.Token); + dialogHost.IsOpen = false; + await wait; + } + } + + [Test] + public async Task WaitAlreadyReachedState_CompletesImmediately() + { + var dialogHost = await LoadXaml(""); + + await dialogHost.RemoteExecute(OpenAndWaitForCompletionTwice); + await Assert.That(dialogHost.GetIsOpen()).IsTrue(); + + await dialogHost.RemoteExecute(CloseAndWaitForCompletionTwice); + await Assert.That(dialogHost.GetIsOpen()).IsFalse(); + + static async Task OpenAndWaitForCompletionTwice(DialogHost dialogHost) + { + CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); + Task wait = dialogHost.WaitForOpened(cts.Token); + dialogHost.IsOpen = true; + await wait; + await dialogHost.WaitForOpened(cts.Token); + } + + static async Task CloseAndWaitForCompletionTwice(DialogHost dialogHost) + { + CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); + Task wait = dialogHost.WaitForClosed(cts.Token); + dialogHost.IsOpen = false; + await wait; + await dialogHost.WaitForClosed(cts.Token); + } + } + [Test] public async Task OnOpenDialog_OverlayCoversContent() { @@ -288,7 +347,7 @@ public async Task CornerRadius_AppliedToContentCoverBorder_WhenSetOnEmbeddedDial await Wait.For(async () => { var contentCoverBorder = await dialogHost.GetElement("ContentCoverBorder"); - + await Assert.That((await contentCoverBorder.GetCornerRadius()).TopLeft).IsEqualTo(1); await Assert.That((await contentCoverBorder.GetCornerRadius()).TopRight).IsEqualTo(2); await Assert.That((await contentCoverBorder.GetCornerRadius()).BottomRight).IsEqualTo(3); @@ -450,7 +509,7 @@ public async Task DialogHost_WithComboBox_CanSelectItem() var comboBox = await dialogHost.GetElement("TargetedPlatformComboBox"); await Task.Delay(500, TestContext.Current!.Execution.CancellationToken); await comboBox.LeftClick(); - + var item = await Wait.For(() => comboBox.GetElement("TargetItem")); await Task.Delay(TimeSpan.FromSeconds(1)); await item.LeftClick(); @@ -507,7 +566,7 @@ public async Task DialogHost_TrapsFocusInsidePopup_WhenTabbing(string dialogHost await Wait.For(async () => await Assert.That(await textBoxOne.GetIsFocused()).IsTrue()); await textBoxOne.SendInput(new KeyboardInput(inputActions)); - + await Wait.For(async () => await Assert.That(await textBoxTwo.GetIsFocused()).IsTrue()); await textBoxTwo.SendInput(new KeyboardInput(inputActions));