Repository: AvaloniaUI/Avalonia

Issue:
Add CustomPopupPlacementCallback support
### Is your feature request related to a problem? Please describe.

WPF has a [CustomPopupPlacementCallback](https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.primitives.custompopupplacementcallback?view=windowsdesktop-8.0) delegate that can be assigned to their [Popup](https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.primitives.popup?view=windowsdesktop-8.0) and when `Popup.Placement == PlacementMode.Custom`, that delegate is used to supply dynamic options for placement via callback.  The options are returned in an array of [CustomPopupPlacement](https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.primitives.custompopupplacement?view=windowsdesktop-8.0) objects, and the one that appears first and allows the popup to fit best on screen is the one that is used.

We have a requirement where an Avalonia `Control` that has a `ToolTip` needs to display that tip in a certain location relative to the target control.  However, that relative location changes based on the current state of the target control and other controls around it at the time popup shows.  Thus, we cannot use the simple `Placement` properties on `Popup` and need the use of a dynamic callback like in WPF to allow us to alter things on the fly.

### Describe the solution you'd like

While we don't necessarily need the API to match WPF's `CustomPopupPlacementCallback` and `CustomPopupPlacement` exactly (they are slightly difficult to grasp when first learning them), we do need a callback function that can tell a popup like a `ToolTip` to display at some location relative to the target control.  

The Avalonia [Popup](https://github.com/AvaloniaUI/Avalonia/blob/master/src/Avalonia.Controls/Primitives/Popup.cs) already has some slight differences and enhancements over WPF popups, so we are happy to have a discussion on API implementation for this callback feature that would fit into the Avalonia model.

### Describe alternatives you've considered

_No response_

### Additional context

_No response_


Hints:
The API is designed to be Wayland-compatible and is designed against `xdg_positioner` object from `xdg-shell` protocol.
 Any placement API that requires access to global screen coordinates won't be Wayland-compatible.


To position your popup relative to the top-left corner of a control use OffsetX/OffsetY properties with TopLeft anchor and BottomRight gravity.
We can, however, introduce a callback that would allow to modify offset/anchor/gravity properties right before the popup is shown
Just to clarify, the callback in WPF doesn't use screen coordinates.  It is all coordinates relative to the target element.  So a `(0,0)` placement value would mean upper left of the target control.

The WPF callback allows us to look at things like target element size, popup size, and horizontal/vertical offsets, and then return target element-relative placement options.  I would think that should be Wayland-compatible since it's not global screen coordinates.

The WPF way would not make us alter the actual `HorizontalOffset` and `VerticalOffset` values since it expected the callback result's placements to specify a target element-relative location and axis.  See the WPF [CustomPopupPlacement](https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.primitives.custompopupplacement?view=windowsdesktop-8.0) for what the result there includes.  Keep in mind that since it's using a `PlacementMode.Custom` setting for this, the `Placement` property is not even considered.

---

I admittedly don't know the nuts and bolts of how Avalonia implemented popup positioning very well but when searching around, I happened to see a `PopupPositionerParameters` class, which looks like a promising possible result for a callback.

If you guys are initializing a `PopupPositionerParameters` based on current props, what if we added a callback delegate property to `Popup` (or could be done via an event I suppose too) and if specified, it would pass in the `PopupPositionerParameters` you built but would allow us to tweak it further before it was actually used?  The callback would need to have access to the target anchor element (to see it and its size), the popup (to see it and some of its properties).

If we could alter the `PopupPositionerParameters` before it was used, that would allow us to dynamically change popup positioning based on the current state of the target element.  And if the callback wasn't specified, or it did nothing in its logic, your current default positioning parameters would be used.  Does that sound reasonable?

Patch:
diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml
index 6b79f6f33b0..13b9724778f 100644
--- a/api/Avalonia.nupkg.xml
+++ b/api/Avalonia.nupkg.xml
@@ -19,6 +19,12 @@
     <Left>baseline/netstandard2.0/Avalonia.Base.dll</Left>
     <Right>target/netstandard2.0/Avalonia.Base.dll</Right>
   </Suppression>
+  <Suppression>
+    <DiagnosticId>CP0002</DiagnosticId>
+    <Target>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})</Target>
+    <Left>baseline/netstandard2.0/Avalonia.Controls.dll</Left>
+    <Right>target/netstandard2.0/Avalonia.Controls.dll</Right>
+  </Suppression>
   <Suppression>
     <DiagnosticId>CP0002</DiagnosticId>
     <Target>M:Avalonia.Controls.Screens.#ctor(Avalonia.Platform.IScreenImpl)</Target>
