Browse Source

Use ContextRequested event to show ContextFlyout + some refactoring of FlyoutBase

Max Katz 4 years ago
parent
commit
800788be20

+ 95 - 49
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<FlyoutBase?> AttachedFlyoutProperty =
             AvaloniaProperty.RegisterAttached<FlyoutBase, Control, FlyoutBase?>("AttachedFlyout", null);
 
+        private readonly Lazy<Popup> _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<Popup>(() => CreatePopup());
+        }
+
+        protected Popup Popup => _popupLazy.Value;
 
         /// <summary>
         /// Gets whether this Flyout is currently Open
@@ -142,18 +151,19 @@ namespace Avalonia.Controls.Primitives
             HideCore();
         }
 
-        protected virtual void HideCore(bool canCancel = true)
+        /// <returns>True, if action was handled</returns>
+        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)
+        /// <returns>True, if action was handled</returns>
+        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
         /// <returns></returns>
         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<PlatformHotkeyConfiguration>();
+
+                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;
         }
 

+ 1 - 1
src/Avalonia.Controls/Flyouts/FlyoutShowMode.cs

@@ -12,7 +12,7 @@
         Standard,
 
         /// <summary>
-        /// 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.
         /// </summary>
         Transient,
 

+ 1 - 9
src/Avalonia.Controls/Flyouts/MenuFlyoutPresenter.cs

@@ -29,16 +29,8 @@ namespace Avalonia.Controls
             var host = this.FindLogicalAncestorOfType<Popup>();
             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;
             }
         }
 

+ 128 - 3
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<IRenderer>();
+            var platform = AvaloniaLocator.Current.GetService<IWindowingPlatform>();
+            var windowImpl = Mock.Get(platform.CreateWindow());
+            windowImpl.Setup(x => x.CreateRenderer(It.IsAny<IRenderRoot>())).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);
+        }
     }
 }