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>
<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>

11
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>

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

@ -1,5 +1,8 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Interactivity;
namespace ControlCatalog.Pages
@ -71,5 +74,25 @@ namespace ControlCatalog.Pages
"Then attach the flyout where you want it:\n" +
"<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">
<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>

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

@ -1,5 +1,8 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Markup.Xaml;
namespace ControlCatalog.Pages
@ -19,6 +22,27 @@ namespace ControlCatalog.Pages
private void ToolTipOpening(object? sender, CancelRoutedEventArgs args)
{
((Control)args.Source!).SetValue(ToolTip.TipProperty, "New tip set from ToolTipOpening.");
}
}
public void CustomPlacementCallback(CustomPopupPlacement placement)
{
var r = new Random().Next();
placement.Anchor = (r % 4) switch
{
1 => PopupAnchor.Top,
2 => PopupAnchor.Left,
3 => PopupAnchor.Right,
_ => PopupAnchor.Bottom,
};
placement.Gravity = (r % 4) switch
{
1 => PopupGravity.Top,
2 => PopupGravity.Left,
3 => PopupGravity.Right,
_ => PopupGravity.Bottom,
};
placement.Offset = new Point(r % 20, r % 20);
}
}
}

12
src/Avalonia.Controls/ContextMenu.cs

@ -82,6 +82,10 @@ namespace Avalonia.Controls
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 @@ namespace Avalonia.Controls
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 @@ namespace Avalonia.Controls
_popup.PlacementConstraintAdjustment = PlacementConstraintAdjustment;
_popup.PlacementGravity = PlacementGravity;
_popup.PlacementRect = PlacementRect;
_popup.CustomPopupPlacementCallback = CustomPopupPlacementCallback;
_popup.WindowManagerAddShadowHint = WindowManagerAddShadowHint;
IsOpen = true;
_popup.IsOpen = true;

7
src/Avalonia.Controls/ContextRequestedEventArgs.cs

@ -26,6 +26,13 @@ namespace Avalonia.Controls
_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>

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

@ -30,11 +30,15 @@ namespace Avalonia.Controls.Primitives
/// <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 @@ namespace Avalonia.Controls.Primitives
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 @@ namespace Avalonia.Controls.Primitives
Popup.HorizontalOffset = HorizontalOffset;
Popup.PlacementAnchor = PlacementAnchor;
Popup.PlacementGravity = PlacementGravity;
Popup.CustomPopupPlacementCallback = CustomPopupPlacementCallback;
if (showAtPointer)
{
Popup.Placement = PlacementMode.Pointer;

7
src/Avalonia.Controls/PlacementMode.cs

@ -81,6 +81,11 @@ namespace Avalonia.Controls
/// <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
}
}

17
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 @@ namespace Avalonia.Controls.Primitives
/// 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.

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

@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Diagnostics;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Metadata;
using Avalonia.Threading;
using Avalonia.VisualTree;
namespace Avalonia.Controls.Primitives
@ -19,9 +19,10 @@ namespace Avalonia.Controls.Primitives
private readonly OverlayLayer _overlayLayer;
private readonly ManagedPopupPositioner _positioner;
private PopupPositionerParameters _positionerParameters;
private Point _lastRequestedPosition;
private bool _shown;
private PopupPositionRequest? _popupPositionRequest;
private Size _popupSize;
private bool _shown, _needsUpdate;
public OverlayPopupHost(OverlayLayer overlayLayer)
{
@ -73,36 +74,42 @@ namespace Avalonia.Controls.Primitives
}
/// <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);
}
}