@@ -43,6 +49,12 @@
     <Left>baseline/netstandard2.0/Avalonia.Controls.dll</Left>
     <Right>target/netstandard2.0/Avalonia.Controls.dll</Right>
   </Suppression>
+  <Suppression>
+    <DiagnosticId>CP0006</DiagnosticId>
+    <Target>M:Avalonia.Controls.Primitives.IPopupHost.ConfigurePosition(Avalonia.Controls.Primitives.PopupPositioning.PopupPositionRequest)</Target>
+    <Left>baseline/netstandard2.0/Avalonia.Controls.dll</Left>
+    <Right>target/netstandard2.0/Avalonia.Controls.dll</Right>
+  </Suppression>
   <Suppression>
     <DiagnosticId>CP0009</DiagnosticId>
     <Target>T:Avalonia.Controls.Screens</Target>
diff --git a/samples/ControlCatalog/Pages/FlyoutsPage.axaml b/samples/ControlCatalog/Pages/FlyoutsPage.axaml
index c0521cd3ba5..64a005c987f 100644
--- a/samples/ControlCatalog/Pages/FlyoutsPage.axaml
+++ b/samples/ControlCatalog/Pages/FlyoutsPage.axaml
@@ -222,7 +222,15 @@
                                 </Flyout>
                             </Button.Flyout>
                         </Button>
-
+                        <Button Content="Placement=Custom">
+                            <Button.Flyout>
+                                <Flyout Placement="Custom" CustomPopupPlacementCallback="CustomPlacementCallback">
+                                    <Panel Width="100" Height="100">
+                                        <TextBlock Text="Flyout Content!" />
+                                    </Panel>
+                                </Flyout>
+                            </Button.Flyout>
+                        </Button>
                     </UniformGrid>
                 </Border>
             </StackPanel>
@@ -267,7 +275,6 @@
                                 </Flyout>
                             </Button.Flyout>
                         </Button>
-                        
                     </WrapPanel>
                 </Border>
             </StackPanel>
diff --git a/samples/ControlCatalog/Pages/FlyoutsPage.axaml.cs b/samples/ControlCatalog/Pages/FlyoutsPage.axaml.cs
index 89441513856..5897eb2cde0 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 @@ private void SetXamlTexts()
                 "Then attach the flyout where you want it:\n" +
                 "<Button Content=\"Launch Flyout here\" Flyout=\"{StaticResource SharedFlyout}\" />";
         }
+
+        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/samples/ControlCatalog/Pages/ToolTipPage.xaml b/samples/ControlCatalog/Pages/ToolTipPage.xaml
index 8aaed903f36..8b76cccbc96 100644
--- a/samples/ControlCatalog/Pages/ToolTipPage.xaml
+++ b/samples/ControlCatalog/Pages/ToolTipPage.xaml
@@ -5,31 +5,24 @@
                 Spacing="4">
         <TextBlock Classes="h2">A control which pops up a hint when a control is hovered</TextBlock>
 
-        <Grid RowDefinitions="Auto,Auto,Auto,Auto"
-              ColumnDefinitions="Auto,Auto"
-              Margin="0,16,0,0"
-              HorizontalAlignment="Center">
+        <UniformGrid Columns="2"
+                     Margin="0,16,0,0"
+                     HorizontalAlignment="Center">
             <ToggleSwitch Margin="5" 
