Browse Source

Improve composition hit testing performance with per-visual AABBs (#21310)

* Prototype an AABB tree for hit testing

* Some optimizations

* Revert runtime knobs and add tests

* Avoid rebuilding the whole tree

* Revert an invalid optimization

* Add a hit test page

* Clean up

* Format code

* Nit

* Use weak CompositionVisual reference in server-side

* Bucket composition hit-test AABB tree by child order

* Use readback revisions for AABB hit-test updates
pull/21397/head
Steve 5 months ago
committed by GitHub
parent
commit
603b14a439
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      samples/RenderDemo/MainWindow.xaml
  2. 401
      samples/RenderDemo/Pages/HitTestingPage.cs
  3. 13
      src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
  4. 894
      src/Avalonia.Base/Rendering/Composition/CompositionHitTestAabbTree.cs
  5. 163
      src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs
  6. 70
      src/Avalonia.Base/Rendering/Composition/ContainerVisual.cs
  7. 32
      src/Avalonia.Base/Rendering/Composition/VisualCollection.cs
  8. 134
      tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs
  9. 2
      tests/Avalonia.UnitTests/CompositorTestServices.cs

3
samples/RenderDemo/MainWindow.xaml

@ -64,6 +64,9 @@
<TabItem Header="Drawing">
<pages:DrawingPage />
</TabItem>
<TabItem Header="Hit Testing">
<pages:HitTestingPage />
</TabItem>
<TabItem Header="SkCanvas">
<pages:CustomSkiaPage />
</TabItem>

401
samples/RenderDemo/Pages/HitTestingPage.cs

@ -0,0 +1,401 @@
using System;
using System.Diagnostics;
using System.Numerics;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Rendering.Composition;
using Avalonia.Rendering.Composition.Animations;
using Avalonia.VisualTree;
namespace RenderDemo.Pages
{
public class HitTestingPage : UserControl
{
private const int GroupColumns = 8;
private const int GroupRows = 5;
private const int CellsPerGroupSide = 10;
private const int CellStride = 10;
private const int CellSize = 8;
private const int AnimationTravel = 64;
private readonly Canvas _scene;
private readonly TextBlock _stats;
private readonly Cell[] _cells = new Cell[GroupColumns * GroupRows * CellsPerGroupSide * CellsPerGroupSide];
private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
private Compositor? _compositor;
private int _hitTestsPerFrame = 256;
private int _updateCount;
private int _hitTestCount;
private int _hitCount;
private int _lastSecondUpdateCount;
private int _lastSecondHitTestCount;
private int _lastSecondHitCount;
private TimeSpan _lastSecondTime;
private double _lastSecondUpdatesPerSecond;
private double _lastSecondHitTestsPerSecond;
private double _lastSecondHitsPerSecond;
private int _clickCount;
private bool _isAttached;
private bool _updateQueued;
private bool _animationsStarted;
private Cell? _lastHitCell;
private Cell? _lastClickedCell;
public HitTestingPage()
{
_scene = new Canvas
{
Width = GroupColumns * CellsPerGroupSide * CellStride,
Height = GroupRows * CellsPerGroupSide * CellStride,
Background = Brushes.Transparent
};
_stats = new TextBlock
{
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
Margin = new Thickness(12),
Padding = new Thickness(8, 4),
Background = new SolidColorBrush(Color.FromArgb(220, 255, 255, 255)),
Foreground = Brushes.Black,
IsHitTestVisible = false
};
var numberOfHitTests = new NumericUpDown
{
Minimum = 0,
Value = _hitTestsPerFrame,
Width = 200,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
numberOfHitTests.ValueChanged += (s, e) =>
{
if (numberOfHitTests.Value.HasValue)
{
_hitTestsPerFrame = (int)numberOfHitTests.Value.Value;
}
};
var param = new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Bottom,
Margin = new Thickness(12),
Spacing = 8,
Children =
{
new TextBlock { Text = "Hit tests per update:" },
numberOfHitTests
}
};
var root = new Grid
{
ClipToBounds = true,
RowDefinitions =
{
new RowDefinition(GridLength.Auto),
new RowDefinition(GridLength.Auto),
new RowDefinition(GridLength.Star),
},
};
Grid.SetRow(param, 0);
root.Children.Add(param);
Grid.SetRow(_stats, 1);
root.Children.Add(_stats);
Grid.SetRow(_scene, 2);
root.Children.Add(_scene);
Content = root;
BuildScene();
ResetState();
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
ResetState();
_compositor = ElementComposition.GetElementVisual(this)?.Compositor;
_isAttached = true;
RequestNextUpdate();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_updateQueued = false;
_compositor = null;
ResetState();
base.OnDetachedFromVisualTree(e);
}
private void BuildScene()
{
var index = 0;
var groupSize = CellsPerGroupSide * CellStride;
for (var groupY = 0; groupY < GroupRows; groupY++)
{
for (var groupX = 0; groupX < GroupColumns; groupX++)
{
var group = new Canvas
{
Width = groupSize,
Height = groupSize,
Background = Brushes.Transparent
};
Canvas.SetLeft(group, groupX * groupSize);
Canvas.SetTop(group, groupY * groupSize);
_scene.Children.Add(group);
for (var y = 0; y < CellsPerGroupSide; y++)
{
for (var x = 0; x < CellsPerGroupSide; x++)
{
var cell = new Cell(index)
{
Width = CellSize,
Height = CellSize,
Background = CreateBrush(index),
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative)
};
cell.PointerPressed += OnCellPointerPressed;
Canvas.SetLeft(cell, x * CellStride);
Canvas.SetTop(cell, y * CellStride);
group.Children.Add(cell);
_cells[index++] = cell;
}
}
}
}
}
private void OnCompositionUpdate()
{
_updateQueued = false;
if (!_isAttached)
return;
if (!_animationsStarted)
StartAnimations();
RunHitTests();
_updateCount++;
if (_stopwatch.Elapsed - _lastSecondTime >= TimeSpan.FromSeconds(1))
UpdateStats();
RequestNextUpdate();
}
private void RequestNextUpdate()
{
if (_updateQueued || _compositor == null)
return;
_updateQueued = true;
_compositor.RequestCompositionUpdate(OnCompositionUpdate);
}
private void StartAnimations()
{
var started = 0;
var easing = new SineEaseInOut();
for (var i = 0; i < _cells.Length; i++)
{
if (i % 5 != 0)
continue;
var visual = ElementComposition.GetElementVisual(_cells[i]);
if (visual == null)
continue;
var translation = visual.Compositor.CreateVector3KeyFrameAnimation();
translation.Target = "Translation";
translation.Duration = TimeSpan.FromMilliseconds(900 + (i % 700));
translation.Direction = PlaybackDirection.Alternate;
translation.IterationBehavior = AnimationIterationBehavior.Forever;
translation.InsertKeyFrame(0f, new Vector3(0, 0, 0), easing);
translation.InsertKeyFrame(1f, GetAnimationOffset(i), easing);
visual.StartAnimation("Translation", translation);
started++;
}
_animationsStarted = started > 0;
}
private void StopAnimations()
{
for (var i = 0; i < _cells.Length; i++)
{
var visual = ElementComposition.GetElementVisual(_cells[i]);
if (visual == null)
continue;
visual.StopAnimation("Translation");
visual.Translation = default;
}
_animationsStarted = false;
}
private void RunHitTests()
{
var width = Math.Max(1, _scene.Bounds.Width);
var height = Math.Max(1, _scene.Bounds.Height);
var baseIndex = _updateCount * 37;
for (var i = 0; i < _hitTestsPerFrame; i++)
{
_hitTestCount++;
var sample = baseIndex + (i * 97);
var point = new Point(sample * 17 % width, sample * 29 % height);
var hit = _scene.GetVisualAt(point);
if (hit is Cell cell)
{
SetLastHitCell(cell);
_hitCount++;
}
}
}
private static Vector3 GetAnimationOffset(int index)
{
var x = index % 4 switch
{
0 => -AnimationTravel,
1 => AnimationTravel,
2 => -AnimationTravel / 2,
_ => AnimationTravel / 2
};
var y = index / 4 % 4 switch
{
0 => -AnimationTravel,
1 => AnimationTravel,
2 => AnimationTravel / 2,
_ => -AnimationTravel / 2
};
return new Vector3(x, y, 0);
}
private void ResetState()
{
StopAnimations();
_lastClickedCell?.ClearHighlight();
_lastHitCell = null;
_lastClickedCell = null;
_updateCount = 0;
_hitTestCount = 0;
_hitCount = 0;
_lastSecondUpdateCount = 0;
_lastSecondHitTestCount = 0;
_lastSecondHitCount = 0;
_lastSecondTime = default;
_lastSecondUpdatesPerSecond = 0;
_lastSecondHitTestsPerSecond = 0;
_lastSecondHitsPerSecond = 0;
_clickCount = 0;
_stopwatch.Restart();
UpdateStats();
}
private void UpdateStats()
{
var elapsed = _stopwatch.Elapsed;
var seconds = Math.Max(0.001, (elapsed - _lastSecondTime).TotalSeconds);
_lastSecondUpdatesPerSecond = (_updateCount - _lastSecondUpdateCount) / seconds;
_lastSecondHitTestsPerSecond = (_hitTestCount - _lastSecondHitTestCount) / seconds;
_lastSecondHitsPerSecond = (_hitCount - _lastSecondHitCount) / seconds;
_lastSecondUpdateCount = _updateCount;
_lastSecondHitTestCount = _hitTestCount;
_lastSecondHitCount = _hitCount;
_lastSecondTime = elapsed;
_stats.Text =
$"Visuals: {_cells.Length} ({_cells.Length / 5} animated), " +
$"Hit tests/frame: {_hitTestsPerFrame}, " +
$"Composition updates/s: {_lastSecondUpdatesPerSecond:F1}, Hit tests/s: {_lastSecondHitTestsPerSecond:F0}, " +
$"Hits/s: {_lastSecondHitsPerSecond:F0}, Misses/s: {_lastSecondHitTestsPerSecond - _lastSecondHitsPerSecond:F0}, " +
$"Clicks: {_clickCount}";
}
private void OnCellPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender is not Cell cell)
return;
SetLastClickedCell(cell);
_clickCount++;
e.Handled = true;
}
private void SetLastHitCell(Cell cell)
{
if (ReferenceEquals(_lastHitCell, cell))
return;
_lastHitCell = cell;
}
private void SetLastClickedCell(Cell cell)
{
if (!ReferenceEquals(_lastClickedCell, cell) && _lastClickedCell != null)
{
_lastClickedCell.IsLatestClick = false;
_lastClickedCell.UpdateHighlight();
}
_lastClickedCell = cell;
cell.IsLatestClick = true;
cell.UpdateHighlight();
}
private static IBrush CreateBrush(int index)
{
var r = (byte)(80 + (index * 47 % 160));
var g = (byte)(80 + (index * 91 % 160));
var b = (byte)(80 + (index * 137 % 160));
return new SolidColorBrush(Color.FromRgb(r, g, b));
}
private sealed class Cell : Border
{
public Cell(int index)
{
Index = index;
BorderThickness = new Thickness(1);
}
public int Index { get; }
public bool IsLatestClick { get; set; }
public void ClearHighlight()
{
IsLatestClick = false;
UpdateHighlight();
}
public void UpdateHighlight()
{
BorderBrush = IsLatestClick ? Brushes.White : Brushes.Transparent;
ZIndex = IsLatestClick ? 1 : 0;
}
}
}
}

