Browse Source

Custom popup placement callback (#15667)

* Add CustomPopupPlacement API

* Add Placement="Custom" support for Flyout, ToolTip and ContextMenu controls as well

* Adjust some API changes

* Add Avalonia.Controls.Primitives.IPopupHost.ConfigurePosition breaking change

* Extract new types into separated files

* Fix build after merge conflict

* Adjust nupkg.xml

* Dispose property subscriptions after popup is closed, avoiding flickering

* Adjust API to be more future proof and add new parameters.

* Add new ContextRequestedEventArgs overload while I am on it
pull/16734/head
Max Katz 1 year ago
committed by GitHub
parent
commit
1cfa82ca57
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 12
      api/Avalonia.nupkg.xml
  2. 11
      samples/ControlCatalog/Pages/FlyoutsPage.axaml
  3. 23
      samples/ControlCatalog/Pages/FlyoutsPage.axaml.cs
  4. 55
      samples/ControlCatalog/Pages/ToolTipPage.xaml
  5. 26
      samples/ControlCatalog/Pages/ToolTipPage.xaml.cs
  6. 12
      src/Avalonia.Controls/ContextMenu.cs
  7. 7
      src/Avalonia.Controls/ContextRequestedEventArgs.cs
  8. 14
      src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs
  9. 7
      src/Avalonia.Controls/PlacementMode.cs
  10. 17
      src/Avalonia.Controls/Primitives/IPopupHost.cs
  11. 33
      src/Avalonia.Controls/Primitives/OverlayPopupHost.cs
  12. 34
      src/Avalonia.Controls/Primitives/Popup.cs
  13. 57
      src/Avalonia.Controls/Primitives/PopupPositioning/CustomPopupPlacement.cs
  14. 6
      src/Avalonia.Controls/Primitives/PopupPositioning/CustomPopupPlacementCallback.cs
  15. 120
      src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs
  16. 35
      src/Avalonia.Controls/Primitives/PopupPositioning/PopupPositionRequest.cs
  17. 37
      src/Avalonia.Controls/Primitives/PopupRoot.cs
  18. 28
      src/Avalonia.Controls/ToolTip.cs
  19. 3
      tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs
  20. 44
      tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs

12
api/Avalonia.nupkg.xml

@ -19,6 +19,12 @@
<Left>baseline/netstandard2.0/Avalonia.Base.dll</Left> <Left>baseline/netstandard2.0/Avalonia.Base.dll</Left>
<Right>target/netstandard2.0/Avalonia.Base.dll</Right> <Right>target/netstandard2.0/Avalonia.Base.dll</Right>
</Suppression> </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> <Suppression>
<DiagnosticId>CP0002</DiagnosticId> <DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Controls.Screens.#ctor(Avalonia.Platform.IScreenImpl)</Target> <Target>M:Avalonia.Controls.Screens.#ctor(Avalonia.Platform.IScreenImpl)</Target>
@ -43,6 +49,12 @@
<Left>baseline/netstandard2.0/Avalonia.Controls.dll</Left> <Left>baseline/netstandard2.0/Avalonia.Controls.dll</Left>
<Right>target/netstandard2.0/Avalonia.Controls.dll</Right> <Right>target/netstandard2.0/Avalonia.Controls.dll</Right>
</Suppression> </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> <Suppression>
<DiagnosticId>CP0009</DiagnosticId> <DiagnosticId>CP0009</DiagnosticId>
<Target>T:Avalonia.Controls.Screens</Target> <Target>T:Avalonia.Controls.Screens</Target>

11
samples/ControlCatalog/Pages/FlyoutsPage.axaml

@ -222,7 +222,15 @@
</Flyout> </Flyout>
</Button.Flyout> </Button.Flyout>
</Button> </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> </UniformGrid>
</Border> </Border>
</StackPanel> </StackPanel>
@ -267,7 +275,6 @@
</Flyout> </Flyout>
</Button.Flyout> </Button.Flyout>
</Button> </Button>
</WrapPanel> </WrapPanel>
</Border> </Border>
</StackPanel> </StackPanel>

23
samples/ControlCatalog/Pages/FlyoutsPage.axaml.cs

@ -1,5 +1,8 @@
using System;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Interactivity; using Avalonia.Interactivity;
namespace ControlCatalog.Pages namespace ControlCatalog.Pages
@ -71,5 +74,25 @@ namespace ControlCatalog.Pages
"Then attach the flyout where you want it:\n" + "Then attach the flyout where you want it:\n" +
"<Button Content=\"Launch Flyout here\" Flyout=\"{StaticResource SharedFlyout}\" />"; "<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);
}
} }
} }

55
samples/ControlCatalog/Pages/ToolTipPage.xaml