-                    HorizontalAlignment="Center"
-                    IsChecked="{Binding Path=(ToolTip.ServiceEnabled), RelativeSource={RelativeSource AncestorType=UserControl}}"
-                    Content="Enable ToolTip service" />
-            <Border Grid.Column="0"
-                    Grid.Row="1"
-                    Background="{DynamicResource SystemAccentColor}"
+                          HorizontalAlignment="Center"
+                          IsChecked="{Binding Path=(ToolTip.ServiceEnabled), RelativeSource={RelativeSource AncestorType=UserControl}}"
+                          Content="Enable ToolTip service" />
+            <ToggleSwitch Margin="5"
+                          IsChecked="{Binding ElementName=Border, Path=(ToolTip.IsOpen)}"
+                          HorizontalAlignment="Center"
+                          Content="ToolTip Open" />
+            <Border Background="{DynamicResource SystemAccentColor}"
                     Margin="5"
                     Padding="50"
                     ToolTip.Tip="This is a ToolTip">
                 <TextBlock>Hover Here</TextBlock>
             </Border>
-            <ToggleSwitch Grid.Column="1"
-                      Margin="5"
-                      Grid.Row="0"
-                      IsChecked="{Binding ElementName=Border, Path=(ToolTip.IsOpen)}"
-                      HorizontalAlignment="Center"
-                      Content="ToolTip Open" />
             <Border Name="Border"
-                    Grid.Column="1"
-                    Grid.Row="1"
                     Background="{DynamicResource SystemAccentColor}"
                     Margin="5"
                     Padding="50"
@@ -42,8 +35,15 @@
                 </ToolTip.Tip>
                 <TextBlock>ToolTip bottom placement</TextBlock>
             </Border>
-            <Border Grid.Row="2"
-                    Background="{DynamicResource SystemAccentColor}"
+            <Border Background="{DynamicResource SystemAccentColor}"
+                    Margin="5"
+                    Padding="50"
+                    ToolTip.Placement="Custom"
+                    ToolTip.CustomPopupPlacementCallback="CustomPlacementCallback"
+                    ToolTip.Tip="Custom positioned tooltip">
+              <TextBlock>ToolTip custom placement</TextBlock>
+            </Border>
+            <Border Background="{DynamicResource SystemAccentColor}"
                     Margin="5"
                     Padding="50"
                     ToolTip.Tip="Hello"
@@ -67,8 +67,7 @@
               <TextBlock>Moving offset</TextBlock>
             </Border>
 
-            <Button Grid.Row="2" Grid.Column="1"
-                    IsEnabled="False"
+            <Button IsEnabled="False"
                     ToolTip.ShowOnDisabled="True"
                     ToolTip.Tip="This control is disabled"
                     Margin="5"
@@ -76,24 +75,20 @@
               <TextBlock>ToolTip on a disabled control</TextBlock>
             </Button>
 
-          <Border Grid.Row="3"
-                  Grid.Column="0"
-                  Background="{DynamicResource SystemAccentColor}"
+          <Border Background="{DynamicResource SystemAccentColor}"
                   Margin="5"
                   Padding="50"
                   ToolTip.Tip="Outer tooltip">
             <TextBlock Background="{StaticResource SystemAccentColorDark1}" Padding="10" ToolTip.Tip="Inner tooltip" VerticalAlignment="Center">Nested ToolTips</TextBlock>
           </Border>
-            
-          <Border Grid.Row="3"
-                  Grid.Column="1"
-                  Background="{DynamicResource SystemAccentColor}"
+
+          <Border Background="{DynamicResource SystemAccentColor}"
                   Margin="5"
                   Padding="50"
                   ToolTip.ToolTipOpening="ToolTipOpening"
                   ToolTip.Tip="Should never be visible">
             <TextBlock VerticalAlignment="Center">ToolTip replaced on the fly</TextBlock>
           </Border>
-        </Grid>
+        </UniformGrid>
     </StackPanel>
 </UserControl>
diff --git a/samples/ControlCatalog/Pages/ToolTipPage.xaml.cs b/samples/ControlCatalog/Pages/ToolTipPage.xaml.cs
index 0e8bf3a181d..7efdb99078c 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 @@ private void InitializeComponent()
         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 cb64cc27e60..940528bf6f2 100644