13
src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Diagnostics;
@ -129,8 +128,16 @@ internal class CompositingRenderer : IRendererWithCompositor, IHitTester
/// <inheritdoc/>
public Visual? HitTestFirst(Point p, Visual root, Func<Visual, bool>? filter)
{
// TODO: Optimize
return HitTest(p, root, filter).FirstOrDefault();
using var _ = Diagnostic.PerformingHitTest();
if (root.CompositionVisual == null)
return null;
Func<CompositionVisual, bool>? f = filter is null
? null :
v => v is not CompositionDrawListVisual dlv || filter(dlv.Visual);
return CompositionTarget.TryHitTestFirst(p, root.CompositionVisual, f, static v => v is CompositionDrawListVisual) is CompositionDrawListVisual dv ? dv.Visual : null;
}
/// <inheritdoc/>

894
src/Avalonia.Base/Rendering/Composition/CompositionHitTestAabbTree.cs

@ -0,0 +1,894 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Avalonia.Collections.Pooled;
using Avalonia.Platform;
namespace Avalonia.Rendering.Composition;
internal sealed class CompositionHitTestAabbTree
{
private const int Null = -1;
private const int OrderBucketSize = 32;
private const double FatBoundsPadding = 1;
private static readonly CandidateComparer s_candidateComparer = new();
private readonly CompositionVisualCollection _children;
private readonly Dictionary<CompositionVisual, Entry> _entries = [];
private readonly List<Bucket> _buckets = [];
private readonly List<Node> _nodes = [];
private int _freeList = Null;
public CompositionHitTestAabbTree(CompositionVisualCollection children)
{
_children = children;
for (var i = 0; i < children.Count; i++)
Update(children[i], i);
}
public void Clear()
{
_entries.Clear();
_buckets.Clear();
_nodes.Clear();
_freeList = Null;
}
public void Update(CompositionVisual visual, int order)
{
ref var entry = ref CollectionsMarshal.GetValueRefOrAddDefault(_entries, visual, out var exists);
if (!exists)
{
var bucketIndex = GetBucketIndex(order);
entry = new Entry(order);
GetOrCreateBucket(bucketIndex);
}
else if (entry.Order != order)
{
UpdateOrderCore(visual, ref entry, order);
}
var state = GetBoundsState(visual, out var bounds, out var revision);
UpdateBounds(visual, ref entry, state, bounds, revision);
}
public void Remove(CompositionVisual visual)
{
if (!_entries.Remove(visual, out var entry))
return;
if (entry.Leaf != Null)
DestroyLeaf(entry.Leaf);
else if (entry.IsUnbounded)
RemoveUnbounded(visual, entry.Order);
RemoveBucketIfEmpty(GetBucketIndex(entry.Order));
}
public void UpdateOrder(CompositionVisual visual, int order)
{
ref var entry = ref CollectionsMarshal.GetValueRefOrAddDefault(_entries, visual, out var exists);
if (!exists)
{
entry = new Entry(order);
return;
}
if (entry.Order != order)
UpdateOrderCore(visual, ref entry, order);
}
private void UpdateOrderCore(CompositionVisual visual, ref Entry entry, int order)
{
var oldOrder = entry.Order;
var oldBucket = GetBucketIndex(oldOrder);
var newBucket = GetBucketIndex(order);
entry.Order = order;
if (entry.Leaf != Null)
{
MoveLeaf(entry.Leaf, order);
return;
}
if (entry.IsUnbounded)
{
MoveUnbounded(visual, oldOrder, order);
return;
}
if (oldBucket != newBucket)
RemoveBucketIfEmpty(oldBucket);
}
public void Query(Point point, PooledList<CompositionVisual> results, ulong readbackRevision)
{
var candidates = ArrayPool<Candidate>.Shared.Rent(OrderBucketSize);
var stack = ArrayPool<int>.Shared.Rent(16);
var candidateCount = 0;
try
{
for (var i = _buckets.Count - 1; i >= 0; i--)
{
UpdateBucket(i, readbackRevision);
var bucket = _buckets[i];
if (bucket.Root == Null && bucket.Unbounded is not { Count: > 0 })
continue;
candidateCount = 0;
var stackCount = 0;
QueryBucket(bucket, point, ref candidates, ref candidateCount, ref stack, ref stackCount);
candidates.AsSpan(0, candidateCount).Sort(s_candidateComparer);
for (var j = 0; j < candidateCount; j++)
results.Add(candidates[j].Visual);
candidates.AsSpan(0, candidateCount).Clear();
}
}
finally
{
candidates.AsSpan(0, candidateCount).Clear();
ArrayPool<Candidate>.Shared.Return(candidates);
ArrayPool<int>.Shared.Return(stack);
}
}
public CompositionVisual? QueryFirst(CompositionTarget target, Point point, Func<CompositionVisual, bool>? filter, Func<CompositionVisual, bool>? resultFilter, ulong readbackRevision)
{
var candidates = ArrayPool<Candidate>.Shared.Rent(OrderBucketSize);
var stack = ArrayPool<int>.Shared.Rent(16);
var candidateCount = 0;
try
{
for (var i = _buckets.Count - 1; i >= 0; i--)
{
UpdateBucket(i, readbackRevision);
var bucket = _buckets[i];
if (bucket.Root == Null && bucket.Unbounded is not { Count: > 0 })
continue;
candidateCount = 0;
var stackCount = 0;
QueryBucket(bucket, point, ref candidates, ref candidateCount, ref stack, ref stackCount);
candidates.AsSpan(0, candidateCount).Sort(s_candidateComparer);
for (var j = 0; j < candidateCount; j++)
{
var hit = target.HitTestFirstCore(candidates[j].Visual, point, filter, resultFilter);
if (hit != null)
return hit;
}
candidates.AsSpan(0, candidateCount).Clear();
}
}
finally
{
candidates.AsSpan(0, candidateCount).Clear();
ArrayPool<Candidate>.Shared.Return(candidates);
ArrayPool<int>.Shared.Return(stack);
}
return null;
}
private void UpdateBucket(int bucketIndex, ulong readbackRevision)
{
ref var bucket = ref GetRef(_buckets, bucketIndex);
if (bucket.ReadbackRevision == readbackRevision)
return;
var end = Math.Min(_children.Count, (bucketIndex + 1) * OrderBucketSize);
for (var i = bucketIndex * OrderBucketSize; i < end; i++)
{
var visual = _children[i];
ref var entry = ref CollectionsMarshal.GetValueRefOrNullRef(_entries, visual);
var state = GetBoundsState(visual, out var bounds, out var revision);
if (entry.Revision != revision)
UpdateBounds(visual, ref entry, state, bounds, revision);
}
bucket.ReadbackRevision = readbackRevision;
}
private void UpdateBounds(CompositionVisual visual, ref Entry entry, BoundsState state, LtrbRect bounds, ulong revision)
{
entry.Revision = revision;
if (entry.Leaf != Null)
{
if (state == BoundsState.Bounded)
{
UpdateLeaf(entry.Leaf, bounds, entry.Order);
}
else
{
DestroyLeaf(entry.Leaf);
entry.Leaf = Null;
if (state == BoundsState.Unbounded)
AddUnbounded(visual, entry.Order, ref entry);
}
return;
}
if (entry.IsUnbounded)
{
if (state == BoundsState.Unbounded)
return;
RemoveUnbounded(visual, entry.Order);
entry.IsUnbounded = false;
if (state == BoundsState.Bounded)
entry.Leaf = CreateLeaf(visual, bounds, entry.Order);
return;
}
if (state == BoundsState.Bounded)
entry.Leaf = CreateLeaf(visual, bounds, entry.Order);
else if (state == BoundsState.Unbounded)
AddUnbounded(visual, entry.Order, ref entry);
}
private void QueryBucket(Bucket bucket, Point point, ref Candidate[] candidates, ref int candidateCount, ref int[] stack, ref int stackCount)
{
PushQueryNode(ref stack, ref stackCount, bucket.Root);
while (stackCount > 0)
{
var nodeIndex = stack[--stackCount];
var node = _nodes[nodeIndex];
if (!node.Bounds.Contains(point))
continue;
if (node.IsLeaf)
{
if (node.Visual != null)
AddCandidate(ref candidates, ref candidateCount, new Candidate(node.Visual, node.Order));
}
else
{
PushQueryNode(ref stack, ref stackCount, node.Child1);
PushQueryNode(ref stack, ref stackCount, node.Child2);
}
}
if (bucket.Unbounded != null)
foreach (var visual in bucket.Unbounded)
if (_entries.TryGetValue(visual, out var entry))
AddCandidate(ref candidates, ref candidateCount, new Candidate(visual, entry.Order));
}
private static void AddCandidate(ref Candidate[] candidates, ref int count, Candidate candidate)
{
if (count == candidates.Length)
Resize(ref candidates, count);
candidates[count++] = candidate;
}
private static void PushQueryNode(ref int[] stack, ref int count, int nodeIndex)
{
if (nodeIndex == Null)
return;
if (count == stack.Length)
Resize(ref stack, count);
stack[count++] = nodeIndex;
}
private static void Resize<T>(ref T[] buffer, int count)
{
var resized = ArrayPool<T>.Shared.Rent(buffer.Length * 2);
buffer.AsSpan(0, count).CopyTo(resized);
ArrayPool<T>.Shared.Return(buffer, RuntimeHelpers.IsReferenceOrContainsReferences<T>());
buffer = resized;
}
private static int GetBucketIndex(int order) => order / OrderBucketSize;
private ref Bucket GetOrCreateBucket(int bucketIndex)
{
while (_buckets.Count <= bucketIndex)
_buckets.Add(new Bucket(Null));
return ref GetRef(_buckets, bucketIndex);
}
private void RemoveBucketIfEmpty(int bucketIndex)
{
if (bucketIndex >= _buckets.Count)
return;
var lastRequiredBucketIndex = _children.Count == 0 ? Null : GetBucketIndex(_children.Count - 1);
bool removeTrailingBucket;
{
ref var bucket = ref GetRef(_buckets, bucketIndex);
if (bucket.Unbounded?.Count == 0)
bucket.Unbounded = null;
removeTrailingBucket = bucket.IsEmpty &&
bucketIndex == _buckets.Count - 1 &&
bucketIndex > lastRequiredBucketIndex;
}
if (!removeTrailingBucket)
return;
do
{
_buckets.RemoveAt(_buckets.Count - 1);
} while (_buckets.Count > 0 &&
_buckets.Count - 1 > lastRequiredBucketIndex &&
_buckets[^1].IsEmpty);
}
private static ref T GetRef<T>(List<T> items, int index) => ref CollectionsMarshal.AsSpan(items)[index];
private int CreateLeaf(CompositionVisual visual, LtrbRect bounds, int order)
{
var leaf = AllocateNode();
{
ref var node = ref GetRef(_nodes, leaf);
node.Bounds = Fatten(bounds);
node.Visual = visual;
node.Order = order;
node.Bucket = GetBucketIndex(order);
node.Height = 0;
}
InsertLeaf(leaf);
return leaf;
}
private void DestroyLeaf(int leaf)
{
RemoveLeaf(leaf);
FreeNode(leaf);
}
private void UpdateLeaf(int leaf, LtrbRect bounds, int order)
{
var bucket = GetBucketIndex(order);
{
ref var node = ref GetRef(_nodes, leaf);
// If the exact bounds still fit inside the fat bounds, the tree shape can stay unchanged.
if (node.Bucket == bucket && node.Bounds.Contains(bounds))
{
node.Order = order;
return;
}
}
RemoveLeaf(leaf);
{
ref var removedNode = ref GetRef(_nodes, leaf);
removedNode.Bounds = Fatten(bounds);
removedNode.Bucket = bucket;
removedNode.Order = order;
}
InsertLeaf(leaf);
}
private void MoveLeaf(int leaf, int order)
{
var bucket = GetBucketIndex(order);
{
ref var node = ref GetRef(_nodes, leaf);
if (node.Bucket == bucket)
{
node.Order = order;
return;
}
}
RemoveLeaf(leaf);
{
ref var removedNode = ref GetRef(_nodes, leaf);
removedNode.Order = order;
removedNode.Bucket = bucket;
}
InsertLeaf(leaf);
}
private int AllocateNode()
{
if (_freeList == Null)
{
_nodes.Add(new Node
{
Parent = Null,
Child1 = Null,
Child2 = Null,
Next = Null
});
return _nodes.Count - 1;
}
var index = _freeList;
ref var node = ref GetRef(_nodes, index);
_freeList = node.Next;
node.Parent = Null;
node.Child1 = Null;
node.Child2 = Null;
node.Next = Null;
node.Height = 0;
node.Visual = null;
node.Order = 0;
node.Bucket = 0;
return index;
}
private void FreeNode(int index)
{
ref var node = ref GetRef(_nodes, index);
node.Next = _freeList;
node.Parent = Null;
node.Child1 = Null;
node.Child2 = Null;
node.Height = -1;
node.Visual = null;
node.Order = 0;
node.Bucket = 0;
_freeList = index;
}
private void InsertLeaf(int leaf)
{
var bucketIndex = GetRef(_nodes, leaf).Bucket;
ref var bucket = ref GetOrCreateBucket(bucketIndex);
if (bucket.Root == Null)
{
bucket.Root = leaf;
ref var root = ref GetRef(_nodes, leaf);
root.Parent = Null;
return;
}
var leafBounds = GetRef(_nodes, leaf).Bounds;
var sibling = FindBestSibling(bucket.Root, leafBounds);
var oldParent = GetRef(_nodes, sibling).Parent;
var newParent = AllocateNode();
// Insert by replacing the chosen sibling with a new internal parent:
//
// Before: oldParent After: oldParent
// | |
// sibling newParent
// / \
// sibling leaf
ref var parentNode = ref GetRef(_nodes, newParent);
parentNode.Parent = oldParent;
parentNode.Bounds = leafBounds.Union(GetRef(_nodes, sibling).Bounds);
parentNode.Height = GetRef(_nodes, sibling).Height + 1;
parentNode.Child1 = sibling;
parentNode.Child2 = leaf;
parentNode.Visual = null;
parentNode.Bucket = bucketIndex;
ref var siblingNode = ref GetRef(_nodes, sibling);
siblingNode.Parent = newParent;
ref var leafNode = ref GetRef(_nodes, leaf);
leafNode.Parent = newParent;
if (oldParent == Null)
{
bucket.Root = newParent;
}
else
{
ref var oldParentNode = ref GetRef(_nodes, oldParent);
if (oldParentNode.Child1 == sibling)
oldParentNode.Child1 = newParent;
else
oldParentNode.Child2 = newParent;
}
FixAncestors(newParent);
}
private int FindBestSibling(int root, LtrbRect leafBounds)
{
var index = root;
while (!_nodes[index].IsLeaf)
{
var node = _nodes[index];
var child1 = node.Child1;
var child2 = node.Child2;
var area = Perimeter(node.Bounds);
var combinedArea = Perimeter(node.Bounds.Union(leafBounds));
var cost = 2 * combinedArea;
var inheritanceCost = 2 * (combinedArea - area);
var cost1 = GetInsertionCost(child1, leafBounds, inheritanceCost);
var cost2 = GetInsertionCost(child2, leafBounds, inheritanceCost);
// Stop descending when pairing with this internal node is already cheaper.
if (cost < cost1 && cost < cost2)
break;
index = cost1 < cost2 ? child1 : child2;
}
return index;
}
private double GetInsertionCost(int nodeIndex, LtrbRect leafBounds, double inheritanceCost)
{
var node = _nodes[nodeIndex];
var union = node.Bounds.Union(leafBounds);
if (node.IsLeaf)
return Perimeter(union) + inheritanceCost;
return Perimeter(union) - Perimeter(node.Bounds) + inheritanceCost;
}
private void RemoveLeaf(int leaf)
{
var bucketIndex = GetRef(_nodes, leaf).Bucket;
var removedRoot = false;
{
ref var bucket = ref GetRef(_buckets, bucketIndex);
if (leaf == bucket.Root)
{
bucket.Root = Null;
removedRoot = true;
}
}
if (removedRoot)
{
RemoveBucketIfEmpty(bucketIndex);
return;
}
var parent = GetRef(_nodes, leaf).Parent;
var parentNode = GetRef(_nodes, parent);
var grandParent = parentNode.Parent;
var sibling = parentNode.Child1 == leaf ? parentNode.Child2 : parentNode.Child1;
// Collapse the removed leaf's parent and promote the sibling.
if (grandParent != Null)
{
// Before: grandParent After: grandParent
// | |
// parent sibling
// / \
// leaf sibling
ref var grandParentNode = ref GetRef(_nodes, grandParent);
if (grandParentNode.Child1 == parent)
grandParentNode.Child1 = sibling;
else
grandParentNode.Child2 = sibling;
ref var siblingNode = ref GetRef(_nodes, sibling);
siblingNode.Parent = grandParent;
FreeNode(parent);
FixAncestors(grandParent);
}
else
{
// If the parent was the root, the sibling becomes the new root.
//
// Before: parent(root) After: sibling(root)
// / \
// leaf sibling
{
ref var bucket = ref GetRef(_buckets, bucketIndex);
bucket.Root = sibling;
}
ref var siblingNode = ref GetRef(_nodes, sibling);
siblingNode.Parent = Null;
FreeNode(parent);
}
{
ref var leafNode = ref GetRef(_nodes, leaf);
leafNode.Parent = Null;
}
RemoveBucketIfEmpty(bucketIndex);
}
private void FixAncestors(int index)
{
while (index != Null)
{
index = Balance(index);
ref var node = ref GetRef(_nodes, index);
var child1 = GetRef(_nodes, node.Child1);
var child2 = GetRef(_nodes, node.Child2);
// Ancestor bounds always cover both children after insert/remove/rotate.
node.Bounds = child1.Bounds.Union(child2.Bounds);
node.Height = 1 + Math.Max(child1.Height, child2.Height);
index = node.Parent;
}
}
private int Balance(int indexA)
{
var a = GetRef(_nodes, indexA);
if (a.IsLeaf || a.Height < 2)
return indexA;
var indexB = a.Child1;
var indexC = a.Child2;
var b = GetRef(_nodes, indexB);
var c = GetRef(_nodes, indexC);
var balance = c.Height - b.Height;
// The right subtree is heavier than the left. Rotate C up.
if (balance > 1)
return RotateCUp(indexA, indexB, indexC);
// The left subtree is heavier than the right. Rotate B up.
if (balance < -1)
return RotateBUp(indexA, indexB, indexC);
return indexA;
}
private int RotateCUp(int indexA, int indexB, int indexC)
{
// Rotate C above A:
//
// Before: A After, if F taller: C
// / \ / \
// B C A F
// / \ / \
// F G B G
//
// After, otherwise: C
// / \
// A G
// / \
// B F
ref var a = ref GetRef(_nodes, indexA);
ref var c = ref GetRef(_nodes, indexC);
var indexF = c.Child1;
var indexG = c.Child2;
ref var f = ref GetRef(_nodes, indexF);
ref var g = ref GetRef(_nodes, indexG);
c.Child1 = indexA;
c.Parent = a.Parent;
a.Parent = indexC;
// C takes A's old place in the parent chain.
ReplaceParentChild(indexA, indexC, c.Parent);
// Keep the taller C child with C, and move the other child under A.
if (f.Height > g.Height)
{
c.Child2 = indexF;
a.Child2 = indexG;
g.Parent = indexA;
a.Bounds = GetRef(_nodes, indexB).Bounds.Union(g.Bounds);
c.Bounds = a.Bounds.Union(f.Bounds);
a.Height = 1 + Math.Max(GetRef(_nodes, indexB).Height, g.Height);
c.Height = 1 + Math.Max(a.Height, f.Height);
}
else
{
c.Child2 = indexG;
a.Child2 = indexF;
f.Parent = indexA;
a.Bounds = GetRef(_nodes, indexB).Bounds.Union(f.Bounds);
c.Bounds = a.Bounds.Union(g.Bounds);
a.Height = 1 + Math.Max(GetRef(_nodes, indexB).Height, f.Height);
c.Height = 1 + Math.Max(a.Height, g.Height);
}
return indexC;
}
private int RotateBUp(int indexA, int indexB, int indexC)
{
// Rotate B above A:
//
// Before: A After, if D taller: B
// / \ / \
// B C A D
// / \ / \
// D E E C
//
// After, otherwise: B
// / \
// A E
// / \
// D C
ref var a = ref GetRef(_nodes, indexA);
ref var b = ref GetRef(_nodes, indexB);
var indexD = b.Child1;
var indexE = b.Child2;
ref var d = ref GetRef(_nodes, indexD);
ref var e = ref GetRef(_nodes, indexE);
b.Child1 = indexA;
b.Parent = a.Parent;
a.Parent = indexB;
// B takes A's old place in the parent chain.
ReplaceParentChild(indexA, indexB, b.Parent);
// Keep the taller B child with B, and move the other child under A.
if (d.Height > e.Height)
{
b.Child2 = indexD;
a.Child1 = indexE;
e.Parent = indexA;
a.Bounds = GetRef(_nodes, indexC).Bounds.Union(e.Bounds);
b.Bounds = a.Bounds.Union(d.Bounds);
a.Height = 1 + Math.Max(GetRef(_nodes, indexC).Height, e.Height);
b.Height = 1 + Math.Max(a.Height, d.Height);
}
else
{
b.Child2 = indexE;
a.Child1 = indexD;
d.Parent = indexA;
a.Bounds = GetRef(_nodes, indexC).Bounds.Union(d.Bounds);
b.Bounds = a.Bounds.Union(e.Bounds);
a.Height = 1 + Math.Max(GetRef(_nodes, indexC).Height, d.Height);
b.Height = 1 + Math.Max(a.Height, e.Height);
}
return indexB;
}
private void ReplaceParentChild(int oldChild, int newChild, int parent)
{
if (parent == Null)
{
ref var bucket = ref GetRef(_buckets, GetRef(_nodes, newChild).Bucket);
bucket.Root = newChild;
return;
}
ref var parentNode = ref GetRef(_nodes, parent);
if (parentNode.Child1 == oldChild)
parentNode.Child1 = newChild;
else
parentNode.Child2 = newChild;
}
private void AddUnbounded(CompositionVisual visual, int order, ref Entry entry)
{
var bucketIndex = GetBucketIndex(order);
ref var bucket = ref GetOrCreateBucket(bucketIndex);
(bucket.Unbounded ??= []).Add(visual);
entry.IsUnbounded = true;
}
private void MoveUnbounded(CompositionVisual visual, int oldOrder, int order)
{
var oldBucketIndex = GetBucketIndex(oldOrder);
var newBucketIndex = GetBucketIndex(order);
if (oldBucketIndex == newBucketIndex)
return;
RemoveUnbounded(visual, oldOrder);
ref var newBucket = ref GetOrCreateBucket(newBucketIndex);
(newBucket.Unbounded ??= []).Add(visual);
}
private void RemoveUnbounded(CompositionVisual visual, int order)
{
var bucketIndex = GetBucketIndex(order);
{
ref var bucket = ref GetRef(_buckets, bucketIndex);
bucket.Unbounded!.Remove(visual);
}
RemoveBucketIfEmpty(bucketIndex);
}
private static BoundsState GetBoundsState(CompositionVisual visual, out LtrbRect bounds, out ulong revision)
{
bounds = default;
revision = 0;
var readback = visual.TryGetValidReadback();
if (readback == null)
return BoundsState.Empty;
revision = readback.Revision;
if (visual.DisableSubTreeBoundsHitTestOptimization)
return BoundsState.Unbounded;
if (readback.TransformedSubtreeBounds is not { } subtreeBounds || subtreeBounds.IsZeroSize)
return BoundsState.Empty;
bounds = subtreeBounds;
return BoundsState.Bounded;
}
// Fatten the bounds by a small amount to avoid having to update the tree for every tiny movement.
private static LtrbRect Fatten(LtrbRect bounds) =>
new(bounds.Left - FatBoundsPadding,
bounds.Top - FatBoundsPadding,
bounds.Right + FatBoundsPadding,
bounds.Bottom + FatBoundsPadding);
private static double Perimeter(LtrbRect bounds) => 2 * (bounds.Width + bounds.Height);
private enum BoundsState
{
Empty,
Bounded,
Unbounded
}
private struct Node
{
public LtrbRect Bounds;
public CompositionVisual? Visual;
public int Parent;
public int Child1;
public int Child2;
public int Next;
public int Height;
public int Order;
public int Bucket;
public readonly bool IsLeaf => Child1 == Null;
}
private struct Bucket(int root)
{
public int Root = root;
public List<CompositionVisual>? Unbounded = null;
public ulong ReadbackRevision;
public readonly bool IsEmpty => Root == Null && (Unbounded == null || Unbounded.Count == 0);
}
private struct Entry(int order)
{
public int Order = order;
public int Leaf = Null;
public ulong Revision;
public bool IsUnbounded;
}
private readonly struct Candidate(CompositionVisual visual, int order)
{
public CompositionVisual Visual { get; } = visual;
public int Order { get; } = order;
}
private sealed class CandidateComparer : IComparer<Candidate>
{
// Higher child order is topmost, sort descending.
public int Compare(Candidate left, Candidate right) => right.Order.CompareTo(left.Order);
}
}

