diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index 6b79f6f33b..13b9724778 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -19,6 +19,12 @@ baseline/netstandard2.0/Avalonia.Base.dll target/netstandard2.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Controls.Primitives.IPopupHost.ConfigurePosition(Avalonia.Visual,Avalonia.Controls.PlacementMode,Avalonia.Point,Avalonia.Controls.Primitives.PopupPositioning.PopupAnchor,Avalonia.Controls.Primitives.PopupPositioning.PopupGravity,Avalonia.Controls.Primitives.PopupPositioning.PopupPositionerConstraintAdjustment,System.Nullable{Avalonia.Rect}) + baseline/netstandard2.0/Avalonia.Controls.dll + target/netstandard2.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Screens.#ctor(Avalonia.Platform.IScreenImpl) @@ -43,6 +49,12 @@ baseline/netstandard2.0/Avalonia.Controls.dll target/netstandard2.0/Avalonia.Controls.dll + + CP0006 + M:Avalonia.Controls.Primitives.IPopupHost.ConfigurePosition(Avalonia.Controls.Primitives.PopupPositioning.PopupPositionRequest) + baseline/netstandard2.0/Avalonia.Controls.dll + target/netstandard2.0/Avalonia.Controls.dll + CP0009 T:Avalonia.Controls.Screens diff --git a/samples/ControlCatalog/Pages/FlyoutsPage.axaml b/samples/ControlCatalog/Pages/FlyoutsPage.axaml index c0521cd3ba..64a005c987 100644 --- a/samples/ControlCatalog/Pages/FlyoutsPage.axaml +++ b/samples/ControlCatalog/Pages/FlyoutsPage.axaml @@ -222,7 +222,15 @@ - + @@ -267,7 +275,6 @@ - diff --git a/samples/ControlCatalog/Pages/FlyoutsPage.axaml.cs b/samples/ControlCatalog/Pages/FlyoutsPage.axaml.cs index 8944151385..5897eb2cde 100644 --- a/samples/ControlCatalog/Pages/FlyoutsPage.axaml.cs +++ b/samples/ControlCatalog/Pages/FlyoutsPage.axaml.cs @@ -1,5 +1,8 @@ +using System; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.Interactivity; namespace ControlCatalog.Pages @@ -71,5 +74,25 @@ namespace ControlCatalog.Pages "Then attach the flyout where you want it:\n" + " - Nested ToolTips - - ToolTip replaced on the fly - + diff --git a/samples/ControlCatalog/Pages/ToolTipPage.xaml.cs b/samples/ControlCatalog/Pages/ToolTipPage.xaml.cs index 0e8bf3a181..7efdb99078 100644 --- a/samples/ControlCatalog/Pages/ToolTipPage.xaml.cs +++ b/samples/ControlCatalog/Pages/ToolTipPage.xaml.cs @@ -1,5 +1,8 @@ +using System; +using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; +using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.Markup.Xaml; namespace ControlCatalog.Pages @@ -19,6 +22,27 @@ namespace ControlCatalog.Pages private void ToolTipOpening(object? sender, CancelRoutedEventArgs args) { ((Control)args.Source!).SetValue(ToolTip.TipProperty, "New tip set from ToolTipOpening."); - } + } + + public void CustomPlacementCallback(CustomPopupPlacement placement) + { + var r = new Random().Next(); + + placement.Anchor = (r % 4) switch + { + 1 => PopupAnchor.Top, + 2 => PopupAnchor.Left, + 3 => PopupAnchor.Right, + _ => PopupAnchor.Bottom, + }; + placement.Gravity = (r % 4) switch + { + 1 => PopupGravity.Top, + 2 => PopupGravity.Left, + 3 => PopupGravity.Right, + _ => PopupGravity.Bottom, + }; + placement.Offset = new Point(r % 20, r % 20); + } } } diff --git a/src/Avalonia.Controls/ContextMenu.cs b/src/Avalonia.Controls/ContextMenu.cs index cb64cc27e6..940528bf6f 100644 --- a/src/Avalonia.Controls/ContextMenu.cs +++ b/src/Avalonia.Controls/ContextMenu.cs @@ -82,6 +82,10 @@ namespace Avalonia.Controls public static readonly StyledProperty PlacementTargetProperty = Popup.PlacementTargetProperty.AddOwner(); + /// + public static readonly StyledProperty CustomPopupPlacementCallbackProperty = + Popup.CustomPopupPlacementCallbackProperty.AddOwner(); + private Popup? _popup; private List? _attachedControls; private IInputElement? _previousFocus; @@ -185,6 +189,13 @@ namespace Avalonia.Controls set => SetValue(PlacementTargetProperty, value); } + /// + public CustomPopupPlacementCallback? CustomPopupPlacementCallback + { + get => GetValue(CustomPopupPlacementCallbackProperty); + set => SetValue(CustomPopupPlacementCallbackProperty, value); + } + /// /// Occurs when the value of the /// @@ -340,6 +351,7 @@ namespace Avalonia.Controls _popup.PlacementConstraintAdjustment = PlacementConstraintAdjustment; _popup.PlacementGravity = PlacementGravity; _popup.PlacementRect = PlacementRect; + _popup.CustomPopupPlacementCallback = CustomPopupPlacementCallback; _popup.WindowManagerAddShadowHint = WindowManagerAddShadowHint; IsOpen = true; _popup.IsOpen = true; diff --git a/src/Avalonia.Controls/ContextRequestedEventArgs.cs b/src/Avalonia.Controls/ContextRequestedEventArgs.cs index ad5ffef267..fa0d5f9855 100644 --- a/src/Avalonia.Controls/ContextRequestedEventArgs.cs +++ b/src/Avalonia.Controls/ContextRequestedEventArgs.cs @@ -26,6 +26,13 @@ namespace Avalonia.Controls _pointerEventArgs = pointerEventArgs; } + /// + public ContextRequestedEventArgs(ContextRequestedEventArgs contextRequestedEventArgs) + : this() + { + _pointerEventArgs = contextRequestedEventArgs._pointerEventArgs; + } + /// /// Gets the x- and y-coordinates of the pointer position, optionally evaluated against a coordinate origin of a supplied . /// diff --git a/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs b/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs index 0c72d67154..82a243cd72 100644 --- a/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs +++ b/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs @@ -30,11 +30,15 @@ namespace Avalonia.Controls.Primitives /// public static readonly StyledProperty PlacementAnchorProperty = Popup.PlacementAnchorProperty.AddOwner(); - + /// public static readonly StyledProperty PlacementGravityProperty = Popup.PlacementGravityProperty.AddOwner(); + /// + public static readonly StyledProperty CustomPopupPlacementCallbackProperty = + Popup.CustomPopupPlacementCallbackProperty.AddOwner(); + /// /// Defines the property /// @@ -112,6 +116,13 @@ namespace Avalonia.Controls.Primitives set => SetValue(VerticalOffsetProperty, value); } + /// + public CustomPopupPlacementCallback? CustomPopupPlacementCallback + { + get => GetValue(CustomPopupPlacementCallbackProperty); + set => SetValue(CustomPopupPlacementCallbackProperty, value); + } + /// /// Gets or sets the desired ShowMode /// @@ -445,6 +456,7 @@ namespace Avalonia.Controls.Primitives Popup.HorizontalOffset = HorizontalOffset; Popup.PlacementAnchor = PlacementAnchor; Popup.PlacementGravity = PlacementGravity; + Popup.CustomPopupPlacementCallback = CustomPopupPlacementCallback; if (showAtPointer) { Popup.Placement = PlacementMode.Pointer; diff --git a/src/Avalonia.Controls/PlacementMode.cs b/src/Avalonia.Controls/PlacementMode.cs index 20e4e5470d..93116d8f1a 100644 --- a/src/Avalonia.Controls/PlacementMode.cs +++ b/src/Avalonia.Controls/PlacementMode.cs @@ -81,6 +81,11 @@ namespace Avalonia.Controls /// /// Preferred location is to the right of the target element, with the bottom edge of popup aligned with bottom edge of the target element. /// - RightEdgeAlignedBottom + RightEdgeAlignedBottom, + + /// + /// A position and repositioning behavior that is defined by the property. + /// + Custom } } diff --git a/src/Avalonia.Controls/Primitives/IPopupHost.cs b/src/Avalonia.Controls/Primitives/IPopupHost.cs index 0aad838b0f..4cb259db5f 100644 --- a/src/Avalonia.Controls/Primitives/IPopupHost.cs +++ b/src/Avalonia.Controls/Primitives/IPopupHost.cs @@ -1,6 +1,7 @@ using System; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives.PopupPositioning; +using Avalonia.Diagnostics; using Avalonia.Input; using Avalonia.Media; using Avalonia.Metadata; @@ -17,6 +18,7 @@ namespace Avalonia.Controls.Primitives /// on an . /// [NotClientImplementable] + [Unstable(ObsoletionMessages.MayBeRemovedInAvalonia12)] public interface IPopupHost : IDisposable, IFocusScope { /// @@ -79,20 +81,7 @@ namespace Avalonia.Controls.Primitives /// Configures the position of the popup according to a target control and a set of /// placement parameters. /// - /// The placement target. - /// The placement mode. - /// The offset, in device-independent pixels. - /// The anchor point. - /// The popup gravity. - /// Defines how a popup position will be adjusted if the unadjusted position would result in the popup being partly constrained. - /// - /// The anchor rect. If null, the bounds of will be used. - /// - void ConfigurePosition(Visual target, PlacementMode placement, Point offset, - PopupAnchor anchor = PopupAnchor.None, - PopupGravity gravity = PopupGravity.None, - PopupPositionerConstraintAdjustment constraintAdjustment = PopupPositionerConstraintAdjustment.All, - Rect? rect = null); + void ConfigurePosition(PopupPositionRequest positionRequest); /// /// Sets the control to display in the popup. diff --git a/src/Avalonia.Controls/Primitives/OverlayPopupHost.cs b/src/Avalonia.Controls/Primitives/OverlayPopupHost.cs index 43bb9b2947..3a602c15b7 100644 --- a/src/Avalonia.Controls/Primitives/OverlayPopupHost.cs +++ b/src/Avalonia.Controls/Primitives/OverlayPopupHost.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; using Avalonia.Controls.Primitives.PopupPositioning; +using Avalonia.Diagnostics; using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Metadata; -using Avalonia.Threading; using Avalonia.VisualTree; namespace Avalonia.Controls.Primitives @@ -19,9 +19,10 @@ namespace Avalonia.Controls.Primitives private readonly OverlayLayer _overlayLayer; private readonly ManagedPopupPositioner _positioner; - private PopupPositionerParameters _positionerParameters; private Point _lastRequestedPosition; - private bool _shown; + private PopupPositionRequest? _popupPositionRequest; + private Size _popupSize; + private bool _shown, _needsUpdate; public OverlayPopupHost(OverlayLayer overlayLayer) { @@ -73,36 +74,42 @@ namespace Avalonia.Controls.Primitives } /// + [Unstable(ObsoletionMessages.MayBeRemovedInAvalonia12)] public void ConfigurePosition(Visual target, PlacementMode placement, Point offset, PopupAnchor anchor = PopupAnchor.None, PopupGravity gravity = PopupGravity.None, PopupPositionerConstraintAdjustment constraintAdjustment = PopupPositionerConstraintAdjustment.All, Rect? rect = null) { - _positionerParameters.ConfigurePosition((TopLevel)_overlayLayer.GetVisualRoot()!, target, placement, offset, anchor, - gravity, constraintAdjustment, rect, FlowDirection); + ((IPopupHost)this).ConfigurePosition(new PopupPositionRequest(target, placement, offset, anchor, gravity, + constraintAdjustment, rect, null)); + } + + /// + void IPopupHost.ConfigurePosition(PopupPositionRequest positionRequest) + { + _popupPositionRequest = positionRequest; + _needsUpdate = true; UpdatePosition(); } /// protected override Size ArrangeOverride(Size finalSize) { - if (_positionerParameters.Size != finalSize) + if (_popupSize != finalSize) { - _positionerParameters.Size = finalSize; + _popupSize = finalSize; + _needsUpdate = true; UpdatePosition(); } return base.ArrangeOverride(finalSize); } - private void UpdatePosition() { - // Don't bother the positioner with layout system artifacts - if (_positionerParameters.Size.Width == 0 || _positionerParameters.Size.Height == 0) - return; - if (_shown) + if (_needsUpdate && _popupPositionRequest is not null) { - _positioner.Update(_positionerParameters); + _needsUpdate = false; + _positioner.Update(TopLevel.GetTopLevel(_overlayLayer)!, _popupPositionRequest, _popupSize, FlowDirection); } } diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index 4c726e9183..28e2a51e06 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -22,6 +22,9 @@ namespace Avalonia.Controls.Primitives /// public class Popup : Control, IPopupHostProvider { + /// + /// Defines the property. + /// public static readonly StyledProperty WindowManagerAddShadowHintProperty = AvaloniaProperty.Register(nameof(WindowManagerAddShadowHint), false); @@ -89,9 +92,21 @@ namespace Avalonia.Controls.Primitives public static readonly StyledProperty PlacementTargetProperty = AvaloniaProperty.Register(nameof(PlacementTarget)); + /// + /// Defines the property. + /// + public static readonly StyledProperty CustomPopupPlacementCallbackProperty = + AvaloniaProperty.Register(nameof(CustomPopupPlacementCallback)); + + /// + /// Defines the property. + /// public static readonly StyledProperty OverlayDismissEventPassThroughProperty = AvaloniaProperty.Register(nameof(OverlayDismissEventPassThrough)); + /// + /// Defines the property. + /// public static readonly StyledProperty OverlayInputPassThroughElementProperty = AvaloniaProperty.Register(nameof(OverlayInputPassThroughElement)); @@ -287,6 +302,15 @@ namespace Avalonia.Controls.Primitives set => SetValue(PlacementTargetProperty, value); } + /// + /// Gets or sets a delegate handler method that positions the Popup control, when is set to . + /// + public CustomPopupPlacementCallback? CustomPopupPlacementCallback + { + get => GetValue(CustomPopupPlacementCallbackProperty); + set => SetValue(CustomPopupPlacementCallbackProperty, value); + } + /// /// Gets or sets a value indicating whether the event that closes the popup is passed /// through to the parent window. @@ -603,14 +627,15 @@ namespace Avalonia.Controls.Primitives private void UpdateHostPosition(IPopupHost popupHost, Control placementTarget) { - popupHost.ConfigurePosition( + popupHost.ConfigurePosition(new PopupPositionRequest( placementTarget, Placement, new Point(HorizontalOffset, VerticalOffset), PlacementAnchor, PlacementGravity, PlacementConstraintAdjustment, - PlacementRect ?? new Rect(default, placementTarget.Bounds.Size)); + PlacementRect ?? new Rect(default, placementTarget.Bounds.Size), + CustomPopupPlacementCallback)); } private void UpdateHostSizing(IPopupHost popupHost, TopLevel topLevel, Control placementTarget) @@ -651,14 +676,15 @@ namespace Avalonia.Controls.Primitives var placementTarget = PlacementTarget ?? this.FindLogicalAncestorOfType(); if (placementTarget == null) return; - _openState.PopupHost.ConfigurePosition( + _openState.PopupHost.ConfigurePosition(new PopupPositionRequest( placementTarget, Placement, new Point(HorizontalOffset, VerticalOffset), PlacementAnchor, PlacementGravity, PlacementConstraintAdjustment, - PlacementRect); + PlacementRect, + CustomPopupPlacementCallback)); } } diff --git a/src/Avalonia.Controls/Primitives/PopupPositioning/CustomPopupPlacement.cs b/src/Avalonia.Controls/Primitives/PopupPositioning/CustomPopupPlacement.cs new file mode 100644 index 0000000000..8be812b4ea --- /dev/null +++ b/src/Avalonia.Controls/Primitives/PopupPositioning/CustomPopupPlacement.cs @@ -0,0 +1,57 @@ +namespace Avalonia.Controls.Primitives.PopupPositioning; + +/// +/// Defines custom placement parameters for a callback. +/// +public record CustomPopupPlacement +{ + private PopupGravity _gravity; + private PopupAnchor _anchor; + + internal CustomPopupPlacement(Size popupSize, Visual target) + { + PopupSize = popupSize; + Target = target; + } + + /// + /// The of the control. + /// + public Size PopupSize { get; } + + /// + /// Placement target of the popup. + /// + public Visual Target { get; } + + /// + public Rect AnchorRectangle { get; set; } + + /// + public PopupAnchor Anchor + { + get => _anchor; + set + { + PopupPositioningEdgeHelper.ValidateEdge(value); + _anchor = value; + } + } + + /// + public PopupGravity Gravity + { + get => _gravity; + set + { + PopupPositioningEdgeHelper.ValidateGravity(value); + _gravity = value; + } + } + + /// + public PopupPositionerConstraintAdjustment ConstraintAdjustment { get; set; } + + /// + public Point Offset { get; set; } +} diff --git a/src/Avalonia.Controls/Primitives/PopupPositioning/CustomPopupPlacementCallback.cs b/src/Avalonia.Controls/Primitives/PopupPositioning/CustomPopupPlacementCallback.cs new file mode 100644 index 0000000000..dbe90d8d32 --- /dev/null +++ b/src/Avalonia.Controls/Primitives/PopupPositioning/CustomPopupPlacementCallback.cs @@ -0,0 +1,6 @@ +namespace Avalonia.Controls.Primitives.PopupPositioning; + +/// +/// Represents a method that provides custom positioning for a control. +/// +public delegate void CustomPopupPlacementCallback(CustomPopupPlacement parameters); diff --git a/src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs b/src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs index 31c1e6054a..a0b853f2dc 100644 --- a/src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs +++ b/src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs @@ -45,6 +45,9 @@ Copyright © 2019 Nikita Tsukanov */ using System; +using System.ComponentModel; +using System.Diagnostics; +using Avalonia.Diagnostics; using Avalonia.Input; using Avalonia.Metadata; using Avalonia.VisualTree; @@ -63,7 +66,7 @@ namespace Avalonia.Controls.Primitives.PopupPositioning /// requirement that a popup must intersect with or be at least partially adjacent to its parent /// surface. /// - [Unstable] + [Unstable(ObsoletionMessages.MayBeRemovedInAvalonia12)] public record struct PopupPositionerParameters { private PopupGravity _gravity; @@ -443,19 +446,35 @@ namespace Avalonia.Controls.Primitives.PopupPositioning void Update(PopupPositionerParameters parameters); } - [Unstable] - static class PopupPositionerExtensions + internal static class PopupPositionerExtensions { - public static void ConfigurePosition(ref this PopupPositionerParameters positionerParameters, + public static void Update( + this IPopupPositioner positioner, TopLevel topLevel, - Visual target, PlacementMode placement, Point offset, - PopupAnchor anchor, PopupGravity gravity, - PopupPositionerConstraintAdjustment constraintAdjustment, Rect? rect, + PopupPositionRequest positionRequest, + Size popupSize, FlowDirection flowDirection) { - positionerParameters.Offset = offset; - positionerParameters.ConstraintAdjustment = constraintAdjustment; - if (placement == PlacementMode.Pointer) + if (popupSize == default) + { + return; + } + + var parameters = BuildParameters(topLevel, positionRequest, popupSize, flowDirection); + positioner.Update(parameters); + } + + private static PopupPositionerParameters BuildParameters( + TopLevel topLevel, + PopupPositionRequest positionRequest, + Size popupSize, + FlowDirection flowDirection) + { + PopupPositionerParameters positionerParameters = default; + positionerParameters.Offset = positionRequest.Offset; + positionerParameters.Size = popupSize; + positionerParameters.ConstraintAdjustment = positionRequest.ConstraintAdjustment; + if (positionRequest.Placement == PlacementMode.Pointer) { // We need a better way for tracking the last pointer position var position = topLevel.PointToClient(topLevel.LastPointerPosition ?? default); @@ -464,39 +483,45 @@ namespace Avalonia.Controls.Primitives.PopupPositioning positionerParameters.Anchor = PopupAnchor.TopLeft; positionerParameters.Gravity = PopupGravity.BottomRight; } - else + else if (positionRequest.Placement == PlacementMode.Custom) { - if (target == null) - throw new InvalidOperationException("Placement mode is not Pointer and PlacementTarget is null"); - Matrix? matrix; - if (TryGetAdorner(target, out var adorned, out var adornerLayer)) - { - matrix = adorned!.TransformToVisual(topLevel) * target.TransformToVisual(adornerLayer!); - } - else - { - matrix = target.TransformToVisual(topLevel); - } + if (positionRequest.PlacementCallback is null) + throw new InvalidOperationException( + "CustomPopupPlacementCallback property must be set, when Placement=PlacementMode.Custom"); - if (matrix == null) + positionerParameters.AnchorRectangle = CalculateAnchorRect(topLevel, positionRequest); + + var customPlacementParameters = new CustomPopupPlacement( + popupSize, + positionRequest.Target) { - if (target.GetVisualRoot() == null) - throw new InvalidOperationException("Target control is not attached to the visual tree"); - throw new InvalidOperationException("Target control is not in the same tree as the popup parent"); - } + AnchorRectangle = positionerParameters.AnchorRectangle, + Anchor = positionerParameters.Anchor, + Gravity = positionerParameters.Gravity, + ConstraintAdjustment = positionerParameters.ConstraintAdjustment, + Offset = positionerParameters.Offset + }; - var bounds = new Rect(default, target.Bounds.Size); - var anchorRect = rect ?? bounds; - positionerParameters.AnchorRectangle = anchorRect.Intersect(bounds).TransformToAABB(matrix.Value); + positionRequest.PlacementCallback.Invoke(customPlacementParameters); - var parameters = placement switch + positionerParameters.AnchorRectangle = customPlacementParameters.AnchorRectangle; + positionerParameters.Anchor = customPlacementParameters.Anchor; + positionerParameters.Gravity = customPlacementParameters.Gravity; + positionerParameters.ConstraintAdjustment = customPlacementParameters.ConstraintAdjustment; + positionerParameters.Offset = customPlacementParameters.Offset; + } + else + { + positionerParameters.AnchorRectangle = CalculateAnchorRect(topLevel, positionRequest); + + var parameters = positionRequest.Placement switch { PlacementMode.Bottom => (PopupAnchor.Bottom, PopupGravity.Bottom), PlacementMode.Right => (PopupAnchor.Right, PopupGravity.Right), PlacementMode.Left => (PopupAnchor.Left, PopupGravity.Left), PlacementMode.Top => (PopupAnchor.Top, PopupGravity.Top), PlacementMode.Center => (PopupAnchor.None, PopupGravity.None), - PlacementMode.AnchorAndGravity => (anchor, gravity), + PlacementMode.AnchorAndGravity => (positionRequest.Anchor, positionRequest.Gravity), PlacementMode.TopEdgeAlignedRight => (PopupAnchor.TopRight, PopupGravity.TopLeft), PlacementMode.TopEdgeAlignedLeft => (PopupAnchor.TopLeft, PopupGravity.TopRight), PlacementMode.BottomEdgeAlignedLeft => (PopupAnchor.BottomLeft, PopupGravity.BottomRight), @@ -505,7 +530,7 @@ namespace Avalonia.Controls.Primitives.PopupPositioning PlacementMode.LeftEdgeAlignedBottom => (PopupAnchor.BottomLeft, PopupGravity.TopLeft), PlacementMode.RightEdgeAlignedTop => (PopupAnchor.TopRight, PopupGravity.BottomRight), PlacementMode.RightEdgeAlignedBottom => (PopupAnchor.BottomRight, PopupGravity.TopRight), - _ => throw new ArgumentOutOfRangeException(nameof(placement), placement, + _ => throw new ArgumentOutOfRangeException(nameof(positionRequest.Placement), positionRequest.Placement, "Invalid value for Popup.PlacementMode") }; positionerParameters.Anchor = parameters.Item1; @@ -537,6 +562,35 @@ namespace Avalonia.Controls.Primitives.PopupPositioning positionerParameters.Gravity |= PopupGravity.Right; } } + + return positionerParameters; + } + + private static Rect CalculateAnchorRect(TopLevel topLevel, PopupPositionRequest positionRequest) + { + var target = positionRequest.Target; + if (target == null) + throw new InvalidOperationException("Placement mode is not Pointer and PlacementTarget is null"); + Matrix? matrix; + if (TryGetAdorner(target, out var adorned, out var adornerLayer)) + { + matrix = adorned!.TransformToVisual(topLevel) * target.TransformToVisual(adornerLayer!); + } + else + { + matrix = target.TransformToVisual(topLevel); + } + + if (matrix == null) + { + if (target.GetVisualRoot() == null) + throw new InvalidOperationException("Target control is not attached to the visual tree"); + throw new InvalidOperationException("Target control is not in the same tree as the popup parent"); + } + + var bounds = new Rect(default, target.Bounds.Size); + var anchorRect = positionRequest.AnchorRect ?? bounds; + return anchorRect.Intersect(bounds).TransformToAABB(matrix.Value); } private static bool TryGetAdorner(Visual target, out Visual? adorned, out Visual? adornerLayer) diff --git a/src/Avalonia.Controls/Primitives/PopupPositioning/PopupPositionRequest.cs b/src/Avalonia.Controls/Primitives/PopupPositioning/PopupPositionRequest.cs new file mode 100644 index 0000000000..35e15e3057 --- /dev/null +++ b/src/Avalonia.Controls/Primitives/PopupPositioning/PopupPositionRequest.cs @@ -0,0 +1,35 @@ +using Avalonia.Diagnostics; +using Avalonia.Metadata; + +namespace Avalonia.Controls.Primitives.PopupPositioning; + +[PrivateApi] +[Unstable(ObsoletionMessages.MayBeRemovedInAvalonia12)] +public class PopupPositionRequest +{ + internal PopupPositionRequest(Visual target, PlacementMode placement) + { + Target = target; + Placement = placement; + } + + internal PopupPositionRequest(Visual target, PlacementMode placement, Point offset, PopupAnchor anchor, PopupGravity gravity, PopupPositionerConstraintAdjustment constraintAdjustment, Rect? anchorRect, CustomPopupPlacementCallback? placementCallback) + : this(target, placement) + { + Offset = offset; + Anchor = anchor; + Gravity = gravity; + ConstraintAdjustment = constraintAdjustment; + AnchorRect = anchorRect; + PlacementCallback = placementCallback; + } + + public Visual Target { get; } + public PlacementMode Placement {get;} + public Point Offset {get;} + public PopupAnchor Anchor {get;} + public PopupGravity Gravity {get;} + public PopupPositionerConstraintAdjustment ConstraintAdjustment {get;} + public Rect? AnchorRect {get;} + public CustomPopupPlacementCallback? PlacementCallback {get;} +} diff --git a/src/Avalonia.Controls/Primitives/PopupRoot.cs b/src/Avalonia.Controls/Primitives/PopupRoot.cs index 723bdca1b2..b2905b9176 100644 --- a/src/Avalonia.Controls/Primitives/PopupRoot.cs +++ b/src/Avalonia.Controls/Primitives/PopupRoot.cs @@ -1,8 +1,10 @@ using System; using Avalonia.Automation.Peers; using Avalonia.Controls.Primitives.PopupPositioning; +using Avalonia.Diagnostics; using Avalonia.Interactivity; using Avalonia.Media; +using Avalonia.Metadata; using Avalonia.Platform; using Avalonia.Styling; using Avalonia.VisualTree; @@ -26,7 +28,9 @@ namespace Avalonia.Controls.Primitives public static readonly StyledProperty WindowManagerAddShadowHintProperty = Popup.WindowManagerAddShadowHintProperty.AddOwner(); - private PopupPositionerParameters _positionerParameters; + private PopupPositionRequest? _popupPositionRequest; + private Size _popupSize; + private bool _needsUpdate; /// /// Initializes static members of the class. @@ -124,20 +128,30 @@ namespace Avalonia.Controls.Primitives private void UpdatePosition() { - PlatformImpl?.PopupPositioner?.Update(_positionerParameters); + if (_needsUpdate && _popupPositionRequest is not null) + { + _needsUpdate = false; + PlatformImpl?.PopupPositioner? + .Update(ParentTopLevel, _popupPositionRequest, _popupSize, FlowDirection); + } } + [Unstable(ObsoletionMessages.MayBeRemovedInAvalonia12)] public void ConfigurePosition(Visual target, PlacementMode placement, Point offset, PopupAnchor anchor = PopupAnchor.None, PopupGravity gravity = PopupGravity.None, PopupPositionerConstraintAdjustment constraintAdjustment = PopupPositionerConstraintAdjustment.All, Rect? rect = null) { - _positionerParameters.ConfigurePosition(ParentTopLevel, target, - placement, offset, anchor, gravity, constraintAdjustment, rect, FlowDirection); + ((IPopupHost)this).ConfigurePosition(new PopupPositionRequest(target, placement, offset, anchor, gravity, + constraintAdjustment, rect, null)); + } - if (_positionerParameters.Size != default) - UpdatePosition(); + void IPopupHost.ConfigurePosition(PopupPositionRequest request) + { + _popupPositionRequest = request; + _needsUpdate = true; + UpdatePosition(); } public void SetChild(Control? control) => Content = control; @@ -184,10 +198,15 @@ namespace Avalonia.Controls.Primitives return new Size(width, height); } - protected override sealed Size ArrangeSetBounds(Size size) + protected sealed override Size ArrangeSetBounds(Size size) { - _positionerParameters.Size = size; - UpdatePosition(); + if (_popupSize != size) + { + _popupSize = size; + _needsUpdate = true; + UpdatePosition(); + } + return ClientSize; } diff --git a/src/Avalonia.Controls/ToolTip.cs b/src/Avalonia.Controls/ToolTip.cs index 8bf0adedee..fc8da13131 100644 --- a/src/Avalonia.Controls/ToolTip.cs +++ b/src/Avalonia.Controls/ToolTip.cs @@ -4,6 +4,7 @@ using Avalonia.Controls.Diagnostics; using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; using Avalonia.Interactivity; +using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.Reactive; using Avalonia.Styling; @@ -51,6 +52,10 @@ namespace Avalonia.Controls public static readonly AttachedProperty VerticalOffsetProperty = AvaloniaProperty.RegisterAttached("VerticalOffset", 20); + /// + public static readonly AttachedProperty CustomPopupPlacementCallbackProperty = + AvaloniaProperty.RegisterAttached("CustomPopupPlacementCallback"); + /// /// Defines the ToolTip.ShowDelay property. /// @@ -327,6 +332,22 @@ namespace Avalonia.Controls public static void RemoveToolTipClosingHandler(Control element, EventHandler handler) => element.RemoveHandler(ToolTipClosingEvent, handler); + /// + /// Gets the value of the ToolTip.CustomPopupPlacementCallback attached property. + /// + public static CustomPopupPlacementCallback? GetCustomPopupPlacementCallback(Control element) + { + return element.GetValue(CustomPopupPlacementCallbackProperty); + } + + /// + /// Sets the value of the ToolTip.CustomPopupPlacementCallback attached property. + /// + public static void SetCustomPopupPlacementCallback(Control element, CustomPopupPlacementCallback? value) + { + element.SetValue(CustomPopupPlacementCallbackProperty, value); + } + private static void IsOpenChanged(AvaloniaPropertyChangedEventArgs e) { var control = (Control)e.Sender; @@ -397,7 +418,8 @@ namespace Avalonia.Controls { _popup.Bind(Popup.HorizontalOffsetProperty, control.GetBindingObservable(HorizontalOffsetProperty)), _popup.Bind(Popup.VerticalOffsetProperty, control.GetBindingObservable(VerticalOffsetProperty)), - _popup.Bind(Popup.PlacementProperty, control.GetBindingObservable(PlacementProperty)) + _popup.Bind(Popup.PlacementProperty, control.GetBindingObservable(PlacementProperty)), + _popup.Bind(Popup.CustomPopupPlacementCallbackProperty, control.GetBindingObservable(CustomPopupPlacementCallbackProperty)) }); _popup.PlacementTarget = control; @@ -415,14 +437,14 @@ namespace Avalonia.Controls adornedControl.RaiseEvent(args); } - _subscriptions?.Dispose(); - if (_popup is not null) { _popup.IsOpen = false; _popup.SetPopupParent(null); _popup.PlacementTarget = null; } + + _subscriptions?.Dispose(); } private void OnPopupClosed(object? sender, EventArgs e) diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs index fb88c98343..38bbc875c5 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs @@ -262,6 +262,7 @@ namespace Avalonia.Controls.UnitTests.Primitives var target = CreateTarget(window, popupImpl.Object); target.Content = child; + ((IPopupHost)target).ConfigurePosition(new PopupPositionRequest(window, PlacementMode.Top)); target.Show(); Assert.Equal(new Size(400, 1024), target.Bounds.Size); @@ -290,6 +291,7 @@ namespace Avalonia.Controls.UnitTests.Primitives Height = 100, }; + ((IPopupHost)target).ConfigurePosition(new PopupPositionRequest(window, PlacementMode.Top)); target.Show(); Assert.Equal(new Rect(0, 0, 400, 800), target.Bounds); @@ -313,6 +315,7 @@ namespace Avalonia.Controls.UnitTests.Primitives target.Width = 400; target.Height = 800; + ((IPopupHost)target).ConfigurePosition(new PopupPositionRequest(window, PlacementMode.Top)); target.Show(); Assert.Equal(400, target.Width); diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs index e1ff48a3cc..1cad1304af 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs @@ -1206,6 +1206,50 @@ namespace Avalonia.Controls.UnitTests.Primitives } } + [Fact] + public void Custom_Placement_Callback_Is_Executed() + { + using (CreateServices()) + { + var callbackExecuted = 0; + var popupContent = new Border { Width = 100, Height = 100 }; + var popup = new Popup + { + Child = popupContent, + Placement = PlacementMode.Custom, + HorizontalOffset = 42, + VerticalOffset = 21 + }; + var popupParent = new Border { Child = popup }; + var root = PreparedWindow(popupParent); + + popup.CustomPopupPlacementCallback = (parameters) => + { + Assert.Equal(popupContent.Width, parameters.PopupSize.Width); + Assert.Equal(popupContent.Height, parameters.PopupSize.Height); + + Assert.Equal(root.Width, parameters.AnchorRectangle.Width); + Assert.Equal(root.Height, parameters.AnchorRectangle.Height); + + Assert.Equal(popup.HorizontalOffset, parameters.Offset.X); + Assert.Equal(popup.VerticalOffset, parameters.Offset.Y); + + callbackExecuted++; + + parameters.Anchor = PopupAnchor.Top; + parameters.Gravity = PopupGravity.Bottom; + }; + + root.LayoutManager.ExecuteInitialLayoutPass(); + popup.Open(); + root.LayoutManager.ExecuteLayoutPass(); + + // Ideally, callback should be executed only once for this test. + // But currently PlacementTargetLayoutUpdated triggers second update either way. + Assert.Equal(2, callbackExecuted); + } + } + private static PopupRoot CreateRoot(TopLevel popupParent, IPopupImpl impl = null) {