@ -5,31 +5,24 @@
Spacing="4"> Spacing="4">
<TextBlock Classes="h2">A control which pops up a hint when a control is hovered</TextBlock> <TextBlock Classes="h2">A control which pops up a hint when a control is hovered</TextBlock>
<Grid RowDefinitions="Auto,Auto,Auto,Auto" <UniformGrid Columns="2"
ColumnDefinitions="Auto,Auto" Margin="0,16,0,0"
Margin="0,16,0,0" HorizontalAlignment="Center">
HorizontalAlignment="Center">
<ToggleSwitch Margin="5" <ToggleSwitch Margin="5"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsChecked="{Binding Path=(ToolTip.ServiceEnabled), RelativeSource={RelativeSource AncestorType=UserControl}}" IsChecked="{Binding Path=(ToolTip.ServiceEnabled), RelativeSource={RelativeSource AncestorType=UserControl}}"
Content="Enable ToolTip service" /> Content="Enable ToolTip service" />
<Border Grid.Column="0" <ToggleSwitch Margin="5"
Grid.Row="1" IsChecked="{Binding ElementName=Border, Path=(ToolTip.IsOpen)}"
Background="{DynamicResource SystemAccentColor}" HorizontalAlignment="Center"
Content="ToolTip Open" />
<Border Background="{DynamicResource SystemAccentColor}"
Margin="5" Margin="5"
Padding="50" Padding="50"
ToolTip.Tip="This is a ToolTip"> ToolTip.Tip="This is a ToolTip">
<TextBlock>Hover Here</TextBlock> <TextBlock>Hover Here</TextBlock>
</Border> </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" <Border Name="Border"
Grid.Column="1"
Grid.Row="1"
Background="{DynamicResource SystemAccentColor}" Background="{DynamicResource SystemAccentColor}"
Margin="5" Margin="5"
Padding="50" Padding="50"
@ -42,8 +35,15 @@
</ToolTip.Tip> </ToolTip.Tip>
<TextBlock>ToolTip bottom placement</TextBlock> <TextBlock>ToolTip bottom placement</TextBlock>
</Border> </Border>
<Border Grid.Row="2" <Border Background="{DynamicResource SystemAccentColor}"
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" Margin="5"
Padding="50" Padding="50"
ToolTip.Tip="Hello" ToolTip.Tip="Hello"
@ -67,8 +67,7 @@
<TextBlock>Moving offset</TextBlock> <TextBlock>Moving offset</TextBlock>
</Border> </Border>
<Button Grid.Row="2" Grid.Column="1" <Button IsEnabled="False"
IsEnabled="False"
ToolTip.ShowOnDisabled="True" ToolTip.ShowOnDisabled="True"
ToolTip.Tip="This control is disabled" ToolTip.Tip="This control is disabled"
Margin="5" Margin="5"
@ -76,24 +75,20 @@
<TextBlock>ToolTip on a disabled control</TextBlock> <TextBlock>ToolTip on a disabled control</TextBlock>
</Button> </Button>
<Border Grid.Row="3" <Border Background="{DynamicResource SystemAccentColor}"
Grid.Column="0"
Background="{DynamicResource SystemAccentColor}"
Margin="5" Margin="5"
Padding="50" Padding="50"
ToolTip.Tip="Outer tooltip"> ToolTip.Tip="Outer tooltip">
<TextBlock Background="{StaticResource SystemAccentColorDark1}" Padding="10" ToolTip.Tip="Inner tooltip" VerticalAlignment="Center">Nested ToolTips</TextBlock> <TextBlock Background="{StaticResource SystemAccentColorDark1}" Padding="10" ToolTip.Tip="Inner tooltip" VerticalAlignment="Center">Nested ToolTips</TextBlock>
</Border> </Border>
<Border Grid.Row="3" <Border Background="{DynamicResource SystemAccentColor}"
Grid.Column="1"
Background="{DynamicResource SystemAccentColor}"
Margin="5" Margin="5"
Padding="50" Padding="50"
ToolTip.ToolTipOpening="ToolTipOpening" ToolTip.ToolTipOpening="ToolTipOpening"
ToolTip.Tip="Should never be visible"> ToolTip.Tip="Should never be visible">
<TextBlock VerticalAlignment="Center">ToolTip replaced on the fly</TextBlock> <TextBlock VerticalAlignment="Center">ToolTip replaced on the fly</TextBlock>
</Border> </Border>
</Grid> </UniformGrid>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

26
samples/ControlCatalog/Pages/ToolTipPage.xaml.cs

@ -1,5 +1,8 @@
using System;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
namespace ControlCatalog.Pages namespace ControlCatalog.Pages
@ -19,6 +22,27 @@ namespace ControlCatalog.Pages
private void ToolTipOpening(object? sender, CancelRoutedEventArgs args) private void ToolTipOpening(object? sender, CancelRoutedEventArgs args)
{ {
((Control)args.Source!).SetValue(ToolTip.TipProperty, "New tip set from ToolTipOpening."); ((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);
}
} }
} }

