From ab5f8fca7496ff740b29583f46f5359d3c9d7df8 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 16 Feb 2026 19:13:21 +0500 Subject: [PATCH] Make sure that adorners are actually correctly positioned in visual tree for TransformToVisual and friends (#20691) * Make sure that adorners are actually correctly positioned in visual tree for TransformToVisual and friends * Fixes --- .../Pages/AdornerLayerPage.xaml | 36 ++-- .../Input/TextInput/InputMethodManager.cs | 2 +- .../TextInput/TransformTrackingHelper.cs | 27 ++- .../ServerCompositionVisual.Adorners.cs | 17 +- .../Primitives/AdornerHelper.cs | 183 ++++++++++++++++++ .../Primitives/AdornerLayer.cs | 82 +++++--- src/Avalonia.Controls/Primitives/Popup.cs | 2 +- .../PopupPositioning/IPopupPositioner.cs | 31 +-- 8 files changed, 295 insertions(+), 85 deletions(-) create mode 100644 src/Avalonia.Controls/Primitives/AdornerHelper.cs diff --git a/samples/ControlCatalog/Pages/AdornerLayerPage.xaml b/samples/ControlCatalog/Pages/AdornerLayerPage.xaml index 7501c80940..e9a245a8e1 100644 --- a/samples/ControlCatalog/Pages/AdornerLayerPage.xaml +++ b/samples/ControlCatalog/Pages/AdornerLayerPage.xaml @@ -44,21 +44,31 @@ VerticalContentAlignment="Center" VerticalAlignment="Stretch" Width="200" Height="42"> - - - - - - + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Base/Input/TextInput/InputMethodManager.cs b/src/Avalonia.Base/Input/TextInput/InputMethodManager.cs index ae3a8bc6a1..005a015644 100644 --- a/src/Avalonia.Base/Input/TextInput/InputMethodManager.cs +++ b/src/Avalonia.Base/Input/TextInput/InputMethodManager.cs @@ -10,7 +10,7 @@ namespace Avalonia.Input.TextInput private IInputElement? _focusedElement; private Interactive? _visualRoot; private TextInputMethodClient? _client; - private readonly TransformTrackingHelper _transformTracker = new TransformTrackingHelper(); + private readonly TransformTrackingHelper _transformTracker = new TransformTrackingHelper(true); public TextInputMethodManager() { diff --git a/src/Avalonia.Base/Input/TextInput/TransformTrackingHelper.cs b/src/Avalonia.Base/Input/TextInput/TransformTrackingHelper.cs index ef8ae00c79..c5954bc600 100644 --- a/src/Avalonia.Base/Input/TextInput/TransformTrackingHelper.cs +++ b/src/Avalonia.Base/Input/TextInput/TransformTrackingHelper.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using Avalonia.Media; +using Avalonia.Reactive; using Avalonia.Threading; using Avalonia.VisualTree; @@ -7,13 +9,15 @@ namespace Avalonia.Input.TextInput { class TransformTrackingHelper : IDisposable { + private readonly bool _deferAfterRenderPass; private Visual? _visual; private bool _queuedForUpdate; private readonly EventHandler _propertyChangedHandler; private readonly List _propertyChangedSubscriptions = new List(); - public TransformTrackingHelper() + public TransformTrackingHelper(bool deferAfterRenderPass) { + _deferAfterRenderPass = deferAfterRenderPass; _propertyChangedHandler = PropertyChangedHandler; } @@ -70,6 +74,7 @@ namespace Avalonia.Input.TextInput void UpdateMatrix() { + _queuedForUpdate = false; Matrix? matrix = null; if (_visual != null && _visual.VisualRoot != null) matrix = _visual.TransformToVisual((Visual)_visual.VisualRoot); @@ -91,7 +96,10 @@ namespace Avalonia.Input.TextInput if(_queuedForUpdate) return; _queuedForUpdate = true; - Dispatcher.UIThread.Post(UpdateMatrix, DispatcherPriority.AfterRender); + if (_deferAfterRenderPass) + Dispatcher.UIThread.Post(UpdateMatrix, DispatcherPriority.AfterRender); + else + MediaContext.Instance.BeginInvokeOnRender(UpdateMatrix); } private void PropertyChangedHandler(object? sender, AvaloniaPropertyChangedEventArgs e) @@ -106,12 +114,23 @@ namespace Avalonia.Input.TextInput UpdateMatrix(); } - public static IDisposable Track(Visual visual, Action cb) + public static IDisposable Track(Visual visual, bool deferAfterRenderPass, Action cb) { - var rv = new TransformTrackingHelper(); + var rv = new TransformTrackingHelper(deferAfterRenderPass); rv.MatrixChanged += () => cb(visual, rv.Matrix); rv.SetVisual(visual); return rv; } + + public static IObservable Observe(Visual visual, bool deferAfterRenderPass) + { + return Observable.Create(observer => + { + var rv = new TransformTrackingHelper(deferAfterRenderPass); + rv.MatrixChanged += () => observer.OnNext(rv.Matrix); + rv.SetVisual(visual); + return rv; + }); + } } } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Adorners.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Adorners.cs index 6f5f7b7572..fe6effbbd4 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Adorners.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Adorners.cs @@ -46,20 +46,25 @@ partial class ServerCompositionVisual public void UpdateAdorner() { GetAttHelper().EnqueuedForAdornerUpdate = false; - var ownTransform = MatrixUtils.ComputeTransform(Size, AnchorPoint, CenterPoint, TransformMatrix, Scale, - RotationAngle, Orientation, Offset); - + if (AdornedVisual != null && Parent != null) { + // We ignore Visual's RenderTransform completely since it's set by AdornerLayer and can be out of sync + // with compositor-driver animations + var ownTransform = MatrixUtils.ComputeTransform(Size, AnchorPoint, CenterPoint, Matrix.Identity, Scale, + RotationAngle, Orientation, Offset); if ( AdornerLayer_GetExpectedSharedAncestor(this) is {} sharedAncestor && ComputeTransformFromAncestor(AdornedVisual, sharedAncestor, out var adornerLayerToAdornedVisual)) - ownTransform = (ownTransform ?? Matrix.Identity) * adornerLayerToAdornedVisual; + _ownTransform = (ownTransform ?? Matrix.Identity) * adornerLayerToAdornedVisual; else - ownTransform = default(Matrix); // Don't render, something is broken + _ownTransform = default(Matrix); // Don't render, something is broken } - _ownTransform = ownTransform; + else + _ownTransform = MatrixUtils.ComputeTransform(Size, AnchorPoint, CenterPoint, TransformMatrix, Scale, + RotationAngle, Orientation, Offset); + PropagateFlags(true, true); } diff --git a/src/Avalonia.Controls/Primitives/AdornerHelper.cs b/src/Avalonia.Controls/Primitives/AdornerHelper.cs new file mode 100644 index 0000000000..c3f967da5c --- /dev/null +++ b/src/Avalonia.Controls/Primitives/AdornerHelper.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace Avalonia.Controls.Primitives; + +class AdornerHelper +{ + + public static IDisposable SubscribeToAncestorPropertyChanges(Visual visual, + bool includeClip, Action changed) + { + return new AncestorPropertyChangesSubscription(visual, includeClip, changed); + } + + private class AncestorPropertyChangesSubscription : IDisposable + { + private readonly Visual _visual; + private readonly bool _includeClip; + private readonly Action _changed; + private readonly EventHandler _propertyChangedHandler; + private readonly List _subscriptions = new List(); + private bool _isDisposed; + + public AncestorPropertyChangesSubscription(Visual visual, bool includeClip, Action changed) + { + _visual = visual; + _includeClip = includeClip; + _changed = changed; + _propertyChangedHandler = OnPropertyChanged; + + _visual.AttachedToVisualTree += OnAttachedToVisualTree; + _visual.DetachedFromVisualTree += OnDetachedFromVisualTree; + + if (_visual.IsAttachedToVisualTree) + { + SubscribeToAncestors(); + } + } + + private void SubscribeToAncestors() + { + UnsubscribeFromAncestors(); + + // Subscribe to the visual's own Bounds property + _visual.PropertyChanged += _propertyChangedHandler; + _subscriptions.Add(_visual); + + // Walk up the ancestor chain + var ancestor = _visual.VisualParent; + while (ancestor != null) + { + if (ancestor is Visual visualAncestor) + { + visualAncestor.PropertyChanged += _propertyChangedHandler; + _subscriptions.Add(visualAncestor); + } + ancestor = ancestor.VisualParent; + } + } + + private void UnsubscribeFromAncestors() + { + foreach (var subscription in _subscriptions) + { + subscription.PropertyChanged -= _propertyChangedHandler; + } + _subscriptions.Clear(); + } + + private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (!e.IsEffectiveValueChange) + return; + + bool shouldNotify = false; + + if (e.Property == Visual.RenderTransformProperty || e.Property == Visual.BoundsProperty) + { + shouldNotify = true; + } + else if (_includeClip) + { + if (e.Property == Visual.ClipToBoundsProperty || + e.Property == Visual.ClipProperty) shouldNotify = true; + } + + if (shouldNotify) + { + _changed(); + } + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + SubscribeToAncestors(); + _changed(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + UnsubscribeFromAncestors(); + _changed(); + } + + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + UnsubscribeFromAncestors(); + _visual.AttachedToVisualTree -= OnAttachedToVisualTree; + _visual.DetachedFromVisualTree -= OnDetachedFromVisualTree; + } + } + + public static Geometry? CalculateAdornerClip(Visual adornedElement) + { + // Walk ancestor stack and calculate clip geometry relative to the current visual. + // If ClipToBounds = true, add extra RectangleGeometry for Bounds.Size + + Geometry? result = null; + var ancestor = adornedElement; + + while (ancestor != null) + { + if (ancestor is Visual visualAncestor) + { + Geometry? ancestorClip = null; + + // Check if ancestor has ClipToBounds enabled + if (visualAncestor.ClipToBounds) + { + ancestorClip = new RectangleGeometry(new Rect(visualAncestor.Bounds.Size)); + } + + // Check if ancestor has explicit Clip geometry + if (visualAncestor.Clip != null) + { + if (ancestorClip != null) + { + ancestorClip = new CombinedGeometry(GeometryCombineMode.Intersect, ancestorClip, visualAncestor.Clip); + } + else + { + ancestorClip = visualAncestor.Clip; + } + } + + // Transform the clip geometry to adorned element's coordinate space + if (ancestorClip != null) + { + var transform = visualAncestor.TransformToVisual(adornedElement); + if (transform.HasValue && !transform.Value.IsIdentity) + { + ancestorClip = ancestorClip.Clone(); + var matrix = ancestorClip.Transform is { Value.IsIdentity: false } + ? transform.Value * ancestorClip.Transform.Value + : transform.Value; + ancestorClip.Transform = new MatrixTransform(matrix); + } + + // Combine with existing result + if (result != null) + { + result = new CombinedGeometry(GeometryCombineMode.Intersect, result, ancestorClip); + } + else + { + result = ancestorClip; + } + } + } + + ancestor = ancestor.VisualParent; + } + + return result; + } + +} \ No newline at end of file diff --git a/src/Avalonia.Controls/Primitives/AdornerLayer.cs b/src/Avalonia.Controls/Primitives/AdornerLayer.cs index 412dd236ff..6271f16784 100644 --- a/src/Avalonia.Controls/Primitives/AdornerLayer.cs +++ b/src/Avalonia.Controls/Primitives/AdornerLayer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Specialized; +using Avalonia.Input.TextInput; using Avalonia.Media; using Avalonia.Reactive; using Avalonia.VisualTree; @@ -45,15 +46,20 @@ namespace Avalonia.Controls.Primitives private static readonly AttachedProperty s_savedAdornerLayerProperty = AvaloniaProperty.RegisterAttached("SavedAdornerLayer"); + private TransformTrackingHelper _trackingHelper = new TransformTrackingHelper(false); + static AdornerLayer() { AdornedElementProperty.Changed.Subscribe(AdornedElementChanged); AdornerProperty.Changed.Subscribe(AdornerChanged); + IsClipEnabledProperty.Changed.Subscribe(AdornerIsClipEnabledChanged); } public AdornerLayer() { Children.CollectionChanged += ChildrenCollectionChanged; + _trackingHelper.SetVisual(this); + _trackingHelper.MatrixChanged += delegate { InvalidateMeasure(); }; } public static Visual? GetAdornedElement(Visual adorner) @@ -199,9 +205,9 @@ namespace Avalonia.Controls.Primitives { var info = ao.GetValue(s_adornedElementInfoProperty); - if (info != null && info.Bounds.HasValue) + if (info is { AdornedElement: not null }) { - child.Measure(info.Bounds.Value.Bounds.Size); + child.Measure(info.AdornedElement.Bounds.Size); } else { @@ -223,12 +229,22 @@ namespace Avalonia.Controls.Primitives var info = ao.GetValue(s_adornedElementInfoProperty); var isClipEnabled = ao.GetValue(IsClipEnabledProperty); - if (info != null && info.Bounds.HasValue) + var adorned = info?.AdornedElement; + + if (adorned != null) { - child.RenderTransform = new MatrixTransform(info.Bounds.Value.Transform); + child.Arrange(new(adorned.Bounds.Size)); + var transform = adorned.TransformToVisual(this); + // If somebody decides that having Margin on an adorner is a good idea, + // we need to compensate for element being positioned at non-(0,0) coords. + if (transform != null && child.Bounds.Position != default) + { + transform = Matrix.CreateTranslation(child.Bounds.Position) * transform.Value * + Matrix.CreateTranslation(-child.Bounds.Position); + } + child.RenderTransform = new MatrixTransform(transform ?? default); child.RenderTransformOrigin = new RelativePoint(new Point(0, 0), RelativeUnit.Absolute); - UpdateClip(child, info.Bounds.Value, isClipEnabled); - child.Arrange(info.Bounds.Value.Bounds); + UpdateClip(child, adorned, isClipEnabled); } else { @@ -248,29 +264,22 @@ namespace Avalonia.Controls.Primitives layer?.UpdateAdornedElement(adorner, adorned); } - private void UpdateClip(Control control, TransformedBounds bounds, bool isEnabled) + private static void AdornerIsClipEnabledChanged(AvaloniaPropertyChangedEventArgs e) + { + var info = ((Visual)e.Sender).GetValue(s_adornedElementInfoProperty); + info?.UpdateSubscription(); + info?.Layer?.InvalidateMeasure(); + } + + private void UpdateClip(Control control, Visual adorned, bool isEnabled) { if (!isEnabled) { control.Clip = null; - return; } - if (!(control.Clip is RectangleGeometry clip)) - { - clip = new RectangleGeometry(); - control.Clip = clip; - } - - var clipBounds = bounds.Bounds; - - if (bounds.Transform.HasInverse) - { - clipBounds = bounds.Clip.TransformToAABB(bounds.Transform.Invert()); - } - - clip.Rect = clipBounds; + control.Clip = AdornerHelper.CalculateAdornerClip(adorned); } private void ChildrenCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) @@ -313,24 +322,35 @@ namespace Avalonia.Controls.Primitives { if (info == null) { - info = new AdornedElementInfo(); + info = new AdornedElementInfo(adorner); adorner.SetValue(s_adornedElementInfoProperty, info); } - if (adorner.CompositionVisual != null) - info.Subscription = adorned.GetObservable(BoundsProperty).Subscribe(x => - { - info.Bounds = new TransformedBounds(new Rect(adorned.Bounds.Size), new Rect(adorned.Bounds.Size), Matrix.Identity); - InvalidateMeasure(); - }); + info.Layer = this; + info.AdornedElement = adorned; + info.UpdateSubscription(); } } - private class AdornedElementInfo + private class AdornedElementInfo(Visual adorner) { + public AdornerLayer? Layer { get; set; } public IDisposable? Subscription { get; set; } + public Visual? AdornedElement { get; set; } - public TransformedBounds? Bounds { get; set; } + public void UpdateSubscription() + { + Subscription?.Dispose(); + Subscription = null; + if (AdornedElement != null) + { + Subscription = AdornerHelper.SubscribeToAncestorPropertyChanges(AdornedElement, + GetIsClipEnabled(adorner), () => + { + Layer?.InvalidateMeasure(); + }); + } + } } } } diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index 76fa73f4c3..5294df72d5 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -456,7 +456,7 @@ namespace Avalonia.Controls.Primitives if (InheritsTransform) { - TransformTrackingHelper.Track(placementTarget, PlacementTargetTransformChanged) + TransformTrackingHelper.Track(placementTarget, true, PlacementTargetTransformChanged) .DisposeWith(handlerCleanup); } else diff --git a/src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs b/src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs index 82dead1ed5..2f735ef691 100644 --- a/src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs +++ b/src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs @@ -570,16 +570,8 @@ namespace Avalonia.Controls.Primitives.PopupPositioning 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); - } - + Matrix? matrix = target.TransformToVisual(topLevel); + if (matrix == null) { if (target.GetVisualRoot() == null) @@ -591,25 +583,6 @@ namespace Avalonia.Controls.Primitives.PopupPositioning var anchorRect = positionRequest.AnchorRect ?? bounds; return anchorRect.Intersect(bounds).TransformToAABB(matrix.Value); } - - private static bool TryGetAdorner(Visual target, out Visual? adorned, out Visual? adornerLayer) - { - var element = target; - while (element != null) - { - if (AdornerLayer.GetAdornedElement(element) is { } adornedElement) - { - adorned = adornedElement; - adornerLayer = AdornerLayer.GetAdornerLayer(adorned); - return true; - } - element = element.VisualParent; - } - - adorned = null; - adornerLayer = null; - return false; - } } }