--- a/src/Avalonia.Controls/ContextMenu.cs
+++ b/src/Avalonia.Controls/ContextMenu.cs
@@ -82,6 +82,10 @@ public class ContextMenu : MenuBase, ISetterValue, IPopupHostProvider
         public static readonly StyledProperty<Control?> PlacementTargetProperty =
             Popup.PlacementTargetProperty.AddOwner<ContextMenu>();
 
+        /// <inheritdoc cref="Popup.CustomPopupPlacementCallbackProperty"/>
+        public static readonly StyledProperty<CustomPopupPlacementCallback?> CustomPopupPlacementCallbackProperty =
+            Popup.CustomPopupPlacementCallbackProperty.AddOwner<ContextMenu>();
+
         private Popup? _popup;
         private List<Control>? _attachedControls;
         private IInputElement? _previousFocus;
@@ -185,6 +189,13 @@ public Control? PlacementTarget
             set => SetValue(PlacementTargetProperty, value);
         }
 
+        /// <inheritdoc cref="Popup.CustomPopupPlacementCallback"/>
+        public CustomPopupPlacementCallback? CustomPopupPlacementCallback
+        {
+            get => GetValue(CustomPopupPlacementCallbackProperty);
+            set => SetValue(CustomPopupPlacementCallbackProperty, value);
+        }
+
         /// <summary>
         /// Occurs when the value of the
         /// <see cref="P:Avalonia.Controls.ContextMenu.IsOpen" />
@@ -340,6 +351,7 @@ private void Open(Control control, Control placementTarget, PlacementMode placem
             _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 ad5ffef267d..fa0d5f98557 100644
--- a/src/Avalonia.Controls/ContextRequestedEventArgs.cs
+++ b/src/Avalonia.Controls/ContextRequestedEventArgs.cs
@@ -26,6 +26,13 @@ public ContextRequestedEventArgs(PointerEventArgs pointerEventArgs)
             _pointerEventArgs = pointerEventArgs;
         }
 
+        /// <inheritdoc cref="ContextRequestedEventArgs()" />
+        public ContextRequestedEventArgs(ContextRequestedEventArgs contextRequestedEventArgs)
+            : this()
+        {
+            _pointerEventArgs = contextRequestedEventArgs._pointerEventArgs;
+        }
+
         /// <summary>
         /// Gets the x- and y-coordinates of the pointer position, optionally evaluated against a coordinate origin of a supplied <see cref="Control"/>.
         /// </summary>
diff --git a/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs b/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs
index 0c72d67154a..82a243cd727 100644
--- a/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs
+++ b/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs
@@ -30,11 +30,15 @@ public abstract class PopupFlyoutBase : FlyoutBase, IPopupHostProvider
         /// <inheritdoc cref="Popup.PlacementAnchorProperty"/>
         public static readonly StyledProperty<PopupAnchor> PlacementAnchorProperty =
             Popup.PlacementAnchorProperty.AddOwner<PopupFlyoutBase>();
-        
+
         /// <inheritdoc cref="Popup.PlacementAnchorProperty"/>
         public static readonly StyledProperty<PopupGravity> PlacementGravityProperty =
             Popup.PlacementGravityProperty.AddOwner<PopupFlyoutBase>();
 
+        /// <inheritdoc cref="Popup.CustomPopupPlacementCallbackProperty"/>
+        public static readonly StyledProperty<CustomPopupPlacementCallback?> CustomPopupPlacementCallbackProperty =
+            Popup.CustomPopupPlacementCallbackProperty.AddOwner<PopupFlyoutBase>();
+
         /// <summary>
         /// Defines the <see cref="ShowMode"/> property
         /// </summary>
@@ -112,6 +116,13 @@ public double VerticalOffset
             set => SetValue(VerticalOffsetProperty, value);
         }
 