12
src/Avalonia.Controls/ContextMenu.cs

@ -82,6 +82,10 @@ namespace Avalonia.Controls
public static readonly StyledProperty<Control?> PlacementTargetProperty = public static readonly StyledProperty<Control?> PlacementTargetProperty =
Popup.PlacementTargetProperty.AddOwner<ContextMenu>(); Popup.PlacementTargetProperty.AddOwner<ContextMenu>();
/// <inheritdoc cref="Popup.CustomPopupPlacementCallbackProperty"/>
public static readonly StyledProperty<CustomPopupPlacementCallback?> CustomPopupPlacementCallbackProperty =
Popup.CustomPopupPlacementCallbackProperty.AddOwner<ContextMenu>();
private Popup? _popup; private Popup? _popup;
private List<Control>? _attachedControls; private List<Control>? _attachedControls;
private IInputElement? _previousFocus; private IInputElement? _previousFocus;
@ -185,6 +189,13 @@ namespace Avalonia.Controls
set => SetValue(PlacementTargetProperty, value); set => SetValue(PlacementTargetProperty, value);
} }
/// <inheritdoc cref="Popup.CustomPopupPlacementCallback"/>
public CustomPopupPlacementCallback? CustomPopupPlacementCallback
{
get => GetValue(CustomPopupPlacementCallbackProperty);
set => SetValue(CustomPopupPlacementCallbackProperty, value);
}
/// <summary> /// <summary>
/// Occurs when the value of the /// Occurs when the value of the
/// <see cref="P:Avalonia.Controls.ContextMenu.IsOpen" /> /// <see cref="P:Avalonia.Controls.ContextMenu.IsOpen" />
@ -340,6 +351,7 @@ namespace Avalonia.Controls
_popup.PlacementConstraintAdjustment = PlacementConstraintAdjustment; _popup.PlacementConstraintAdjustment = PlacementConstraintAdjustment;
_popup.PlacementGravity = PlacementGravity; _popup.PlacementGravity = PlacementGravity;
_popup.PlacementRect = PlacementRect; _popup.PlacementRect = PlacementRect;
_popup.CustomPopupPlacementCallback = CustomPopupPlacementCallback;
_popup.WindowManagerAddShadowHint = WindowManagerAddShadowHint; _popup.WindowManagerAddShadowHint = WindowManagerAddShadowHint;
IsOpen = true; IsOpen = true;
_popup.IsOpen = true; _popup.IsOpen = true;

7
src/Avalonia.Controls/ContextRequestedEventArgs.cs

@ -26,6 +26,13 @@ namespace Avalonia.Controls
_pointerEventArgs = pointerEventArgs; _pointerEventArgs = pointerEventArgs;
} }
/// <inheritdoc cref="ContextRequestedEventArgs()" />
public ContextRequestedEventArgs(ContextRequestedEventArgs contextRequestedEventArgs)
: this()
{
_pointerEventArgs = contextRequestedEventArgs._pointerEventArgs;
}
/// <summary> /// <summary>
/// Gets the x- and y-coordinates of the pointer position, optionally evaluated against a coordinate origin of a supplied <see cref="Control"/>. /// Gets the x- and y-coordinates of the pointer position, optionally evaluated against a coordinate origin of a supplied <see cref="Control"/>.
/// </summary> /// </summary>

14
src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs

