diff --git a/src/Avalonia.Controls/Flyouts/Flyout.cs b/src/Avalonia.Controls/Flyouts/Flyout.cs new file mode 100644 index 0000000000..723a5e84f8 --- /dev/null +++ b/src/Avalonia.Controls/Flyouts/Flyout.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Text; +using Avalonia.Controls.Primitives; +using Avalonia.Styling; + +#nullable enable + +namespace Avalonia.Controls +{ + public class Flyout : FlyoutBase + { + public static readonly StyledProperty ContentProperty = + AvaloniaProperty.Register(nameof(Content)); + + public Styles? FlyoutPresenterStyle + { + get + { + if (_styles == null) + { + _styles = new Styles(); + _styles.CollectionChanged += OnFlyoutPresenterStylesChanged; + } + + return _styles; + } + } + + private Styles? _styles; + private bool _stylesDirty; + + public object Content + { + get => GetValue(ContentProperty); + set => SetValue(ContentProperty, value); + } + + protected override Control CreatePresenter() + { + return new FlyoutPresenter + { + [!ContentControl.ContentProperty] = this[!ContentProperty] + }; + } + + protected override void OnOpened() + { + if (_styles != null && _stylesDirty) + { + // Presenter for flyout generally shouldn't be public, so + // we should be ok to just reset the styles + _popup.Child.Styles.Clear(); + _popup.Child.Styles.Add(_styles); + } + base.OnOpened(); + } + + private void OnFlyoutPresenterStylesChanged(object sender, NotifyCollectionChangedEventArgs e) + { + _stylesDirty = true; + } + } +} diff --git a/src/Avalonia.Controls/Flyouts/FlyoutBase.cs b/src/Avalonia.Controls/Flyouts/FlyoutBase.cs new file mode 100644 index 0000000000..64ab07a658 --- /dev/null +++ b/src/Avalonia.Controls/Flyouts/FlyoutBase.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Text; +using Avalonia.Layout; + +#nullable enable + +namespace Avalonia.Controls.Primitives +{ + public abstract class FlyoutBase : AvaloniaObject + { + private static readonly DirectProperty IsOpenProperty = + AvaloniaProperty.RegisterDirect(nameof(IsOpen), + x => x.IsOpen); + + public static readonly DirectProperty TargetProperty = + AvaloniaProperty.RegisterDirect(nameof(Target), x => x.Target); + + public static readonly DirectProperty PlacementProperty = + AvaloniaProperty.RegisterDirect(nameof(Placement), + x => x.Placement, (x, v) => x.Placement = v); + + public static readonly AttachedProperty AttachedFlyoutProperty = + AvaloniaProperty.RegisterAttached("AttachedFlyout", null); + + private bool _isOpen; + private Control? _target; + protected Popup? _popup; + + public bool IsOpen + { + get => _isOpen; + private set => SetAndRaise(IsOpenProperty, ref _isOpen, value); + } + + public FlyoutPlacementMode Placement + { + get => GetValue(PlacementProperty); + set => SetValue(PlacementProperty, value); + } + + public Control? Target + { + get => _target; + private set => SetAndRaise(TargetProperty, ref _target, 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); + } + + public void ShowAt(Control placementTarget) + { + ShowAtCore(placementTarget); + } + + public void ShowAt(Control placementTarget, bool showAtPointer) + { + ShowAtCore(placementTarget, showAtPointer); + } + + public void Hide(bool canCancel = true) + { + if (!IsOpen) + { + return; + } + + if (canCancel) + { + bool cancel = false; + + var closing = new CancelEventArgs(); + Closing?.Invoke(this, closing); + if (cancel || closing.Cancel) + { + return; + } + } + + IsOpen = _popup.IsOpen = false; + + OnClosed(); + } + + protected virtual void ShowAtCore(Control placementTarget, bool showAtPointer = false) + { + if (placementTarget == null) + throw new ArgumentNullException("placementTarget cannot be null"); + + if (_popup == null) + { + InitPopup(); + } + + if (IsOpen) + { + if (placementTarget == Target) + { + return; + } + else // Close before opening a new one + { + Hide(false); + } + } + + if (_popup.Parent != null && _popup.Parent != placementTarget) + { + ((ISetLogicalParent)_popup).SetParent(null); + } + + _popup.PlacementTarget = Target = placementTarget; + + ((ISetLogicalParent)_popup).SetParent(placementTarget); + + if (_popup.Child == null) + { + _popup.Child = CreatePresenter(); + } + + OnOpening(); + IsOpen = _popup.IsOpen = true; + PositionPopup(showAtPointer); + OnOpened(); + } + + protected virtual void OnOpening() + { + Opening?.Invoke(this, null); + } + + protected virtual void OnOpened() + { + Opened?.Invoke(this, null); + } + + protected virtual void OnClosing(CancelEventArgs args) + { + Closing?.Invoke(this, args); + } + + protected virtual void OnClosed() + { + Closed?.Invoke(this, null); + } + + protected abstract Control CreatePresenter(); + + private void InitPopup() + { + _popup = new Popup(); + _popup.WindowManagerAddShadowHint = false; + _popup.IsLightDismissEnabled = true; + + _popup.Opened += OnPopupOpened; + _popup.Closed += OnPopupClosed; + } + + private void OnPopupOpened(object sender, EventArgs e) + { + IsOpen = true; + OnOpened(); + } + + private void OnPopupClosed(object sender, EventArgs e) + { + Hide(); + } + + private void PositionPopup(bool showAtPointer) + { + Size sz; + if(_popup.DesiredSize == Size.Empty) + { + sz = LayoutHelper.MeasureChild(_popup, Size.Infinity, new Thickness()); + } + else + { + sz = _popup.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(-sz.Width / 2, 0, sz.Width, 1); + _popup.PlacementGravity = PopupPositioning.PopupGravity.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; + + case FlyoutPlacementMode.Full: + //Not sure how the get this to work + //Popup should display at max size in the middle of the VisualRoot/Window of the Target + throw new NotSupportedException("FlyoutPlacementMode.Full is not supported at this time"); + //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; + } + } + } +} diff --git a/src/Avalonia.Controls/Flyouts/FlyoutPlacementMode.cs b/src/Avalonia.Controls/Flyouts/FlyoutPlacementMode.cs new file mode 100644 index 0000000000..15987daa50 --- /dev/null +++ b/src/Avalonia.Controls/Flyouts/FlyoutPlacementMode.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Avalonia.Controls +{ + public enum FlyoutPlacementMode + { + /// + /// Preferred location is above the target element + /// + Top = 0, + + /// + /// Preferred location is below the target element + /// + Bottom = 1, + + /// + /// Preferred location is to the left of the target element + /// + Left = 2, + + /// + /// Preferred location is to the right of the target element + /// + Right = 3, + + /// + /// Preferred location is centered on the screen + /// + Full = 4, + + /// + /// Preferred location is above the target element, with the left edge of the flyout + /// aligned with the left edge of the target element + /// + TopEdgeAlignedLeft = 5, + + /// + /// Preferred location is above the target element, with the right edge of flyout aligned with right edge of the target element. + /// + TopEdgeAlignedRight = 6, + + /// + /// Preferred location is below the target element, with the left edge of flyout aligned with left edge of the target element. + /// + BottomEdgeAlignedLeft = 7, + + /// + /// Preferred location is below the target element, with the right edge of flyout aligned with right edge of the target element. + /// + BottomEdgeAlignedRight = 8, + + /// + /// Preferred location is to the left of the target element, with the top edge of flyout aligned with top edge of the target element. + /// + LeftEdgeAlignedTop = 9, + + /// + /// Preferred location is to the left of the target element, with the bottom edge of flyout aligned with bottom edge of the target element. + /// + LeftEdgeAlignedBottom = 10, + + /// + /// Preferred location is to the right of the target element, with the top edge of flyout aligned with top edge of the target element. + /// + RightEdgeAlignedTop = 11, + + /// + /// Preferred location is to the right of the target element, with the bottom edge of flyout aligned with bottom edge of the target element. + /// + RightEdgeAlignedBottom = 12, + + /// + /// Preferred location is determined automatically. + /// + Auto = 13 + } +} diff --git a/src/Avalonia.Controls/Flyouts/FlyoutPresenter.cs b/src/Avalonia.Controls/Flyouts/FlyoutPresenter.cs new file mode 100644 index 0000000000..80477d6969 --- /dev/null +++ b/src/Avalonia.Controls/Flyouts/FlyoutPresenter.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Avalonia.Controls +{ + public class FlyoutPresenter : ContentControl + { + } +} diff --git a/src/Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml b/src/Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml new file mode 100644 index 0000000000..2be49d007b --- /dev/null +++ b/src/Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml @@ -0,0 +1,39 @@ + + + +