From 800788be20d7e35add669fda2eae5e481aaf92e5 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sun, 13 Jun 2021 02:44:06 -0400 Subject: [PATCH] Use ContextRequested event to show ContextFlyout + some refactoring of FlyoutBase --- src/Avalonia.Controls/Flyouts/FlyoutBase.cs | 144 ++++++++++++------ .../Flyouts/FlyoutShowMode.cs | 2 +- .../Flyouts/MenuFlyoutPresenter.cs | 10 +- .../FlyoutTests.cs | 131 +++++++++++++++- 4 files changed, 225 insertions(+), 62 deletions(-) diff --git a/src/Avalonia.Controls/Flyouts/FlyoutBase.cs b/src/Avalonia.Controls/Flyouts/FlyoutBase.cs index 011af4e7a5..30fb2c7761 100644 --- a/src/Avalonia.Controls/Flyouts/FlyoutBase.cs +++ b/src/Avalonia.Controls/Flyouts/FlyoutBase.cs @@ -1,6 +1,9 @@ using System; using System.ComponentModel; +using System.Linq; + using Avalonia.Input; +using Avalonia.Input.Platform; using Avalonia.Input.Raw; using Avalonia.Layout; using Avalonia.Logging; @@ -49,6 +52,7 @@ namespace Avalonia.Controls.Primitives public static readonly AttachedProperty AttachedFlyoutProperty = AvaloniaProperty.RegisterAttached("AttachedFlyout", null); + private readonly Lazy _popupLazy; private bool _isOpen; private Control? _target; private FlyoutShowMode _showMode = FlyoutShowMode.Standard; @@ -56,7 +60,12 @@ namespace Avalonia.Controls.Primitives private PixelRect? _enlargePopupRectScreenPixelRect; private IDisposable? _transientDisposable; - protected Popup? Popup { get; private set; } + public FlyoutBase() + { + _popupLazy = new Lazy(() => CreatePopup()); + } + + protected Popup Popup => _popupLazy.Value; /// /// Gets whether this Flyout is currently Open @@ -142,18 +151,19 @@ namespace Avalonia.Controls.Primitives HideCore(); } - protected virtual void HideCore(bool canCancel = true) + /// True, if action was handled + protected virtual bool HideCore(bool canCancel = true) { if (!IsOpen) { - return; + return false; } if (canCancel) { if (CancelClosing()) { - return; + return false; } } @@ -166,34 +176,40 @@ namespace Avalonia.Controls.Primitives _enlargedPopupRect = null; _enlargePopupRectScreenPixelRect = null; + if (Target != null) + { + Target.DetachedFromVisualTree -= PlacementTarget_DetachedFromVisualTree; + Target.KeyUp -= OnPlacementTargetOrPopupKeyUp; + } + OnClosed(); + + return true; } - protected virtual void ShowAtCore(Control placementTarget, bool showAtPointer = false) + /// True, if action was handled + protected virtual bool ShowAtCore(Control placementTarget, bool showAtPointer = false) { if (placementTarget == null) - throw new ArgumentNullException("placementTarget cannot be null"); - - if (Popup == null) { - InitPopup(); + throw new ArgumentNullException(nameof(placementTarget)); } if (IsOpen) { if (placementTarget == Target) { - return; + return false; } else // Close before opening a new one { - HideCore(false); + _ = HideCore(false); } } if (CancelOpening()) { - return; + return false; } if (Popup.Parent != null && Popup.Parent != placementTarget) @@ -212,11 +228,13 @@ namespace Avalonia.Controls.Primitives Popup.Child = CreatePresenter(); } - OnOpening(); PositionPopup(showAtPointer); - IsOpen = Popup.IsOpen = true; + IsOpen = Popup.IsOpen = true; OnOpened(); - + + placementTarget.DetachedFromVisualTree += PlacementTarget_DetachedFromVisualTree; + placementTarget.KeyUp += OnPlacementTargetOrPopupKeyUp; + if (ShowMode == FlyoutShowMode.Standard) { // Try and focus content inside Flyout @@ -237,6 +255,13 @@ namespace Avalonia.Controls.Primitives { _transientDisposable = InputManager.Instance?.Process.Subscribe(HandleTransientDismiss); } + + return true; + } + + private void PlacementTarget_DetachedFromVisualTree(object sender, VisualTreeAttachmentEventArgs e) + { + _ = HideCore(false); } private void HandleTransientDismiss(RawInputEventArgs args) @@ -255,7 +280,7 @@ namespace Avalonia.Controls.Primitives { // Only do this once when the Flyout opens & cache the result if (Popup?.Host is PopupRoot root) - { + { // Get the popup root bounds and convert to screen coordinates var tmp = root.Bounds.Inflate(100); @@ -295,9 +320,9 @@ namespace Avalonia.Controls.Primitives } } - protected virtual void OnOpening() + protected virtual void OnOpening(CancelEventArgs args) { - Opening?.Invoke(this, null); + Opening?.Invoke(this, args); } protected virtual void OnOpened() @@ -321,15 +346,18 @@ namespace Avalonia.Controls.Primitives /// protected abstract Control CreatePresenter(); - private void InitPopup() + private Popup CreatePopup() { - Popup = new Popup(); - Popup.WindowManagerAddShadowHint = false; - Popup.IsLightDismissEnabled = true; - - Popup.Opened += OnPopupOpened; - Popup.Closed += OnPopupClosed; - Popup.Closing += OnPopupClosing; + var popup = new Popup(); + popup.WindowManagerAddShadowHint = false; + popup.IsLightDismissEnabled = true; + popup.OverlayDismissEventPassThrough = true; + + popup.Opened += OnPopupOpened; + popup.Closed += OnPopupClosed; + popup.Closing += OnPopupClosing; + popup.KeyUp += OnPlacementTargetOrPopupKeyUp; + return popup; } private void OnPopupOpened(object sender, EventArgs e) @@ -339,7 +367,10 @@ namespace Avalonia.Controls.Primitives private void OnPopupClosing(object sender, CancelEventArgs e) { - e.Cancel = CancelClosing(); + if (IsOpen) + { + e.Cancel = CancelClosing(); + } } private void OnPopupClosed(object sender, EventArgs e) @@ -347,10 +378,27 @@ namespace Avalonia.Controls.Primitives HideCore(false); } + // This method is handling both popup logical tree and target logical tree. + private void OnPlacementTargetOrPopupKeyUp(object sender, KeyEventArgs e) + { + if (!e.Handled + && IsOpen + && Target?.ContextFlyout == this) + { + var keymap = AvaloniaLocator.Current.GetService(); + + if (keymap.OpenContextMenu.Any(k => k.Matches(e))) + { + e.Handled = HideCore(); + } + } + } + private void PositionPopup(bool showAtPointer) { Size sz; - if(Popup.Child.DesiredSize == Size.Empty) + // Popup.Child can't be null here, it was set in ShowAtCore. + if (Popup.Child!.DesiredSize == Size.Empty) { // Popup may not have been shown yet. Measure content sz = LayoutHelper.MeasureChild(Popup.Child, Size.Infinity, new Thickness()); @@ -377,19 +425,19 @@ namespace Avalonia.Controls.Primitives switch (Placement) { case FlyoutPlacementMode.Top: //Above & centered - Popup.PlacementRect = new Rect(0, 0, trgtBnds.Width-1, 1); + Popup.PlacementRect = new Rect(0, 0, trgtBnds.Width - 1, 1); Popup.PlacementGravity = PopupPositioning.PopupGravity.Top; Popup.PlacementAnchor = PopupPositioning.PopupAnchor.Top; break; case FlyoutPlacementMode.TopEdgeAlignedLeft: Popup.PlacementRect = new Rect(0, 0, 0, 0); - Popup.PlacementGravity = PopupPositioning.PopupGravity.TopRight; + Popup.PlacementGravity = PopupPositioning.PopupGravity.TopRight; break; case FlyoutPlacementMode.TopEdgeAlignedRight: Popup.PlacementRect = new Rect(trgtBnds.Width - 1, 0, 10, 1); - Popup.PlacementGravity = PopupPositioning.PopupGravity.TopLeft; + Popup.PlacementGravity = PopupPositioning.PopupGravity.TopLeft; break; case FlyoutPlacementMode.RightEdgeAlignedTop: @@ -461,46 +509,44 @@ namespace Avalonia.Controls.Primitives { if (args.OldValue is FlyoutBase) { - c.PointerReleased -= OnControlWithContextFlyoutPointerReleased; + c.ContextRequested -= OnControlContextRequested; } if (args.NewValue is FlyoutBase) { - c.PointerReleased += OnControlWithContextFlyoutPointerReleased; + c.ContextRequested += OnControlContextRequested; } } } - private static void OnControlWithContextFlyoutPointerReleased(object sender, PointerReleasedEventArgs e) + private static void OnControlContextRequested(object sender, ContextRequestedEventArgs e) { - if (sender is Control c) + var control = (Control)sender; + if (!e.Handled + && control.ContextFlyout is FlyoutBase flyout) { - if (e.InitialPressMouseButton == MouseButton.Right && - e.GetCurrentPoint(c).Properties.PointerUpdateKind == PointerUpdateKind.RightButtonReleased) + if (control.ContextMenu != null) { - if (c.ContextFlyout != null) - { - if (c.ContextMenu != null) - { - Logger.TryGet(LogEventLevel.Verbose, "FlyoutBase")?.Log(c, "ContextMenu and ContextFlyout are both set, defaulting to ContextMenu"); - return; - } - c.ContextFlyout.ShowAt(c, true); - } + Logger.TryGet(LogEventLevel.Verbose, "FlyoutBase")?.Log(control, "ContextMenu and ContextFlyout are both set, defaulting to ContextMenu"); + return; } - } + + // We do not support absolute popup positioning yet, so we ignore "point" at this moment. + var triggeredByPointerInput = e.TryGetPosition(null, out _); + e.Handled = flyout.ShowAtCore(control, triggeredByPointerInput); + } } private bool CancelClosing() { var eventArgs = new CancelEventArgs(); - Closing?.Invoke(this, eventArgs); + OnClosing(eventArgs); return eventArgs.Cancel; } private bool CancelOpening() { var eventArgs = new CancelEventArgs(); - Opening?.Invoke(this, eventArgs); + OnOpening(eventArgs); return eventArgs.Cancel; } diff --git a/src/Avalonia.Controls/Flyouts/FlyoutShowMode.cs b/src/Avalonia.Controls/Flyouts/FlyoutShowMode.cs index cb5b5f00c0..fe496619fd 100644 --- a/src/Avalonia.Controls/Flyouts/FlyoutShowMode.cs +++ b/src/Avalonia.Controls/Flyouts/FlyoutShowMode.cs @@ -12,7 +12,7 @@ Standard, /// - /// Behavior is typical of a flyout shown proactively. The open flyout does not take focus. For a CommandBarFlyout, it opens in it's collapsed state. + /// Behavior is typical of a flyout shown proactively. The open flyout does not take focus. /// Transient, diff --git a/src/Avalonia.Controls/Flyouts/MenuFlyoutPresenter.cs b/src/Avalonia.Controls/Flyouts/MenuFlyoutPresenter.cs index 92f414631a..3a45c85c70 100644 --- a/src/Avalonia.Controls/Flyouts/MenuFlyoutPresenter.cs +++ b/src/Avalonia.Controls/Flyouts/MenuFlyoutPresenter.cs @@ -29,16 +29,8 @@ namespace Avalonia.Controls var host = this.FindLogicalAncestorOfType(); if (host != null) { - for (int i = 0; i < LogicalChildren.Count; i++) - { - if (LogicalChildren[i] is MenuItem item) - { - item.IsSubMenuOpen = false; - } - } - SelectedIndex = -1; - host.IsOpen = false; + host.IsOpen = false; } } diff --git a/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs b/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs index 0dce73180e..336c414270 100644 --- a/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs +++ b/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs @@ -1,10 +1,18 @@ using System; +using System.ComponentModel; using System.Linq; + +using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Markup.Xaml; using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering; using Avalonia.UnitTests; using Avalonia.VisualTree; + +using Moq; + using Xunit; namespace Avalonia.Controls.UnitTests @@ -28,6 +36,7 @@ namespace Avalonia.Controls.UnitTests f.ShowAt(window); Assert.Equal(1, tracker); + Assert.True(f.IsOpen); } } @@ -51,6 +60,31 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void Opening_Is_Cancellable() + { + using (CreateServicesWithFocus()) + { + var window = PreparedWindow(); + window.Show(); + + int tracker = 0; + Flyout f = new Flyout(); + f.Opening += (s, e) => + { + tracker++; + if (e is CancelEventArgs cancelEventArgs) + { + cancelEventArgs.Cancel = true; + } + }; + f.ShowAt(window); + + Assert.Equal(1, tracker); + Assert.False(f.IsOpen); + } + } + [Fact] public void Closing_Raises_Single_Closing_Event() { @@ -101,16 +135,89 @@ namespace Avalonia.Controls.UnitTests var window = PreparedWindow(); window.Show(); - int tracker = 0; - Flyout f = new Flyout(); + var tracker = 0; + var f = new Flyout(); f.Closing += (s, e) => { + tracker++; e.Cancel = true; }; f.ShowAt(window); f.Hide(); Assert.True(f.IsOpen); + Assert.Equal(1, tracker); + } + } + + [Fact] + public void Cancel_Light_Dismiss_Closing_Keeps_Flyout_Open() + { + using (CreateServicesWithFocus()) + { + var window = PreparedWindow(); + window.Width = 100; + window.Height = 100; + + var button = new Button + { + Height = 10, + Width = 10, + HorizontalAlignment = Layout.HorizontalAlignment.Left, + VerticalAlignment = Layout.VerticalAlignment.Top + }; + window.Content = button; + + window.Show(); + + var tracker = 0; + var f = new Flyout(); + f.Content = new Border { Width = 10, Height = 10 }; + f.Closing += (s, e) => + { + tracker++; + e.Cancel = true; + }; + f.ShowAt(window); + + var e = CreatePointerPressedEventArgs(window, new Point(90, 90)); + var overlay = LightDismissOverlayLayer.GetLightDismissOverlayLayer(window); + overlay.RaiseEvent(e); + + Assert.Equal(1, tracker); + Assert.True(f.IsOpen); + } + } + + [Fact] + public void Light_Dismiss_Closes_Flyout() + { + using (CreateServicesWithFocus()) + { + var window = PreparedWindow(); + window.Width = 100; + window.Height = 100; + + var button = new Button + { + Height = 10, + Width = 10, + HorizontalAlignment = Layout.HorizontalAlignment.Left, + VerticalAlignment = Layout.VerticalAlignment.Top + }; + window.Content = button; + + window.Show(); + + var f = new Flyout(); + f.Content = new Border { Width = 10, Height = 10 }; + f.ShowAt(window); + + var e = CreatePointerPressedEventArgs(window, new Point(90, 90)); + var overlay = LightDismissOverlayLayer.GetLightDismissOverlayLayer(window); + overlay.RaiseEvent(e); + + Assert.False(f.IsOpen); } } @@ -317,9 +424,27 @@ namespace Avalonia.Controls.UnitTests private Window PreparedWindow(object content = null) { - var w = new Window { Content = content }; + var renderer = new Mock(); + var platform = AvaloniaLocator.Current.GetService(); + var windowImpl = Mock.Get(platform.CreateWindow()); + windowImpl.Setup(x => x.CreateRenderer(It.IsAny())).Returns(renderer.Object); + + var w = new Window(windowImpl.Object) { Content = content }; w.ApplyTemplate(); return w; } + + private PointerPressedEventArgs CreatePointerPressedEventArgs(Window source, Point p) + { + var pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Mouse, true); + return new PointerPressedEventArgs( + source, + pointer, + source, + p, + 0, + new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.LeftButtonPressed), + KeyModifiers.None); + } } }