@ -30,11 +30,15 @@ namespace Avalonia.Controls.Primitives
/// <inheritdoc cref="Popup.PlacementAnchorProperty"/> /// <inheritdoc cref="Popup.PlacementAnchorProperty"/>
public static readonly StyledProperty<PopupAnchor> PlacementAnchorProperty = public static readonly StyledProperty<PopupAnchor> PlacementAnchorProperty =
Popup.PlacementAnchorProperty.AddOwner<PopupFlyoutBase>(); Popup.PlacementAnchorProperty.AddOwner<PopupFlyoutBase>();
/// <inheritdoc cref="Popup.PlacementAnchorProperty"/> /// <inheritdoc cref="Popup.PlacementAnchorProperty"/>
public static readonly StyledProperty<PopupGravity> PlacementGravityProperty = public static readonly StyledProperty<PopupGravity> PlacementGravityProperty =
Popup.PlacementGravityProperty.AddOwner<PopupFlyoutBase>(); Popup.PlacementGravityProperty.AddOwner<PopupFlyoutBase>();
/// <inheritdoc cref="Popup.CustomPopupPlacementCallbackProperty"/>
public static readonly StyledProperty<CustomPopupPlacementCallback?> CustomPopupPlacementCallbackProperty =
Popup.CustomPopupPlacementCallbackProperty.AddOwner<PopupFlyoutBase>();
/// <summary> /// <summary>
/// Defines the <see cref="ShowMode"/> property /// Defines the <see cref="ShowMode"/> property
/// </summary> /// </summary>
@ -112,6 +116,13 @@ namespace Avalonia.Controls.Primitives
set => SetValue(VerticalOffsetProperty, value); set => SetValue(VerticalOffsetProperty, value);
} }
/// <inheritdoc cref="Popup.CustomPopupPlacementCallback"/>
public CustomPopupPlacementCallback? CustomPopupPlacementCallback
{
get => GetValue(CustomPopupPlacementCallbackProperty);
set => SetValue(CustomPopupPlacementCallbackProperty, value);
}
/// <summary> /// <summary>
/// Gets or sets the desired ShowMode /// Gets or sets the desired ShowMode
/// </summary> /// </summary>
@ -445,6 +456,7 @@ namespace Avalonia.Controls.Primitives
Popup.HorizontalOffset = HorizontalOffset; Popup.HorizontalOffset = HorizontalOffset;
Popup.PlacementAnchor = PlacementAnchor; Popup.PlacementAnchor = PlacementAnchor;
Popup.PlacementGravity = PlacementGravity; Popup.PlacementGravity = PlacementGravity;
Popup.CustomPopupPlacementCallback = CustomPopupPlacementCallback;
if (showAtPointer) if (showAtPointer)
{ {
Popup.Placement = PlacementMode.Pointer; Popup.Placement = PlacementMode.Pointer;

7
src/Avalonia.Controls/PlacementMode.cs

@ -81,6 +81,11 @@ namespace Avalonia.Controls
/// <summary> /// <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. /// 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> /// </summary>
RightEdgeAlignedBottom RightEdgeAlignedBottom,
/// <summary>
/// A position and repositioning behavior that is defined by the <see cref="Popup.CustomPopupPlacementCallback"/> property.
/// </summary>
Custom
} }
} }

17
src/Avalonia.Controls/Primitives/IPopupHost.cs