+        /// <inheritdoc cref="Popup.CustomPopupPlacementCallback"/>
+        public CustomPopupPlacementCallback? CustomPopupPlacementCallback
+        {
+            get => GetValue(CustomPopupPlacementCallbackProperty);
+            set => SetValue(CustomPopupPlacementCallbackProperty, value);
+        }
+
         /// <summary>
         /// Gets or sets the desired ShowMode
         /// </summary>
@@ -445,6 +456,7 @@ private void PositionPopup(bool showAtPointer)
             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 20e4e5470d5..93116d8f1a2 100644
--- a/src/Avalonia.Controls/PlacementMode.cs
+++ b/src/Avalonia.Controls/PlacementMode.cs
@@ -81,6 +81,11 @@ public enum PlacementMode
         /// <summary>
         /// Preferred location is to the right of the target element, with the bottom edge of popup aligned with bottom edge of the target element.
         /// </summary>
-        RightEdgeAlignedBottom
+        RightEdgeAlignedBottom,
+
+        /// <summary>
+        /// A position and repositioning behavior that is defined by the <see cref="Popup.CustomPopupPlacementCallback"/> property.
+        /// </summary>
+        Custom
     }
 }
diff --git a/src/Avalonia.Controls/Primitives/IPopupHost.cs b/src/Avalonia.Controls/Primitives/IPopupHost.cs
index 0aad838b0f4..4cb259db5f7 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 <see cref="OverlayLayer"/>.
     /// </remarks>
     [NotClientImplementable]
+    [Unstable(ObsoletionMessages.MayBeRemovedInAvalonia12)]
     public interface IPopupHost : IDisposable, IFocusScope
     {
         /// <summary>
@@ -79,20 +81,7 @@ public interface IPopupHost : IDisposable, IFocusScope
         /// Configures the position of the popup according to a target control and a set of
         /// placement parameters.
         /// </summary>
-        /// <param name="target">The placement target.</param>
-        /// <param name="placement">The placement mode.</param>
-        /// <param name="offset">The offset, in device-independent pixels.</param>
-        /// <param name="anchor">The anchor point.</param>
-        /// <param name="gravity">The popup gravity.</param>
-        /// <param name="constraintAdjustment">Defines how a popup position will be adjusted if the unadjusted position would result in the popup being partly constrained.</param>
-        /// <param name="rect">
-        /// The anchor rect. If null, the bounds of <paramref name="target"/> will be used.
-        /// </param>
-        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);
 
         /// <summary>
         /// 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 43bb9b2947f..3a602c15b7d 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 @@ public class OverlayPopupHost : ContentControl, IPopupHost, IManagedPopupPositio
 
         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 @@ public void Hide()
         }
 
         /// <inheritdoc />
+        [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));
+        }
+
+        /// <inheritdoc />
+        void IPopupHost.ConfigurePosition(PopupPositionRequest positionRequest)
+        {
+            _popupPositionRequest = positionRequest;
+            _needsUpdate = true;
             UpdatePosition();
         }
 
         /// <inheritdoc />
         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 4c726e91839..28e2a51e069 100644
