using System; using System.ComponentModel; using Avalonia.Controls.Diagnostics; using System.Linq; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Input.Raw; using Avalonia.Layout; using Avalonia.Logging; namespace Avalonia.Controls.Primitives { public abstract class FlyoutBase : AvaloniaObject, IPopupHostProvider { /// /// Defines the property /// public static readonly DirectProperty IsOpenProperty = AvaloniaProperty.RegisterDirect(nameof(IsOpen), x => x.IsOpen); /// /// Defines the property /// public static readonly DirectProperty TargetProperty = AvaloniaProperty.RegisterDirect(nameof(Target), x => x.Target); /// /// Defines the property /// public static readonly StyledProperty PlacementProperty = AvaloniaProperty.Register(nameof(Placement)); /// /// Defines the property /// public static readonly DirectProperty ShowModeProperty = AvaloniaProperty.RegisterDirect(nameof(ShowMode), x => x.ShowMode, (x, v) => x.ShowMode = v); /// /// Defines the property /// public static readonly DirectProperty OverlayInputPassThroughElementProperty = Popup.OverlayInputPassThroughElementProperty.AddOwner( o => o._overlayInputPassThroughElement, (o, v) => o._overlayInputPassThroughElement = v); /// /// Defines the AttachedFlyout property /// public static readonly AttachedProperty AttachedFlyoutProperty = AvaloniaProperty.RegisterAttached("AttachedFlyout", null); private readonly Lazy _popupLazy; private bool _isOpen; private Control? _target; private FlyoutShowMode _showMode = FlyoutShowMode.Standard; private Rect? _enlargedPopupRect; private PixelRect? _enlargePopupRectScreenPixelRect; private IDisposable? _transientDisposable; private Action? _popupHostChangedHandler; private IInputElement? _overlayInputPassThroughElement; static FlyoutBase() { Control.ContextFlyoutProperty.Changed.Subscribe(OnContextFlyoutPropertyChanged); } public FlyoutBase() { _popupLazy = new Lazy(() => CreatePopup()); } protected Popup Popup => _popupLazy.Value; /// /// Gets whether this Flyout is currently Open /// public bool IsOpen { get => _isOpen; private set => SetAndRaise(IsOpenProperty, ref _isOpen, value); } /// /// Gets or sets the desired placement /// public FlyoutPlacementMode Placement { get => GetValue(PlacementProperty); set => SetValue(PlacementProperty, value); } /// /// Gets or sets the desired ShowMode /// public FlyoutShowMode ShowMode { get => _showMode; set => SetAndRaise(ShowModeProperty, ref _showMode, value); } /// /// Gets the Target used for showing the Flyout /// public Control? Target { get => _target; private set => SetAndRaise(TargetProperty, ref _target, value); } /// /// Gets or sets an element that should receive pointer input events even when underneath /// the flyout's overlay. /// public IInputElement? OverlayInputPassThroughElement { get => _overlayInputPassThroughElement; set => SetAndRaise(OverlayInputPassThroughElementProperty, ref _overlayInputPassThroughElement, value); } IPopupHost? IPopupHostProvider.PopupHost => Popup?.Host; event Action? IPopupHostProvider.PopupHostChanged { add => _popupHostChangedHandler += value; remove => _popupHostChangedHandler -= value; } public event EventHandler? Closed; public event EventHandler? Closing; public event EventHandler? Opened; public event EventHandler? Opening; public static FlyoutBase? GetAttachedFlyout(Control element) { return element.GetValue(AttachedFlyoutProperty); } public static void SetAttachedFlyout(Control element, FlyoutBase? value) { element.SetValue(AttachedFlyoutProperty, value); } public static void ShowAttachedFlyout(Control flyoutOwner) { var flyout = GetAttachedFlyout(flyoutOwner); flyout?.ShowAt(flyoutOwner); } /// /// Shows the Flyout at the given Control /// /// The control to show the Flyout at public void ShowAt(Control placementTarget) { ShowAtCore(placementTarget); } /// /// Shows the Flyout for the given control at the current pointer location, as in a ContextFlyout /// /// The target control /// True to show at pointer public void ShowAt(Control placementTarget, bool showAtPointer) { ShowAtCore(placementTarget, showAtPointer); } /// /// Hides the Flyout /// public void Hide() { HideCore(); } /// True, if action was handled protected virtual bool HideCore(bool canCancel = true) { if (!IsOpen) { return false; } if (canCancel) { if (CancelClosing()) { return false; } } IsOpen = false; Popup.IsOpen = false; ((ISetLogicalParent)Popup).SetParent(null); // Ensure this isn't active _transientDisposable?.Dispose(); _transientDisposable = null; _enlargedPopupRect = null; _enlargePopupRectScreenPixelRect = null; if (Target != null) { Target.DetachedFromVisualTree -= PlacementTarget_DetachedFromVisualTree; Target.KeyUp -= OnPlacementTargetOrPopupKeyUp; } OnClosed(); return true; } /// True, if action was handled protected virtual bool ShowAtCore(Control placementTarget, bool showAtPointer = false) { if (placementTarget == null) { throw new ArgumentNullException(nameof(placementTarget)); } if (IsOpen) { if (placementTarget == Target) { return false; } else // Close before opening a new one { _ = HideCore(false); } } if (Popup.Parent != null && Popup.Parent != placementTarget) { ((ISetLogicalParent)Popup).SetParent(null); } if (Popup.Parent == null || Popup.PlacementTarget != placementTarget) { Popup.PlacementTarget = Target = placementTarget; ((ISetLogicalParent)Popup).SetParent(placementTarget); Popup.SetValue(StyledElement.TemplatedParentProperty, placementTarget.TemplatedParent); } if (Popup.Child == null) { Popup.Child = CreatePresenter(); } Popup.OverlayInputPassThroughElement = OverlayInputPassThroughElement; if (CancelOpening()) { return false; } PositionPopup(showAtPointer); IsOpen = Popup.IsOpen = true; OnOpened(); placementTarget.DetachedFromVisualTree += PlacementTarget_DetachedFromVisualTree; placementTarget.KeyUp += OnPlacementTargetOrPopupKeyUp; if (ShowMode == FlyoutShowMode.Standard) { // Try and focus content inside Flyout if (Popup.Child.Focusable) { FocusManager.Instance?.Focus(Popup.Child); } else { var nextFocus = KeyboardNavigationHandler.GetNext(Popup.Child, NavigationDirection.Next); if (nextFocus != null) { FocusManager.Instance?.Focus(nextFocus); } } } else if (ShowMode == FlyoutShowMode.TransientWithDismissOnPointerMoveAway) { _transientDisposable = InputManager.Instance?.Process.Subscribe(HandleTransientDismiss); } return true; } private void PlacementTarget_DetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _ = HideCore(false); } private void HandleTransientDismiss(RawInputEventArgs args) { if (args is RawPointerEventArgs pArgs && pArgs.Type == RawPointerEventType.Move) { // In ShowMode = TransientWithDismissOnPointerMoveAway, the Flyout is kept // shown as long as the pointer is within a certain px distance from the // flyout itself. I'm not sure what WinUI uses, but I'm defaulting to // 100px, which seems about right // enlargedPopupRect is the Flyout bounds enlarged 100px // For windowed popups, enlargedPopupRect is in screen coordinates, // for overlay popups, its in OverlayLayer coordinates if (_enlargedPopupRect == null && _enlargePopupRectScreenPixelRect == null) { // 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); _enlargePopupRectScreenPixelRect = new PixelRect(root.PointToScreen(tmp.TopLeft), root.PointToScreen(tmp.BottomRight)); } else if (Popup?.Host is OverlayPopupHost host) { // Overlay popups are in OverlayLayer coordinates, just use that _enlargedPopupRect = host.Bounds.Inflate(100); } return; } if (Popup?.Host is PopupRoot) { // As long as the pointer stays within the enlargedPopupRect // the flyout stays open. If it leaves, close it // Despite working in screen coordinates, leaving the TopLevel // window will not close this (as pointer events stop), which // does match UWP var pt = pArgs.Root.PointToScreen(pArgs.Position); if (!_enlargePopupRectScreenPixelRect?.Contains(pt) ?? false) { HideCore(false); } } else if (Popup?.Host is OverlayPopupHost) { // Same as above here, but just different coordinate space // so we don't need to translate if (!_enlargedPopupRect?.Contains(pArgs.Position) ?? false) { HideCore(false); } } } } protected virtual void OnOpening(CancelEventArgs args) { Opening?.Invoke(this, args); } protected virtual void OnOpened() { Opened?.Invoke(this, EventArgs.Empty); } protected virtual void OnClosing(CancelEventArgs args) { Closing?.Invoke(this, args); } protected virtual void OnClosed() { Closed?.Invoke(this, EventArgs.Empty); } /// /// Used to create the content the Flyout displays /// /// protected abstract Control CreatePresenter(); private Popup CreatePopup() { var popup = new Popup { WindowManagerAddShadowHint = false, IsLightDismissEnabled = true, //Note: This is required to prevent Button.Flyout from opening the flyout again after dismiss. OverlayDismissEventPassThrough = false }; popup.Opened += OnPopupOpened; popup.Closed += OnPopupClosed; popup.Closing += OnPopupClosing; popup.KeyUp += OnPlacementTargetOrPopupKeyUp; return popup; } private void OnPopupOpened(object? sender, EventArgs e) { IsOpen = true; _popupHostChangedHandler?.Invoke(Popup.Host); } private void OnPopupClosing(object? sender, CancelEventArgs e) { if (IsOpen) { e.Cancel = CancelClosing(); } } private void OnPopupClosed(object? sender, EventArgs e) { HideCore(false); _popupHostChangedHandler?.Invoke(null); } // 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)) == true) { e.Handled = HideCore(); } } } private void PositionPopup(bool showAtPointer) { Size sz; // 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()); } else { sz = Popup.Child.DesiredSize; } if (showAtPointer) { Popup.PlacementMode = PlacementMode.Pointer; } else { Popup.PlacementMode = PlacementMode.AnchorAndGravity; Popup.PlacementConstraintAdjustment = PopupPositioning.PopupPositionerConstraintAdjustment.SlideX | PopupPositioning.PopupPositionerConstraintAdjustment.SlideY; } var trgtBnds = Target?.Bounds ?? Rect.Empty; switch (Placement) { case FlyoutPlacementMode.Top: //Above & centered 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; break; case FlyoutPlacementMode.TopEdgeAlignedRight: Popup.PlacementRect = new Rect(trgtBnds.Width - 1, 0, 10, 1); Popup.PlacementGravity = PopupPositioning.PopupGravity.TopLeft; break; case FlyoutPlacementMode.RightEdgeAlignedTop: Popup.PlacementRect = new Rect(trgtBnds.Width - 1, 0, 1, 1); Popup.PlacementGravity = PopupPositioning.PopupGravity.BottomRight; Popup.PlacementAnchor = PopupPositioning.PopupAnchor.Right; break; case FlyoutPlacementMode.Right: //Right & centered Popup.PlacementRect = new Rect(trgtBnds.Width - 1, 0, 1, trgtBnds.Height); Popup.PlacementGravity = PopupPositioning.PopupGravity.Right; Popup.PlacementAnchor = PopupPositioning.PopupAnchor.Right; break; case FlyoutPlacementMode.RightEdgeAlignedBottom: Popup.PlacementRect = new Rect(trgtBnds.Width - 1, trgtBnds.Height - 1, 1, 1); Popup.PlacementGravity = PopupPositioning.PopupGravity.TopRight; Popup.PlacementAnchor = PopupPositioning.PopupAnchor.Right; break; case FlyoutPlacementMode.Bottom: //Below & centered Popup.PlacementRect = new Rect(0, trgtBnds.Height - 1, trgtBnds.Width, 1); Popup.PlacementGravity = PopupPositioning.PopupGravity.Bottom; Popup.PlacementAnchor = PopupPositioning.PopupAnchor.Bottom; break; case FlyoutPlacementMode.BottomEdgeAlignedLeft: Popup.PlacementRect = new Rect(0, trgtBnds.Height - 1, 1, 1); Popup.PlacementGravity = PopupPositioning.PopupGravity.BottomRight; Popup.PlacementAnchor = PopupPositioning.PopupAnchor.Bottom; break; case FlyoutPlacementMode.BottomEdgeAlignedRight: Popup.PlacementRect = new Rect(trgtBnds.Width - 1, trgtBnds.Height - 1, 1, 1); Popup.PlacementGravity = PopupPositioning.PopupGravity.BottomLeft; Popup.PlacementAnchor = PopupPositioning.PopupAnchor.Bottom; break; case FlyoutPlacementMode.LeftEdgeAlignedTop: Popup.PlacementRect = new Rect(0, 0, 1, 1); Popup.PlacementGravity = PopupPositioning.PopupGravity.BottomLeft; Popup.PlacementAnchor = PopupPositioning.PopupAnchor.Left; break; case FlyoutPlacementMode.Left: //Left & centered Popup.PlacementRect = new Rect(0, 0, 1, trgtBnds.Height); Popup.PlacementGravity = PopupPositioning.PopupGravity.Left; Popup.PlacementAnchor = PopupPositioning.PopupAnchor.Left; break; case FlyoutPlacementMode.LeftEdgeAlignedBottom: Popup.PlacementRect = new Rect(0, trgtBnds.Height - 1, 1, 1); Popup.PlacementGravity = PopupPositioning.PopupGravity.TopLeft; Popup.PlacementAnchor = PopupPositioning.PopupAnchor.BottomLeft; break; //includes Auto (not sure what determines that)... default: //This is just FlyoutPlacementMode.Top behavior (above & centered) Popup.PlacementRect = new Rect(-sz.Width / 2, 0, sz.Width, 1); Popup.PlacementGravity = PopupPositioning.PopupGravity.Top; break; } } private static void OnContextFlyoutPropertyChanged(AvaloniaPropertyChangedEventArgs args) { if (args.Sender is Control c) { if (args.OldValue is FlyoutBase) { c.ContextRequested -= OnControlContextRequested; } if (args.NewValue is FlyoutBase) { c.ContextRequested += OnControlContextRequested; } } } private static void OnControlContextRequested(object? sender, ContextRequestedEventArgs e) { if (!e.Handled && sender is Control control && control.ContextFlyout is FlyoutBase flyout) { if (control.ContextMenu != null) { 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(); OnClosing(eventArgs); return eventArgs.Cancel; } private bool CancelOpening() { var eventArgs = new CancelEventArgs(); OnOpening(eventArgs); return eventArgs.Cancel; } internal static void SetPresenterClasses(IControl? presenter, Classes classes) { if(presenter is null) { return; } //Remove any classes no longer in use, ignoring pseudo classes for (int i = presenter.Classes.Count - 1; i >= 0; i--) { if (!classes.Contains(presenter.Classes[i]) && !presenter.Classes[i].Contains(":")) { presenter.Classes.RemoveAt(i); } } //Add new classes presenter.Classes.AddRange(classes); } } }