@ -1,6 +1,7 @@
using System; using System;
using Avalonia.Controls.Presenters; using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Diagnostics;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Metadata; using Avalonia.Metadata;
@ -17,6 +18,7 @@ namespace Avalonia.Controls.Primitives
/// on an <see cref="OverlayLayer"/>. /// on an <see cref="OverlayLayer"/>.
/// </remarks> /// </remarks>
[NotClientImplementable] [NotClientImplementable]
[Unstable(ObsoletionMessages.MayBeRemovedInAvalonia12)]
public interface IPopupHost : IDisposable, IFocusScope public interface IPopupHost : IDisposable, IFocusScope
{ {
/// <summary> /// <summary>
@ -79,20 +81,7 @@ namespace Avalonia.Controls.Primitives
/// Configures the position of the popup according to a target control and a set of /// Configures the position of the popup according to a target control and a set of
/// placement parameters. /// placement parameters.
/// </summary> /// </summary>
/// <param name="target">The placement target.</param> void ConfigurePosition(PopupPositionRequest positionRequest);
/// <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);
/// <summary> /// <summary>
/// Sets the control to display in the popup. /// Sets the control to display in the popup.

33
src/Avalonia.Controls/Primitives/OverlayPopupHost.cs

@ -1,10 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Diagnostics;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Metadata; using Avalonia.Metadata;
using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
namespace Avalonia.Controls.Primitives namespace Avalonia.Controls.Primitives
@ -19,9 +19,10 @@ namespace Avalonia.Controls.Primitives
private readonly OverlayLayer _overlayLayer; private readonly OverlayLayer _overlayLayer;
private readonly ManagedPopupPositioner _positioner; private readonly ManagedPopupPositioner _positioner;
private PopupPositionerParameters _positionerParameters;
private Point _lastRequestedPosition; private Point _lastRequestedPosition;
private bool _shown; private PopupPositionRequest? _popupPositionRequest;
private Size _popupSize;
private bool _shown, _needsUpdate;
public OverlayPopupHost(OverlayLayer overlayLayer) public OverlayPopupHost(OverlayLayer overlayLayer)
{ {
@ -73,36 +74,42 @@ namespace Avalonia.Controls.Primitives
} }
/// <inheritdoc /> /// <inheritdoc />
[Unstable(ObsoletionMessages.MayBeRemovedInAvalonia12)]
public void ConfigurePosition(Visual target, PlacementMode placement, Point offset, public void ConfigurePosition(Visual target, PlacementMode placement, Point offset,
PopupAnchor anchor = PopupAnchor.None, PopupGravity gravity = PopupGravity.None, PopupAnchor anchor = PopupAnchor.None, PopupGravity gravity = PopupGravity.None,
PopupPositionerConstraintAdjustment constraintAdjustment = PopupPositionerConstraintAdjustment.All, PopupPositionerConstraintAdjustment constraintAdjustment = PopupPositionerConstraintAdjustment.All,
Rect? rect = null) Rect? rect = null)
{ {
_positionerParameters.ConfigurePosition((TopLevel)_overlayLayer.GetVisualRoot()!, target, placement, offset, anchor, ((IPopupHost)this).ConfigurePosition(new PopupPositionRequest(target, placement, offset, anchor, gravity,
gravity, constraintAdjustment, rect, FlowDirection); constraintAdjustment, rect, null));
}
/// <inheritdoc />
void IPopupHost.ConfigurePosition(PopupPositionRequest positionRequest)
{
_popupPositionRequest = positionRequest;
_needsUpdate = true;
UpdatePosition(); UpdatePosition();
} }
/// <inheritdoc /> /// <inheritdoc />
protected override Size ArrangeOverride(Size finalSize) protected override Size ArrangeOverride(Size finalSize)
{ {
if (_positionerParameters.Size != finalSize) if (_popupSize != finalSize)
{ {
_positionerParameters.Size = finalSize; _popupSize = finalSize;
_needsUpdate = true;
UpdatePosition(); UpdatePosition();
} }
return base.ArrangeOverride(finalSize); return base.ArrangeOverride(finalSize);
} }
private void UpdatePosition() private void UpdatePosition()
{ {
// Don't bother the positioner with layout system artifacts if (_needsUpdate && _popupPositionRequest is not null)
if (_positionerParameters.Size.Width == 0 || _positionerParameters.Size.Height == 0)
return;
if (_shown)
{ {
_positioner.Update(_positionerParameters); _needsUpdate = false;
_positioner.Update(TopLevel.GetTopLevel(_overlayLayer)!, _popupPositionRequest, _popupSize, FlowDirection);
} }
} }

34
src/Avalonia.Controls/Primitives/Popup.cs

@ -22,6 +22,9 @@ namespace Avalonia.Controls.Primitives
/// </summary> /// </summary>
public class Popup : Control, IPopupHostProvider public class Popup : Control, IPopupHostProvider
{ {
/// <summary>
/// Defines the <see cref="WindowManagerAddShadowHint"/> property.
/// </summary>
public static readonly StyledProperty<bool> WindowManagerAddShadowHintProperty = public static readonly StyledProperty<bool> WindowManagerAddShadowHintProperty =
AvaloniaProperty.Register<Popup, bool>(nameof(WindowManagerAddShadowHint), false); AvaloniaProperty.Register<Popup, bool>(nameof(WindowManagerAddShadowHint), false);
@ -89,9 +92,21 @@ namespace Avalonia.Controls.Primitives
public static readonly StyledProperty<Control?> PlacementTargetProperty = public static readonly StyledProperty<Control?> PlacementTargetProperty =
AvaloniaProperty.Register<Popup, Control?>(nameof(PlacementTarget)); 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 = public static readonly StyledProperty<bool> OverlayDismissEventPassThroughProperty =
AvaloniaProperty.Register<Popup, bool>(nameof(OverlayDismissEventPassThrough)); AvaloniaProperty.Register<Popup, bool>(nameof(OverlayDismissEventPassThrough));
/// <summary>
/// Defines the <see cref="OverlayInputPassThroughElement"/> property.
/// </summary>
public static readonly StyledProperty<IInputElement?> OverlayInputPassThroughElementProperty = public static readonly StyledProperty<IInputElement?> OverlayInputPassThroughElementProperty =
AvaloniaProperty.Register<Popup, IInputElement?>(nameof(OverlayInputPassThroughElement)); AvaloniaProperty.Register<Popup, IInputElement?>(nameof(OverlayInputPassThroughElement));
@ -287,6 +302,15 @@ namespace Avalonia.Controls.Primitives
set => SetValue(PlacementTargetProperty, value); 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> /// <summary>
/// Gets or sets a value indicating whether the event that closes the popup is passed /// Gets or sets a value indicating whether the event that closes the popup is passed
/// through to the parent window. /// through to the parent window.
@ -603,14 +627,15 @@ namespace Avalonia.Controls.Primitives
private void UpdateHostPosition(IPopupHost popupHost, Control placementTarget) private void UpdateHostPosition(IPopupHost popupHost, Control placementTarget)
{ {
popupHost.ConfigurePosition( popupHost.ConfigurePosition(new PopupPositionRequest(
placementTarget, placementTarget,
Placement, Placement,
new Point(HorizontalOffset, VerticalOffset), new Point(HorizontalOffset, VerticalOffset),
PlacementAnchor, PlacementAnchor,
PlacementGravity, PlacementGravity,
PlacementConstraintAdjustment, 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) private void UpdateHostSizing(IPopupHost popupHost, TopLevel topLevel, Control placementTarget)
@ -651,14 +676,15 @@ namespace Avalonia.Controls.Primitives
var placementTarget = PlacementTarget ?? this.FindLogicalAncestorOfType<Control>(); var placementTarget = PlacementTarget ?? this.FindLogicalAncestorOfType<Control>();
if (placementTarget == null) if (placementTarget == null)
return; return;
_openState.PopupHost.ConfigurePosition( _openState.PopupHost.ConfigurePosition(new PopupPositionRequest(
placementTarget, placementTarget,
Placement, Placement,
new Point(HorizontalOffset, VerticalOffset), new Point(HorizontalOffset, VerticalOffset),
PlacementAnchor, PlacementAnchor,
PlacementGravity, PlacementGravity,
PlacementConstraintAdjustment, PlacementConstraintAdjustment,
PlacementRect); PlacementRect,
CustomPopupPlacementCallback));
} }
} }

57
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; }
}

6
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);

120
src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs

@ -45,6 +45,9 @@ Copyright © 2019 Nikita Tsukanov
*/ */
using System; using System;
using System.ComponentModel;
using System.Diagnostics;
using Avalonia.Diagnostics;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Metadata; using Avalonia.Metadata;
using Avalonia.VisualTree; 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 /// requirement that a popup must intersect with or be at least partially adjacent to its parent
/// surface. /// surface.
/// </remarks> /// </remarks>
[Unstable] [Unstable(ObsoletionMessages.MayBeRemovedInAvalonia12)]
public record struct PopupPositionerParameters public record struct PopupPositionerParameters
{ {
private PopupGravity _gravity; private PopupGravity _gravity;
@ -443,19 +446,35 @@ namespace Avalonia.Controls.Primitives.PopupPositioning
void Update(PopupPositionerParameters parameters); void Update(PopupPositionerParameters parameters);
} }
[Unstable] internal static class PopupPositionerExtensions
static class PopupPositionerExtensions
{ {
public static void ConfigurePosition(ref this PopupPositionerParameters positionerParameters, public static void Update(
this IPopupPositioner positioner,
TopLevel topLevel, TopLevel topLevel,
Visual target, PlacementMode placement, Point offset, PopupPositionRequest positionRequest,
PopupAnchor anchor, PopupGravity gravity, Size popupSize,
PopupPositionerConstraintAdjustment constraintAdjustment, Rect? rect,
FlowDirection flowDirection) FlowDirection flowDirection)
{ {
positionerParameters.Offset = offset; if (popupSize == default)
positionerParameters.ConstraintAdjustment = constraintAdjustment; {
if (placement == PlacementMode.Pointer) 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 // We need a better way for tracking the last pointer position
var position = topLevel.PointToClient(topLevel.LastPointerPosition ?? default); var position = topLevel.PointToClient(topLevel.LastPointerPosition ?? default);
@ -464,39 +483,45 @@ namespace Avalonia.Controls.Primitives.PopupPositioning
positionerParameters.Anchor = PopupAnchor.TopLeft; positionerParameters.Anchor = PopupAnchor.TopLeft;
positionerParameters.Gravity = PopupGravity.BottomRight; positionerParameters.Gravity = PopupGravity.BottomRight;
} }
else else if (positionRequest.Placement == PlacementMode.Custom)
{ {
if (target == null) if (positionRequest.PlacementCallback is null)
throw new InvalidOperationException("Placement mode is not Pointer and PlacementTarget is null"); throw new InvalidOperationException(
Matrix? matrix; "CustomPopupPlacementCallback property must be set, when Placement=PlacementMode.Custom");
if (TryGetAdorner(target, out var adorned, out var adornerLayer))
{
matrix = adorned!.TransformToVisual(topLevel) * target.TransformToVisual(adornerLayer!);
}
else
{
matrix = target.TransformToVisual(topLevel);
}
if (matrix == null) positionerParameters.AnchorRectangle = CalculateAnchorRect(topLevel, positionRequest);
var customPlacementParameters = new CustomPopupPlacement(
popupSize,
positionRequest.Target)
{ {
if (target.GetVisualRoot() == null) AnchorRectangle = positionerParameters.AnchorRectangle,
throw new InvalidOperationException("Target control is not attached to the visual tree"); Anchor = positionerParameters.Anchor,
throw new InvalidOperationException("Target control is not in the same tree as the popup parent"); Gravity = positionerParameters.Gravity,
} ConstraintAdjustment = positionerParameters.ConstraintAdjustment,
Offset = positionerParameters.Offset
};
var bounds = new Rect(default, target.Bounds.Size); positionRequest.PlacementCallback.Invoke(customPlacementParameters);
var anchorRect = rect ?? bounds;
positionerParameters.AnchorRectangle = anchorRect.Intersect(bounds).TransformToAABB(matrix.Value);
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.Bottom => (PopupAnchor.Bottom, PopupGravity.Bottom),
PlacementMode.Right => (PopupAnchor.Right, PopupGravity.Right), PlacementMode.Right => (PopupAnchor.Right, PopupGravity.Right),
PlacementMode.Left => (PopupAnchor.Left, PopupGravity.Left), PlacementMode.Left => (PopupAnchor.Left, PopupGravity.Left),
PlacementMode.Top => (PopupAnchor.Top, PopupGravity.Top), PlacementMode.Top => (PopupAnchor.Top, PopupGravity.Top),
PlacementMode.Center => (PopupAnchor.None, PopupGravity.None), PlacementMode.Center => (PopupAnchor.None, PopupGravity.None),
PlacementMode.AnchorAndGravity => (anchor, gravity), PlacementMode.AnchorAndGravity => (positionRequest.Anchor, positionRequest.Gravity),
PlacementMode.TopEdgeAlignedRight => (PopupAnchor.TopRight, PopupGravity.TopLeft), PlacementMode.TopEdgeAlignedRight => (PopupAnchor.TopRight, PopupGravity.TopLeft),
PlacementMode.TopEdgeAlignedLeft => (PopupAnchor.TopLeft, PopupGravity.TopRight), PlacementMode.TopEdgeAlignedLeft => (PopupAnchor.TopLeft, PopupGravity.TopRight),
PlacementMode.BottomEdgeAlignedLeft => (PopupAnchor.BottomLeft, PopupGravity.BottomRight), PlacementMode.BottomEdgeAlignedLeft => (PopupAnchor.BottomLeft, PopupGravity.BottomRight),
@ -505,7 +530,7 @@ namespace Avalonia.Controls.Primitives.PopupPositioning
PlacementMode.LeftEdgeAlignedBottom => (PopupAnchor.BottomLeft, PopupGravity.TopLeft), PlacementMode.LeftEdgeAlignedBottom => (PopupAnchor.BottomLeft, PopupGravity.TopLeft),
PlacementMode.RightEdgeAlignedTop => (PopupAnchor.TopRight, PopupGravity.BottomRight), PlacementMode.RightEdgeAlignedTop => (PopupAnchor.TopRight, PopupGravity.BottomRight),
PlacementMode.RightEdgeAlignedBottom => (PopupAnchor.BottomRight, PopupGravity.TopRight), 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") "Invalid value for Popup.PlacementMode")
}; };
positionerParameters.Anchor = parameters.Item1; positionerParameters.Anchor = parameters.Item1;
@ -537,6 +562,35 @@ namespace Avalonia.Controls.Primitives.PopupPositioning
positionerParameters.Gravity |= PopupGravity.Right; 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) private static bool TryGetAdorner(Visual target, out Visual? adorned, out Visual? adornerLayer)

35
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;}
}

37
src/Avalonia.Controls/Primitives/PopupRoot.cs

@ -1,8 +1,10 @@
using System; using System;
using Avalonia.Automation.Peers; using Avalonia.Automation.Peers;
using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Diagnostics;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Metadata;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.VisualTree; using Avalonia.VisualTree;
@ -26,7 +28,9 @@ namespace Avalonia.Controls.Primitives
public static readonly StyledProperty<bool> WindowManagerAddShadowHintProperty = public static readonly StyledProperty<bool> WindowManagerAddShadowHintProperty =
Popup.WindowManagerAddShadowHintProperty.AddOwner<PopupRoot>(); Popup.WindowManagerAddShadowHintProperty.AddOwner<PopupRoot>();
private PopupPositionerParameters _positionerParameters; private PopupPositionRequest? _popupPositionRequest;
private Size _popupSize;
private bool _needsUpdate;
/// <summary> /// <summary>
/// Initializes static members of the <see cref="PopupRoot"/> class. /// Initializes static members of the <see cref="PopupRoot"/> class.
@ -124,20 +128,30 @@ namespace Avalonia.Controls.Primitives
private void UpdatePosition() 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, public void ConfigurePosition(Visual target, PlacementMode placement, Point offset,
PopupAnchor anchor = PopupAnchor.None, PopupAnchor anchor = PopupAnchor.None,
PopupGravity gravity = PopupGravity.None, PopupGravity gravity = PopupGravity.None,
PopupPositionerConstraintAdjustment constraintAdjustment = PopupPositionerConstraintAdjustment.All, PopupPositionerConstraintAdjustment constraintAdjustment = PopupPositionerConstraintAdjustment.All,
Rect? rect = null) Rect? rect = null)
{ {
_positionerParameters.ConfigurePosition(ParentTopLevel, target, ((IPopupHost)this).ConfigurePosition(new PopupPositionRequest(target, placement, offset, anchor, gravity,
placement, offset, anchor, gravity, constraintAdjustment, rect, FlowDirection); constraintAdjustment, rect, null));
}
if (_positionerParameters.Size != default) void IPopupHost.ConfigurePosition(PopupPositionRequest request)
UpdatePosition(); {
_popupPositionRequest = request;
_needsUpdate = true;
UpdatePosition();
} }
public void SetChild(Control? control) => Content = control; public void SetChild(Control? control) => Content = control;
@ -184,10 +198,15 @@ namespace Avalonia.Controls.Primitives
return new Size(width, height); return new Size(width, height);
} }
protected override sealed Size ArrangeSetBounds(Size size) protected sealed override Size ArrangeSetBounds(Size size)
{ {
_positionerParameters.Size = size; if (_popupSize != size)
UpdatePosition(); {
_popupSize = size;
_needsUpdate = true;
UpdatePosition();
}
return ClientSize; return ClientSize;
} }