--- a/src/Avalonia.Controls/Primitives/Popup.cs
+++ b/src/Avalonia.Controls/Primitives/Popup.cs
@@ -22,6 +22,9 @@ namespace Avalonia.Controls.Primitives
     /// </summary>
     public class Popup : Control, IPopupHostProvider
     {
+        /// <summary>
+        /// Defines the <see cref="WindowManagerAddShadowHint"/> property.
+        /// </summary>
         public static readonly StyledProperty<bool> WindowManagerAddShadowHintProperty =
             AvaloniaProperty.Register<Popup, bool>(nameof(WindowManagerAddShadowHint), false);
 
@@ -89,9 +92,21 @@ public class Popup : Control, IPopupHostProvider
         public static readonly StyledProperty<Control?> PlacementTargetProperty =
             AvaloniaProperty.Register<Popup, Control?>(nameof(PlacementTarget));
 
+        /// <summary>
+        /// Defines the <see cref="CustomPopupPlacementCallback"/> property.
+        /// </summary>
+        public static readonly StyledProperty<CustomPopupPlacementCallback?> CustomPopupPlacementCallbackProperty =
+            AvaloniaProperty.Register<Popup, CustomPopupPlacementCallback?>(nameof(CustomPopupPlacementCallback));
+        
+        /// <summary>
+        /// Defines the <see cref="OverlayDismissEventPassThrough"/> property.
+        /// </summary>
         public static readonly StyledProperty<bool> OverlayDismissEventPassThroughProperty =
             AvaloniaProperty.Register<Popup, bool>(nameof(OverlayDismissEventPassThrough));
 
+        /// <summary>
+        /// Defines the <see cref="OverlayInputPassThroughElement"/> property.
+        /// </summary>
         public static readonly StyledProperty<IInputElement?> OverlayInputPassThroughElementProperty =
             AvaloniaProperty.Register<Popup, IInputElement?>(nameof(OverlayInputPassThroughElement));
 
@@ -287,6 +302,15 @@ public Control? PlacementTarget
             set => SetValue(PlacementTargetProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets a delegate handler method that positions the Popup control, when <see cref="Placement"/> is set to <see cref="PlacementMode.Custom"/>.
+        /// </summary>
+        public CustomPopupPlacementCallback? CustomPopupPlacementCallback
+        {
+            get => GetValue(CustomPopupPlacementCallbackProperty);
+            set => SetValue(CustomPopupPlacementCallbackProperty, value);
+        }
+
         /// <summary>
         /// Gets or sets a value indicating whether the event that closes the popup is passed
         /// through to the parent window.
@@ -603,14 +627,15 @@ internal void SetPopupParent(Control? newParent)
 
         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 @@ private void HandlePositionChange()
                 var placementTarget = PlacementTarget ?? this.FindLogicalAncestorOfType<Control>();
                 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 00000000000..8be812b4eae
--- /dev/null
+++ b/src/Avalonia.Controls/Primitives/PopupPositioning/CustomPopupPlacement.cs
@@ -0,0 +1,57 @@
+﻿namespace Avalonia.Controls.Primitives.PopupPositioning;
+
+/// <summary>
+/// Defines custom placement parameters for a <see cref="CustomPopupPlacementCallback"/> callback.
+/// </summary>
+public record CustomPopupPlacement
+{
+    private PopupGravity _gravity;
+    private PopupAnchor _anchor;
+
+    internal CustomPopupPlacement(Size popupSize, Visual target)
+    {
+        PopupSize = popupSize;
+        Target = target;
+    }
+
+    /// <summary>
+    /// The <see cref="Size"/> of the <see cref="Popup"/> control.
+    /// </summary>
+    public Size PopupSize { get; }
+
+    /// <summary>
+    /// Placement target of the popup.
+    /// </summary>
+    public Visual Target { get; }
+
+    /// <see cref="PopupPositionerParameters.AnchorRectangle"/>
+    public Rect AnchorRectangle { get; set; }
+
+    /// <see cref="PopupPositionerParameters.Anchor"/>
+    public PopupAnchor Anchor
+    {
+        get => _anchor;
+        set
+        {
+            PopupPositioningEdgeHelper.ValidateEdge(value);
+            _anchor = value;
+        }
+    }
+
+    /// <see cref="PopupPositionerParameters.Gravity"/>
+    public PopupGravity Gravity
+    {
+        get => _gravity;
+        set
+        {
+            PopupPositioningEdgeHelper.ValidateGravity(value);
+            _gravity = value;
+        }
+    }
+
+    /// <see cref="PopupPositionerParameters.ConstraintAdjustment"/>
+    public PopupPositionerConstraintAdjustment ConstraintAdjustment { get; set; }
+
+    /// <see cref="PopupPositionerParameters.Offset"/>
+    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 00000000000..dbe90d8d32e
--- /dev/null
+++ b/src/Avalonia.Controls/Primitives/PopupPositioning/CustomPopupPlacementCallback.cs
@@ -0,0 +1,6 @@
+namespace Avalonia.Controls.Primitives.PopupPositioning;
+
+/// <summary>
+/// Represents a method that provides custom positioning for a <see cref="Popup"/> control.
+/// </summary>
+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 31c1e6054a4..a0b853f2dc8 100644
--- a/src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs
+++ b/src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs
@@ -45,6 +45,9 @@ DEALINGS IN THE SOFTWARE.
 */
 
 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.
     /// </remarks>
-    [Unstable]
+    [Unstable(ObsoletionMessages.MayBeRemovedInAvalonia12)]
     public record struct PopupPositionerParameters
     {
         private PopupGravity _gravity;
@@ -443,19 +446,35 @@ public interface IPopupPositioner
         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 @@ public static void ConfigurePosition(ref this PopupPositionerParameters position
                 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 @@ public static void ConfigurePosition(ref this PopupPositionerParameters position
                     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 @@ public static void ConfigurePosition(ref this PopupPositionerParameters position
                     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 00000000000..35e15e30570
--- /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 723bdca1b24..b2905b9176c 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 @@ public sealed class PopupRoot : WindowBase, IHostedVisualTreeRoot, IDisposable,
         public static readonly StyledProperty<bool> WindowManagerAddShadowHintProperty =
             Popup.WindowManagerAddShadowHintProperty.AddOwner<PopupRoot>();
 
-        private PopupPositionerParameters _positionerParameters;        
+        private PopupPositionRequest? _popupPositionRequest;
+        private Size _popupSize;
+        private bool _needsUpdate;
 
         /// <summary>
         /// Initializes static members of the <see cref="PopupRoot"/> class.
@@ -124,20 +128,30 @@ public void Dispose()
 
         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 @@ protected override Size MeasureOverride(Size availableSize)
             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 8bf0adedee5..fc8da13131a 100644
--- a/src/Avalonia.Controls/ToolTip.cs
+++ b/src/Avalonia.Controls/ToolTip.cs
@@ -4,6 +4,7 @@
 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 @@ public class ToolTip : ContentControl, IPopupHostProvider
         public static readonly AttachedProperty<double> VerticalOffsetProperty =
             AvaloniaProperty.RegisterAttached<ToolTip, Control, double>("VerticalOffset", 20);
 
+        /// <inheritdoc cref="Popup.CustomPopupPlacementCallbackProperty"/>
+        public static readonly AttachedProperty<CustomPopupPlacementCallback?> CustomPopupPlacementCallbackProperty =
+            AvaloniaProperty.RegisterAttached<ToolTip, Control, CustomPopupPlacementCallback?>("CustomPopupPlacementCallback");
+
         /// <summary>
         /// Defines the ToolTip.ShowDelay property.
         /// </summary>
@@ -327,6 +332,22 @@ public static void AddToolTipClosingHandler(Control element, EventHandler<Routed
         public static void RemoveToolTipClosingHandler(Control element, EventHandler<RoutedEventArgs> handler) =>
             element.RemoveHandler(ToolTipClosingEvent, handler);
 
+        /// <summary>
+        /// Gets the value of the ToolTip.CustomPopupPlacementCallback attached property.
+        /// </summary>
+        public static CustomPopupPlacementCallback? GetCustomPopupPlacementCallback(Control element)
+        {
+            return element.GetValue(CustomPopupPlacementCallbackProperty);
+        }
+
+        /// <summary>
+        /// Sets the value of the ToolTip.CustomPopupPlacementCallback attached property.
+        /// </summary>
+        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 @@ private void Open(Control control)
             {
                 _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 @@ private void Close()
                 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)


Test patch:
diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs
index fb88c983436..38bbc875c50 100644
--- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs
@@ -262,6 +262,7 @@ public void Should_Not_Have_Offset_On_Bounds_When_Content_Larger_Than_Max_Window
                 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 @@ public void MinWidth_MinHeight_Should_Be_Respected()
                     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 @@ public void Setting_Width_Should_Resize_WindowImpl()
                 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 e1ff48a3ccc..1cad1304af6 100644
--- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs
@@ -1206,6 +1206,50 @@ public void Popup_Attached_To_Adorner_Respects_Adorner_Position()
             }
         }
 
+        [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)
         {