163
src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using Avalonia.Collections.Pooled;
using Avalonia.VisualTree;
@ -11,6 +10,9 @@ namespace Avalonia.Rendering.Composition
/// </summary>
internal partial class CompositionTarget
{
private readonly PooledList<CompositionVisual> _hitTestChildCandidates = [];
private bool _hitTestChildCandidatesInUse;
partial void OnRootChanged()
{
if (Root != null)
@ -30,12 +32,11 @@ namespace Avalonia.Rendering.Composition
public PooledList<CompositionVisual>? TryHitTest(Point point, CompositionVisual? root, Func<CompositionVisual, bool>? filter)
{
Server.Compositor.Readback.NextRead();
root ??= Root;
if (root == null)
return null;
var res = new PooledList<CompositionVisual>();
// Need to convert transform the point using visual's readback since HitTestCore will use its inverse matrix
// NOTE: it can technically break hit-testing of the root visual itself if it has a non-identity transform,
// need to investigate that possibility later. We might want a separate mode for root hit-testing.
@ -43,53 +44,159 @@ namespace Avalonia.Rendering.Composition
if (readback == null)
return null;
point = point.Transform(readback.Matrix);
var res = new PooledList<CompositionVisual>();
HitTestCore(root, point, res, filter);
return res;
}
void HitTestCore(CompositionVisual visual, Point parentPoint, PooledList<CompositionVisual> result,
Func<CompositionVisual, bool>? filter)
private PooledList<CompositionVisual> RentHitTestChildCandidates(out bool releaseToField)
{
if (visual.Visible == false)
if (!_hitTestChildCandidatesInUse)
{
_hitTestChildCandidatesInUse = true;
releaseToField = true;
_hitTestChildCandidates.Clear();
return _hitTestChildCandidates;
}
releaseToField = false;
return [];
}
private void ReleaseHitTestChildCandidates(PooledList<CompositionVisual> candidates, bool releaseToField)
{
if (releaseToField)
{
candidates.Clear();
_hitTestChildCandidatesInUse = false;
}
else
{
candidates.Dispose();
}
}
private void HitTestCore(CompositionVisual visual, Point parentPoint, PooledList<CompositionVisual> result, Func<CompositionVisual, bool>? filter)
{
if (!HitTestVisual(visual, parentPoint, filter, out var point))
return;
// Inspect children
if (visual is CompositionContainerVisual cv)
HitTestChildren(cv, point, result, filter);
// Hit-test the current node
if (visual.HitTest(point))
result.Add(visual);
}
private void HitTestChildren(CompositionContainerVisual visual, Point point, PooledList<CompositionVisual> result, Func<CompositionVisual, bool>? filter)
{
if (visual.Children.Count >= CompositionContainerVisual.HitTestAabbTreeThreshold)
{
var candidates = RentHitTestChildCandidates(out var releaseToField);
try
{
if (visual.TryQueryHitTestChildren(point, candidates))
{
foreach (var child in candidates)
HitTestCore(child, point, result, filter);
return;
}
}
finally
{
ReleaseHitTestChildCandidates(candidates, releaseToField);
}
}
for (var c = visual.Children.Count - 1; c >= 0; c--)
HitTestCore(visual.Children[c], point, result, filter);
}
private static bool HitTestVisual(CompositionVisual visual, Point parentPoint, Func<CompositionVisual, bool>? filter, out Point point)
{
point = default;
if (visual.Visible == false)
return false;
if (filter != null && !filter(visual))
return;
return false;
var readback = visual.TryGetValidReadback();
if(readback == null)
return;
return false;
if (!visual.DisableSubTreeBoundsHitTestOptimization &&
(readback.TransformedSubtreeBounds == null ||
!readback.TransformedSubtreeBounds.Value.Contains(parentPoint)))
return;
return false;
if(!readback.Matrix.TryInvert(out var invMatrix))
return;
return false;
point = parentPoint.Transform(invMatrix);
var point = parentPoint.Transform(invMatrix);
if (visual.ClipToBounds
&& (point.X < 0 || point.Y < 0 || point.X > visual.Size.X || point.Y > visual.Size.Y))
return;
return false;
if (visual.Clip?.FillContains(point) == false)
return;
// Inspect children
return false;
return true;
}
public CompositionVisual? TryHitTestFirst(Point point, CompositionVisual? root, Func<CompositionVisual, bool>? filter, Func<CompositionVisual, bool>? resultFilter)
{
Server.Compositor.Readback.NextRead();
root ??= Root;
if (root == null)
return null;
// Need to convert transform the point using visual's readback since HitTestCore will use its inverse matrix
// NOTE: it can technically break hit-testing of the root visual itself if it has a non-identity transform,
// need to investigate that possibility later. We might want a separate mode for root hit-testing.
var readback = root.TryGetValidReadback();
if (readback == null)
return null;
return HitTestFirstCore(root, point.Transform(readback.Matrix), filter, resultFilter);
}
internal CompositionVisual? HitTestFirstCore(CompositionVisual visual, Point parentPoint, Func<CompositionVisual, bool>? filter, Func<CompositionVisual, bool>? resultFilter)
{
if (!HitTestVisual(visual, parentPoint, filter, out var point))
return null;
if (visual is CompositionContainerVisual cv)
for (var c = cv.Children.Count - 1; c >= 0; c--)
{
var queriedIndexedChildren = false;
if (cv.Children.Count >= CompositionContainerVisual.HitTestAabbTreeThreshold)
{
var ch = cv.Children[c];
HitTestCore(ch, point, result, filter);
if (cv.TryQueryFirstHitTestChild(this, point, filter, resultFilter, out var hit))
{
queriedIndexedChildren = true;
if (hit != null)
return hit;
}
}
// Hit-test the current node
if (visual.HitTest(point))
result.Add(visual);
if (!queriedIndexedChildren)
{
for (var c = cv.Children.Count - 1; c >= 0; c--)
{
var hit = HitTestFirstCore(cv.Children[c], point, filter, resultFilter);
if (hit != null)
return hit;
}
}
}
return visual.HitTest(point) && (resultFilter == null || resultFilter(visual)) ? visual : null;
}
/// <summary>

70
src/Avalonia.Base/Rendering/Composition/ContainerVisual.cs

@ -1,3 +1,5 @@
using System;
using Avalonia.Collections.Pooled;
using Avalonia.Rendering.Composition.Server;
namespace Avalonia.Rendering.Composition
@ -7,6 +9,9 @@ namespace Avalonia.Rendering.Composition
/// </summary>
public partial class CompositionContainerVisual : CompositionVisual
{
internal const int HitTestAabbTreeThreshold = 32;
private CompositionHitTestAabbTree? _hitTestChildren;
public CompositionVisualCollection Children { get; private set; } = null!;
partial void InitializeDefaultsExtra()
@ -20,5 +25,70 @@ namespace Avalonia.Rendering.Composition
ch.Root = Root;
base.OnRootChangedCore();
}
internal void AddHitTestChild(CompositionVisual child)
{
if (_hitTestChildren == null)
return;
var order = Children.IndexOf(child);
if (order >= 0)
_hitTestChildren.Update(child, order);
UpdateHitTestChildOrder();
}
internal void RemoveHitTestChild(CompositionVisual child)
{
if (_hitTestChildren == null)
return;
_hitTestChildren.Remove(child);
UpdateHitTestChildOrder();
}
internal void ClearHitTestChildren()
{
_hitTestChildren?.Clear();
}
internal bool TryQueryHitTestChildren(Point point, PooledList<CompositionVisual> results)
{
if (Children.Count < HitTestAabbTreeThreshold)
{
_hitTestChildren = null;
return false;
}
_hitTestChildren ??= new CompositionHitTestAabbTree(Children);
_hitTestChildren.Query(point, results, Server.Compositor.Readback.ReadRevision);
return true;
}
internal bool TryQueryFirstHitTestChild(CompositionTarget target, Point point, Func<CompositionVisual, bool>? filter, Func<CompositionVisual, bool>? resultFilter, out CompositionVisual? hit)
{
if (Children.Count < HitTestAabbTreeThreshold)
{
_hitTestChildren = null;
hit = null;
return false;
}
_hitTestChildren ??= new CompositionHitTestAabbTree(Children);
hit = _hitTestChildren.QueryFirst(target, point, filter, resultFilter, Server.Compositor.Readback.ReadRevision);
return true;
}
private void UpdateHitTestChildOrder()
{
if (_hitTestChildren == null)
return;
for (var i = 0; i < Children.Count; i++)
_hitTestChildren.UpdateOrder(Children[i], i);
}
}
}