28
src/Avalonia.Controls/ToolTip.cs

@ -4,6 +4,7 @@ using Avalonia.Controls.Diagnostics;
using Avalonia.Controls.Metadata; using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Reactive; using Avalonia.Reactive;
using Avalonia.Styling; using Avalonia.Styling;
@ -51,6 +52,10 @@ namespace Avalonia.Controls
public static readonly AttachedProperty<double> VerticalOffsetProperty = public static readonly AttachedProperty<double> VerticalOffsetProperty =
AvaloniaProperty.RegisterAttached<ToolTip, Control, double>("VerticalOffset", 20); AvaloniaProperty.RegisterAttached<ToolTip, Control, double>("VerticalOffset", 20);
/// <inheritdoc cref="Popup.CustomPopupPlacementCallbackProperty"/>
public static readonly AttachedProperty<CustomPopupPlacementCallback?> CustomPopupPlacementCallbackProperty =
AvaloniaProperty.RegisterAttached<ToolTip, Control, CustomPopupPlacementCallback?>("CustomPopupPlacementCallback");
/// <summary> /// <summary>
/// Defines the ToolTip.ShowDelay property. /// Defines the ToolTip.ShowDelay property.
/// </summary> /// </summary>
@ -327,6 +332,22 @@ namespace Avalonia.Controls
public static void RemoveToolTipClosingHandler(Control element, EventHandler<RoutedEventArgs> handler) => public static void RemoveToolTipClosingHandler(Control element, EventHandler<RoutedEventArgs> handler) =>
element.RemoveHandler(ToolTipClosingEvent, 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) private static void IsOpenChanged(AvaloniaPropertyChangedEventArgs e)
{ {
var control = (Control)e.Sender; var control = (Control)e.Sender;
@ -397,7 +418,8 @@ namespace Avalonia.Controls
{ {
_popup.Bind(Popup.HorizontalOffsetProperty, control.GetBindingObservable(HorizontalOffsetProperty)), _popup.Bind(Popup.HorizontalOffsetProperty, control.GetBindingObservable(HorizontalOffsetProperty)),
_popup.Bind(Popup.VerticalOffsetProperty, control.GetBindingObservable(VerticalOffsetProperty)), _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; _popup.PlacementTarget = control;
@ -415,14 +437,14 @@ namespace Avalonia.Controls
adornedControl.RaiseEvent(args); adornedControl.RaiseEvent(args);
} }
_subscriptions?.Dispose();
if (_popup is not null) if (_popup is not null)
{ {
_popup.IsOpen = false; _popup.IsOpen = false;
_popup.SetPopupParent(null); _popup.SetPopupParent(null);
_popup.PlacementTarget = null; _popup.PlacementTarget = null;
} }
_subscriptions?.Dispose();
} }
private void OnPopupClosed(object? sender, EventArgs e) private void OnPopupClosed(object? sender, EventArgs e)