34
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 @@ namespace Avalonia.Controls.Primitives
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 @@ namespace Avalonia.Controls.Primitives
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 @@ namespace Avalonia.Controls.Primitives
private void UpdateHostPosition(IPopupHost popupHost, Control placementTarget)
{
popupHost.ConfigurePosition(
popupHost.ConfigurePosition(new PopupPositionRequest(
placementTarget,
Placement,
new Point(HorizontalOffset, VerticalOffset),
PlacementAnchor,
PlacementGravity,
PlacementConstraintAdjustment,
PlacementRect ?? new Rect(default, placementTarget.Bounds.Size));
PlacementRect ?? new Rect(default, placementTarget.Bounds.Size),
CustomPopupPlacementCallback));
}
private void UpdateHostSizing(IPopupHost popupHost, TopLevel topLevel, Control placementTarget)
@ -651,14 +676,15 @@ namespace Avalonia.Controls.Primitives
var placementTarget = PlacementTarget ?? this.FindLogicalAncestorOfType<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));
}
}

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

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 Avalonia.Automation.Peers;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Diagnostics;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Metadata;
using Avalonia.Platform;
using Avalonia.Styling;
using Avalonia.VisualTree;
@ -26,7 +28,9 @@ namespace Avalonia.Controls.Primitives
public static readonly StyledProperty<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 @@ namespace Avalonia.Controls.Primitives
private void UpdatePosition()
{
PlatformImpl?.PopupPositioner?.Update(_positionerParameters);
if (_needsUpdate && _popupPositionRequest is not null)
{
_needsUpdate = false;
PlatformImpl?.PopupPositioner?
.Update(ParentTopLevel, _popupPositionRequest, _popupSize, FlowDirection);
}
}
[Unstable(ObsoletionMessages.MayBeRemovedInAvalonia12)]
public void ConfigurePosition(Visual target, PlacementMode placement, Point offset,
PopupAnchor anchor = PopupAnchor.None,
PopupGravity gravity = PopupGravity.None,
PopupPositionerConstraintAdjustment constraintAdjustment = PopupPositionerConstraintAdjustment.All,
Rect? rect = null)
{
_positionerParameters.ConfigurePosition(ParentTopLevel, target,
placement, offset, anchor, gravity, constraintAdjustment, rect, FlowDirection);
((IPopupHost)this).ConfigurePosition(new PopupPositionRequest(target, placement, offset, anchor, gravity,
constraintAdjustment, rect, null));
}
if (_positionerParameters.Size != default)
UpdatePosition();
void IPopupHost.ConfigurePosition(PopupPositionRequest request)
{
_popupPositionRequest = request;
_needsUpdate = true;
UpdatePosition();
}
public void SetChild(Control? control) => Content = control;
@ -184,10 +198,15 @@ namespace Avalonia.Controls.Primitives
return new Size(width, height);
}
protected override sealed Size ArrangeSetBounds(Size size)
protected sealed override Size ArrangeSetBounds(Size size)
{
_positionerParameters.Size = size;
UpdatePosition();
if (_popupSize != size)
{
_popupSize = size;
_needsUpdate = true;
UpdatePosition();
}
return ClientSize;
}

28
src/Avalonia.Controls/ToolTip.cs

@ -4,6 +4,7 @@ using Avalonia.Controls.Diagnostics;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Reactive;
using Avalonia.Styling;
@ -51,6 +52,10 @@ namespace Avalonia.Controls
public static readonly AttachedProperty<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 @@ namespace Avalonia.Controls
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 @@ namespace Avalonia.Controls
{
_popup.Bind(Popup.HorizontalOffsetProperty, control.GetBindingObservable(HorizontalOffsetProperty)),
_popup.Bind(Popup.VerticalOffsetProperty, control.GetBindingObservable(VerticalOffsetProperty)),
_popup.Bind(Popup.PlacementProperty, control.GetBindingObservable(PlacementProperty))
_popup.Bind(Popup.PlacementProperty, control.GetBindingObservable(PlacementProperty)),
_popup.Bind(Popup.CustomPopupPlacementCallbackProperty, control.GetBindingObservable(CustomPopupPlacementCallbackProperty))
});
_popup.PlacementTarget = control;
@ -415,14 +437,14 @@ namespace Avalonia.Controls
adornedControl.RaiseEvent(args);
}
_subscriptions?.Dispose();
if (_popup is not null)
{
_popup.IsOpen = false;
_popup.SetPopupParent(null);
_popup.PlacementTarget = null;
}
_subscriptions?.Dispose();
}
private void OnPopupClosed(object? sender, EventArgs e)

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

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

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)
{

Loading…
Cancel
Save