32
src/Avalonia.Base/Rendering/Composition/VisualCollection.cs

@ -38,7 +38,11 @@ namespace Avalonia.Rendering.Composition
public void RemoveAll() => Clear();
partial void OnAdded(CompositionVisual item) => item.Parent = _owner;
partial void OnAdded(CompositionVisual item)
{
item.Parent = _owner;
AddHitTestChild(item);
}
partial void OnBeforeReplace(CompositionVisual oldItem, CompositionVisual newItem)
{
@ -55,7 +59,11 @@ namespace Avalonia.Rendering.Composition
}
}
partial void OnRemoved(CompositionVisual item) => item.Parent = null;
partial void OnRemoved(CompositionVisual item)
{
item.Parent = null;
RemoveHitTestChild(item);
}
partial void OnBeforeClear()
{
@ -63,11 +71,31 @@ namespace Avalonia.Rendering.Composition
i.Parent = null;
}
partial void OnClear() => ClearHitTestChildren();
partial void OnBeforeAdded(CompositionVisual item)
{
if (item.Parent != null)
throw new InvalidOperationException("Visual already has a parent");
item.Parent = _owner;
}
private void AddHitTestChild(CompositionVisual item)
{
if (_owner is CompositionContainerVisual container)
container.AddHitTestChild(item);
}
private void RemoveHitTestChild(CompositionVisual item)
{
if (_owner is CompositionContainerVisual container)
container.RemoveHitTestChild(item);
}
private void ClearHitTestChildren()
{
if (_owner is CompositionContainerVisual container)
container.ClearHitTestChildren();
}
}
}