3
tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs

@ -262,6 +262,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
var target = CreateTarget(window, popupImpl.Object); var target = CreateTarget(window, popupImpl.Object);
target.Content = child; target.Content = child;
((IPopupHost)target).ConfigurePosition(new PopupPositionRequest(window, PlacementMode.Top));
target.Show(); target.Show();
Assert.Equal(new Size(400, 1024), target.Bounds.Size); Assert.Equal(new Size(400, 1024), target.Bounds.Size);
@ -290,6 +291,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
Height = 100, Height = 100,
}; };
((IPopupHost)target).ConfigurePosition(new PopupPositionRequest(window, PlacementMode.Top));
target.Show(); target.Show();
Assert.Equal(new Rect(0, 0, 400, 800), target.Bounds); Assert.Equal(new Rect(0, 0, 400, 800), target.Bounds);
@ -313,6 +315,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
target.Width = 400; target.Width = 400;
target.Height = 800; target.Height = 800;
((IPopupHost)target).ConfigurePosition(new PopupPositionRequest(window, PlacementMode.Top));
target.Show(); target.Show();
Assert.Equal(400, target.Width); Assert.Equal(400, target.Width);

44
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) private static PopupRoot CreateRoot(TopLevel popupParent, IPopupImpl impl = null)
{ {

Loading…
Cancel
Save