From 0d2dad71f16af68570da964573172c94a8832e3b Mon Sep 17 00:00:00 2001 From: Bill Henning Date: Thu, 9 May 2024 10:07:41 -0400 Subject: [PATCH] Update popups and flyouts to properly support OverlayDismissEventPassThrough (#15517) * Updated Popup to raise the pass-through overlay dismiss event prior to possibly closing the popup when a pointer is pressed. Added the PopupFlyoutBase.OverlayDismissEventPassThrough property and updated logic in Button. * Updated SplitButton logic to handle OverlayDismissEventPassThrough scenarios. * Updated CalendarDatePicker logic to handle OverlayDismissEventPassThrough scenarios. * Updated ComboBox logic to handle OverlayDismissEventPassThrough scenarios. * Removed unncessary ComboBox.PopupClosed logic that focused the control. This was problematic when the popup was open with OverlayDismissEventPassThrough and clicking onto another control. Focus would not move to the clicked control. * Fixed the Clicking_On_Control_PseudoClass unit test to properly recognize pseudo-class behavior change. * Added a couple unit tests to FlyoutTests. --- src/Avalonia.Controls/Button.cs | 18 +++- .../CalendarDatePicker/CalendarDatePicker.cs | 18 +++- src/Avalonia.Controls/ComboBox.cs | 20 +++-- .../Flyouts/PopupFlyoutBase.cs | 25 +++++- src/Avalonia.Controls/Primitives/Popup.cs | 8 +- .../SplitButton/SplitButton.cs | 32 ++++++- .../ComboBoxTests.cs | 2 +- .../FlyoutTests.cs | 84 +++++++++++++++++++ 8 files changed, 185 insertions(+), 22 deletions(-) diff --git a/src/Avalonia.Controls/Button.cs b/src/Avalonia.Controls/Button.cs index 821aef7758..dc836a0871 100644 --- a/src/Avalonia.Controls/Button.cs +++ b/src/Avalonia.Controls/Button.cs @@ -394,13 +394,23 @@ namespace Avalonia.Controls if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { - IsPressed = true; - e.Handled = true; - - if (ClickMode == ClickMode.Press) + if (_isFlyoutOpen && IsEffectivelyEnabled) { + // When a flyout is open with OverlayDismissEventPassThrough enabled and the button is pressed, + // close the flyout, but do not transition to a pressed state + e.Handled = true; OnClick(); } + else + { + IsPressed = true; + e.Handled = true; + + if (ClickMode == ClickMode.Press) + { + OnClick(); + } + } } } diff --git a/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.cs b/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.cs index b570b2c4ff..ec54533f4e 100644 --- a/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.cs +++ b/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.cs @@ -629,10 +629,22 @@ namespace Avalonia.Controls private void DropDownButton_PointerPressed(object? sender, PointerPressedEventArgs e) { - _ignoreButtonClick = _isPopupClosing; + if (_isFlyoutOpen && (_dropDownButton?.IsEffectivelyEnabled == true) && e.GetCurrentPoint(_dropDownButton).Properties.IsLeftButtonPressed) + { + // When a flyout is open with OverlayDismissEventPassThrough enabled and the drop-down button + // is pressed, close the flyout + _ignoreButtonClick = true; - _isPressed = true; - UpdatePseudoClasses(); + e.Handled = true; + TogglePopUp(); + } + else + { + _ignoreButtonClick = _isPopupClosing; + + _isPressed = true; + UpdatePseudoClasses(); + } } private void DropDownButton_PointerReleased(object? sender, PointerReleasedEventArgs e) diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index f1cc84f7a4..dbdbf4b536 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/src/Avalonia.Controls/ComboBox.cs @@ -298,7 +298,18 @@ namespace Avalonia.Controls return; } } - PseudoClasses.Set(pcPressed, true); + + if (IsDropDownOpen) + { + // When a drop-down is open with OverlayDismissEventPassThrough enabled and the control + // is pressed, close the drop-down + SetCurrentValue(IsDropDownOpenProperty, false); + e.Handled = true; + } + else + { + PseudoClasses.Set(pcPressed, true); + } } /// @@ -314,7 +325,7 @@ namespace Avalonia.Controls e.Handled = true; } } - else + else if (PseudoClasses.Contains(pcPressed)) { SetCurrentValue(IsDropDownOpenProperty, !IsDropDownOpen); e.Handled = true; @@ -375,11 +386,6 @@ namespace Avalonia.Controls { _subscriptionsOnOpen.Clear(); - if (CanFocus(this)) - { - Focus(); - } - DropDownClosed?.Invoke(this, EventArgs.Empty); } diff --git a/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs b/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs index 99c9f065ad..b91543556a 100644 --- a/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs +++ b/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs @@ -41,6 +41,12 @@ namespace Avalonia.Controls.Primitives public static readonly StyledProperty ShowModeProperty = AvaloniaProperty.Register(nameof(ShowMode)); + /// + /// Defines the property + /// + public static readonly StyledProperty OverlayDismissEventPassThroughProperty = + Popup.OverlayDismissEventPassThroughProperty.AddOwner(); + /// /// Defines the property /// @@ -115,6 +121,22 @@ namespace Avalonia.Controls.Primitives set => SetValue(ShowModeProperty, value); } + /// + /// Gets or sets a value indicating whether the event that closes the flyout is passed + /// through to the parent window. + /// + /// + /// Clicks outside the popup cause the popup to close. When + /// is set to false, these clicks will be + /// handled by the popup and not be registered by the parent window. When set to true, + /// the events will be passed through to the parent window. + /// + public bool OverlayDismissEventPassThrough + { + get => GetValue(OverlayDismissEventPassThroughProperty); + set => SetValue(OverlayDismissEventPassThroughProperty, value); + } + /// /// Gets or sets an element that should receive pointer input events even when underneath /// the flyout's overlay. @@ -247,6 +269,7 @@ namespace Avalonia.Controls.Primitives Popup.Child = CreatePresenter(); } + Popup.OverlayDismissEventPassThrough = OverlayDismissEventPassThrough; Popup.OverlayInputPassThroughElement = OverlayInputPassThroughElement; if (CancelOpening()) @@ -365,8 +388,6 @@ namespace Avalonia.Controls.Primitives { WindowManagerAddShadowHint = false, IsLightDismissEnabled = true, - //Note: This is required to prevent Button.Flyout from opening the flyout again after dismiss. - OverlayDismissEventPassThrough = false }; popup.Opened += OnPopupOpened; diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index a2bf07db0e..7285281d89 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -745,12 +745,16 @@ namespace Avalonia.Controls.Primitives { if (IsLightDismissEnabled && e.Source is Visual v && !IsChildOrThis(v)) { - CloseCore(); - if (OverlayDismissEventPassThrough) { PassThroughEvent(e); } + + // Ensure the popup is closed if it was not closed by a pass-through event handler + if (IsOpen) + { + CloseCore(); + } } } diff --git a/src/Avalonia.Controls/SplitButton/SplitButton.cs b/src/Avalonia.Controls/SplitButton/SplitButton.cs index dc8dc56fcf..c51c77bee2 100644 --- a/src/Avalonia.Controls/SplitButton/SplitButton.cs +++ b/src/Avalonia.Controls/SplitButton/SplitButton.cs @@ -226,6 +226,7 @@ namespace Avalonia.Controls if (_secondaryButton != null) { _secondaryButton.Click -= SecondaryButton_Click; + _secondaryButton.RemoveHandler(PointerPressedEvent, SecondaryButton_PreviewPointerPressed); } } @@ -248,6 +249,7 @@ namespace Avalonia.Controls if (_secondaryButton != null) { _secondaryButton.Click += SecondaryButton_Click; + _secondaryButton.AddHandler(PointerPressedEvent, SecondaryButton_PreviewPointerPressed, RoutingStrategies.Tunnel); } RegisterFlyoutEvents(Flyout); @@ -422,7 +424,14 @@ namespace Avalonia.Controls // Note: It is not currently required to check enabled status; however, this is a failsafe if (IsEffectivelyEnabled) { - OpenFlyout(); + if (_isFlyoutOpen) + { + CloseFlyout(); + } + else + { + OpenFlyout(); + } } } @@ -443,7 +452,7 @@ namespace Avalonia.Controls } /// - /// Event handler for when the internal primary button part is pressed. + /// Event handler for when the internal primary button part is clicked. /// private void PrimaryButton_Click(object? sender, RoutedEventArgs e) { @@ -453,7 +462,7 @@ namespace Avalonia.Controls } /// - /// Event handler for when the internal secondary button part is pressed. + /// Event handler for when the internal secondary button part is clicked. /// private void SecondaryButton_Click(object? sender, RoutedEventArgs e) { @@ -461,6 +470,23 @@ namespace Avalonia.Controls e.Handled = true; OnClickSecondary(e); } + + /// + /// Event handler for when the internal secondary button part is pressed. + /// + private void SecondaryButton_PreviewPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (_isFlyoutOpen && _secondaryButton?.IsEffectivelyEnabled == true) + { + if (e.GetCurrentPoint(_secondaryButton).Properties.IsLeftButtonPressed) + { + // When a flyout is open with OverlayDismissEventPassThrough enabled and the secondary button + // is pressed, close the flyout + e.Handled = true; + OnClickSecondary(e); + } + } + } /// /// Called when the property changes. diff --git a/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs index 162e3a6f8e..65243fa445 100644 --- a/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs @@ -53,7 +53,7 @@ namespace Avalonia.Controls.UnitTests Assert.True(target.Classes.Contains(ComboBox.pcDropdownOpen)); _helper.Down(target); - Assert.True(target.Classes.Contains(ComboBox.pcPressed)); + Assert.True(!target.Classes.Contains(ComboBox.pcPressed)); _helper.Up(target); Assert.True(!target.Classes.Contains(ComboBox.pcPressed)); diff --git a/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs b/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs index e3f5983274..dad8c3b78e 100644 --- a/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs +++ b/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs @@ -221,7 +221,91 @@ namespace Avalonia.Controls.UnitTests Assert.False(f.IsOpen); } } + + [Fact] + public void Light_Dismiss_No_Event_Pass_Through_To_Button() + { + using (CreateServicesWithFocus()) + { + var window = PreparedWindow(); + window.Width = 100; + window.Height = 100; + + bool buttonClicked = false; + var button = new Button() + { + ClickMode = ClickMode.Press + }; + button.Click += (s, e) => + { + buttonClicked = true; + }; + window.Content = button; + + window.Show(); + + var f = new Flyout(); + f.OverlayDismissEventPassThrough = false; // Focus of test + f.Content = new Border { Width = 10, Height = 10 }; + f.ShowAt(window); + + var hitTester = new Mock(); + window.HitTesterOverride = hitTester.Object; + hitTester.Setup(x => + x.HitTestFirst(new Point(90, 90), window, It.IsAny>())) + .Returns(button); + + var e = CreatePointerPressedEventArgs(window, new Point(90, 90)); + var overlay = LightDismissOverlayLayer.GetLightDismissOverlayLayer(window); + overlay.RaiseEvent(e); + + Assert.False(f.IsOpen); + Assert.False(buttonClicked); // Button is NOT clicked + } + } + [Fact] + public void Light_Dismiss_Event_Pass_Through_To_Button() + { + using (CreateServicesWithFocus()) + { + var window = PreparedWindow(); + window.Width = 100; + window.Height = 100; + + bool buttonClicked = false; + var button = new Button() + { + ClickMode = ClickMode.Press + }; + button.Click += (s, e) => + { + buttonClicked = true; + }; + window.Content = button; + + window.Show(); + + var f = new Flyout(); + f.OverlayDismissEventPassThrough = true; // Focus of test + f.Content = new Border { Width = 10, Height = 10 }; + f.ShowAt(window); + + var hitTester = new Mock(); + window.HitTesterOverride = hitTester.Object; + hitTester.Setup(x => + x.HitTestFirst(new Point(90, 90), window, It.IsAny>())) + .Returns(button); + + var e = CreatePointerPressedEventArgs(window, new Point(90, 90)); + var overlay = LightDismissOverlayLayer.GetLightDismissOverlayLayer(window); + overlay.RaiseEvent(e); + + Assert.False(f.IsOpen); + Assert.True(buttonClicked); // Button is clicked + } + } + [Fact] public void Flyout_Has_Uncancellable_Close_Before_Showing_On_A_Different_Target() {