134
tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs

@ -8,6 +8,7 @@ using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering;
using Avalonia.Rendering.Composition;
using Avalonia.UnitTests;
using Avalonia.VisualTree;
using Moq;
@ -494,6 +495,139 @@ public class CompositorHitTestingTests : CompositorTestsBase
}
}
[Fact]
public void HitTestFirst_Should_Skip_Element_Child_Composition_Visual()
{
using (var s = new CompositorTestServices(new Size(200, 200)))
{
var target = new Border
{
Width = 200,
Height = 200,
Background = Brushes.Red
};
s.TopLevel.Content = target;
s.RunJobs();
var childVisual = s.Compositor.CreateSolidColorVisual();
childVisual.Size = new Vector(200, 200);
childVisual.Color = Colors.Blue;
ElementComposition.SetElementChildVisual(target, childVisual);
s.AssertHitTestFirst(new Point(100, 100), null, target);
}
}
[Fact]
public void HitTest_Should_Find_Control_With_Many_Siblings()
{
using (var s = new CompositorTestServices(new Size(1000, 200)))
{
Border target = null!;
var canvas = new Canvas { Width = 1000, Height = 200 };
for (var i = 0; i < 70; i++)
{
var child = new Border { Width = 8, Height = 8, Background = Brushes.Red };
Canvas.SetLeft(child, i * 12);
canvas.Children.Add(child);
if (i == 0)
target = child;
}
s.TopLevel.Content = canvas;
s.AssertHitTestFirst(new Point(4, 4), null, target);
}
}
[Fact]
public void HitTest_Should_Return_Top_Controls_First_With_Many_Overlapping_Siblings()
{
using (var s = new CompositorTestServices(new Size(200, 200)))
{
Border top = null!;
var canvas = new Canvas { Width = 200, Height = 200 };
for (var i = 0; i < 70; i++)
{
var child = new Border { Width = 100, Height = 100, Background = Brushes.Red };
Canvas.SetLeft(child, 50);
Canvas.SetTop(child, 50);
canvas.Children.Add(child);
if (i == 69)
top = child;
}
s.TopLevel.Content = canvas;
s.AssertHitTestFirst(new Point(100, 100), null, top);
s.AssertHitTest(new Point(100, 100), null, canvas.Children.Cast<Visual>().Reverse().ToArray());
}
}
[Fact]
public void HitTest_Should_Update_Many_Sibling_Index_When_Child_Moves()
{
using (var s = new CompositorTestServices(new Size(1000, 200)))
{
Border moving = null!;
var canvas = new Canvas { Width = 1000, Height = 200 };
for (var i = 0; i < 70; i++)
{
var child = new Border { Width = 8, Height = 8, Background = Brushes.Red };
Canvas.SetLeft(child, i * 12);
canvas.Children.Add(child);
if (i == 69)
moving = child;
}
s.TopLevel.Content = canvas;
s.AssertHitTestFirst(new Point(69 * 12 + 4, 4), null, moving);
Canvas.SetLeft(moving, 10);
Canvas.SetTop(moving, 100);
s.AssertHitTestFirst(new Point(14, 104), null, moving);
}
}
[Fact]
public void HitTest_Should_Update_Many_Sibling_Index_When_Child_Is_Added_And_Removed()
{
using (var s = new CompositorTestServices(new Size(200, 200)))
{
Border top = null!;
var canvas = new Canvas { Width = 200, Height = 200 };
for (var i = 0; i < 70; i++)
{
var child = new Border { Width = 100, Height = 100, Background = Brushes.Red };
Canvas.SetLeft(child, 50);
Canvas.SetTop(child, 50);
canvas.Children.Add(child);
if (i == 69)
top = child;
}
s.TopLevel.Content = canvas;
s.AssertHitTestFirst(new Point(100, 100), null, top);
var added = new Border { Width = 100, Height = 100, Background = Brushes.Blue };
Canvas.SetLeft(added, 50);
Canvas.SetTop(added, 50);
canvas.Children.Add(added);
s.AssertHitTestFirst(new Point(100, 100), null, added);
canvas.Children.Remove(added);
s.AssertHitTestFirst(new Point(100, 100), null, top);
}
}
private static IDisposable TestApplication()
{
return UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);

2
tests/Avalonia.UnitTests/CompositorTestServices.cs

@ -110,7 +110,7 @@ public class CompositorTestServices : IDisposable
public void AssertHitTestFirst(Point pt, Func<Visual, bool>? filter, Visual? expected)
{
RunJobs();
var tested = Renderer.HitTest(pt, TopLevel, filter).First();
var tested = Renderer.HitTestFirst(pt, TopLevel, filter);
Assert.Equal(expected, tested);
}

Loading…
Cancel
Save