diff --git a/src/Avalonia.Layout/EffectiveViewportChangedEventArgs.cs b/src/Avalonia.Layout/EffectiveViewportChangedEventArgs.cs
new file mode 100644
index 0000000000..1cdc775b13
--- /dev/null
+++ b/src/Avalonia.Layout/EffectiveViewportChangedEventArgs.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace Avalonia.Layout
+{
+ ///
+ /// Provides data for the event.
+ ///
+ public class EffectiveViewportChangedEventArgs : EventArgs
+ {
+ public EffectiveViewportChangedEventArgs(Rect effectiveViewport)
+ {
+ EffectiveViewport = effectiveViewport;
+ }
+
+ ///
+ /// Gets the representing the effective viewport.
+ ///
+ ///
+ /// The viewport is expressed in coordinates relative to the control that the event is
+ /// raised on.
+ ///
+ public Rect EffectiveViewport { get; }
+ }
+}
diff --git a/src/Avalonia.Layout/ILayoutManager.cs b/src/Avalonia.Layout/ILayoutManager.cs
index d996b301f8..614670a53b 100644
--- a/src/Avalonia.Layout/ILayoutManager.cs
+++ b/src/Avalonia.Layout/ILayoutManager.cs
@@ -54,5 +54,17 @@ namespace Avalonia.Layout
///
[Obsolete("Call ExecuteInitialLayoutPass without parameter")]
void ExecuteInitialLayoutPass(ILayoutRoot root);
+
+ ///
+ /// Registers a control as wanting to receive effective viewport notifications.
+ ///
+ /// The control.
+ void RegisterEffectiveViewportListener(ILayoutable control);
+
+ ///
+ /// Registers a control as no longer wanting to receive effective viewport notifications.
+ ///
+ /// The control.
+ void UnregisterEffectiveViewportListener(ILayoutable control);
}
}
diff --git a/src/Avalonia.Layout/ILayoutable.cs b/src/Avalonia.Layout/ILayoutable.cs
index 316a017f1d..54d3ba6a11 100644
--- a/src/Avalonia.Layout/ILayoutable.cs
+++ b/src/Avalonia.Layout/ILayoutable.cs
@@ -111,5 +111,12 @@ namespace Avalonia.Layout
///
/// The child control.
void ChildDesiredSizeChanged(ILayoutable control);
+
+ ///
+ /// Used by the to notify the control that its effective
+ /// viewport is changed.
+ ///
+ /// The viewport information.
+ void EffectiveViewportChanged(EffectiveViewportChangedEventArgs e);
}
}
diff --git a/src/Avalonia.Layout/LayoutManager.cs b/src/Avalonia.Layout/LayoutManager.cs
index c923aa62e9..888c8a4910 100644
--- a/src/Avalonia.Layout/LayoutManager.cs
+++ b/src/Avalonia.Layout/LayoutManager.cs
@@ -1,7 +1,9 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics;
using Avalonia.Logging;
using Avalonia.Threading;
+using Avalonia.VisualTree;
#nullable enable
@@ -12,10 +14,12 @@ namespace Avalonia.Layout
///
public class LayoutManager : ILayoutManager, IDisposable
{
+ private const int MaxPasses = 3;
private readonly ILayoutRoot _owner;
private readonly LayoutQueue _toMeasure = new LayoutQueue(v => !v.IsMeasureValid);
private readonly LayoutQueue _toArrange = new LayoutQueue(v => !v.IsArrangeValid);
private readonly Action _executeLayoutPass;
+ private List? _effectiveViewportChangedListeners;
private bool _disposed;
private bool _queued;
private bool _running;
@@ -92,8 +96,6 @@ namespace Avalonia.Layout
///
public virtual void ExecuteLayoutPass()
{
- const int MaxPasses = 3;
-
Dispatcher.UIThread.VerifyAccess();
if (_disposed)
@@ -125,23 +127,15 @@ namespace Avalonia.Layout
_toMeasure.BeginLoop(MaxPasses);
_toArrange.BeginLoop(MaxPasses);
- try
+ for (var pass = 0; pass < MaxPasses; ++pass)
{
- for (var pass = 0; pass < MaxPasses; ++pass)
- {
- ExecuteMeasurePass();
- ExecuteArrangePass();
+ InnerLayoutPass();
- if (_toMeasure.Count == 0)
- {
- break;
- }
+ if (!RaiseEffectiveViewportChanged())
+ {
+ break;
}
}
- finally
- {
- _running = false;
- }
_toMeasure.EndLoop();
_toArrange.EndLoop();
@@ -202,6 +196,47 @@ namespace Avalonia.Layout
_toArrange.Dispose();
}
+ void ILayoutManager.RegisterEffectiveViewportListener(ILayoutable control)
+ {
+ _effectiveViewportChangedListeners ??= new List();
+ _effectiveViewportChangedListeners.Add(new EffectiveViewportChangedListener(control));
+ }
+
+ void ILayoutManager.UnregisterEffectiveViewportListener(ILayoutable control)
+ {
+ if (_effectiveViewportChangedListeners is object)
+ {
+ for (var i = 0; i < _effectiveViewportChangedListeners.Count; ++i)
+ {
+ if (_effectiveViewportChangedListeners[i].Listener == control)
+ {
+ _effectiveViewportChangedListeners.RemoveAt(i);
+ }
+ }
+ }
+ }
+
+ private void InnerLayoutPass()
+ {
+ try
+ {
+ for (var pass = 0; pass < MaxPasses; ++pass)
+ {
+ ExecuteMeasurePass();
+ ExecuteArrangePass();
+
+ if (_toMeasure.Count == 0)
+ {
+ break;
+ }
+ }
+ }
+ finally
+ {
+ _running = false;
+ }
+ }
+
private void ExecuteMeasurePass()
{
while (_toMeasure.Count > 0)
@@ -285,5 +320,64 @@ namespace Avalonia.Layout
_queued = true;
}
}
+
+ private bool RaiseEffectiveViewportChanged()
+ {
+ var startCount = _toMeasure.Count + _toArrange.Count;
+
+ if (_effectiveViewportChangedListeners is object)
+ {
+ // TODO: This may not work correctly if listener is removed in event handler.
+ for (var i = 0; i < _effectiveViewportChangedListeners.Count; ++i)
+ {
+ var l = _effectiveViewportChangedListeners[i];
+ var viewport = new Rect(0, 0, double.PositiveInfinity, double.PositiveInfinity);
+ CalculateEffectiveViewport(l.Listener, ref viewport);
+
+ if (viewport != l.Viewport)
+ {
+ l.Listener.EffectiveViewportChanged(new EffectiveViewportChangedEventArgs(viewport));
+ _effectiveViewportChangedListeners[i] = new EffectiveViewportChangedListener(l.Listener, viewport);
+ }
+ }
+ }
+
+ return startCount != _toMeasure.Count + _toMeasure.Count;
+ }
+
+ private void CalculateEffectiveViewport(IVisual control, ref Rect viewport)
+ {
+ if (control.VisualParent is object)
+ {
+ CalculateEffectiveViewport(control.VisualParent, ref viewport);
+ }
+
+ if (control.ClipToBounds || control.VisualParent is null)
+ {
+ viewport = control.Bounds;
+ }
+ else
+ {
+ viewport = viewport.Translate(-control.Bounds.Position);
+ }
+ }
+
+ private readonly struct EffectiveViewportChangedListener
+ {
+ public EffectiveViewportChangedListener(ILayoutable listener)
+ {
+ Listener = listener;
+ Viewport = new Rect(double.NaN, double.NaN, double.NaN, double.NaN);
+ }
+
+ public EffectiveViewportChangedListener(ILayoutable listener, Rect viewport)
+ {
+ Listener = listener;
+ Viewport = viewport;
+ }
+
+ public ILayoutable Listener { get; }
+ public Rect Viewport { get; }
+ }
}
}
diff --git a/src/Avalonia.Layout/Layoutable.cs b/src/Avalonia.Layout/Layoutable.cs
index 8d2a825fa0..e62e22f8ec 100644
--- a/src/Avalonia.Layout/Layoutable.cs
+++ b/src/Avalonia.Layout/Layoutable.cs
@@ -132,6 +132,7 @@ namespace Avalonia.Layout
private bool _measuring;
private Size? _previousMeasure;
private Rect? _previousArrange;
+ private EventHandler? _effectiveViewportChanged;
private EventHandler? _layoutUpdated;
///
@@ -152,6 +153,32 @@ namespace Avalonia.Layout
VerticalAlignmentProperty);
}
+ ///
+ /// Occurs when the element's effective viewport changes.
+ ///
+ public event EventHandler? EffectiveViewportChanged
+ {
+ add
+ {
+ if (_effectiveViewportChanged is null && VisualRoot is ILayoutRoot r)
+ {
+ r.LayoutManager.RegisterEffectiveViewportListener(this);
+ }
+
+ _effectiveViewportChanged += value;
+ }
+
+ remove
+ {
+ _effectiveViewportChanged -= value;
+
+ if (_effectiveViewportChanged is null && VisualRoot is ILayoutRoot r)
+ {
+ r.LayoutManager.UnregisterEffectiveViewportListener(this);
+ }
+ }
+ }
+
///
/// Occurs when a layout pass completes for the control.
///
@@ -384,13 +411,6 @@ namespace Avalonia.Layout
}
}
- ///
- /// Called by InvalidateMeasure
- ///
- protected virtual void OnMeasureInvalidated()
- {
- }
-
///
/// Invalidates the measurement of the control and queues a new layout pass.
///
@@ -436,6 +456,11 @@ namespace Avalonia.Layout
}
}
+ void ILayoutable.EffectiveViewportChanged(EffectiveViewportChangedEventArgs e)
+ {
+ _effectiveViewportChanged?.Invoke(this, e);
+ }
+
///
/// Marks a property as affecting the control's measurement.
///
@@ -717,9 +742,17 @@ namespace Avalonia.Layout
{
base.OnAttachedToVisualTreeCore(e);
- if (_layoutUpdated is object && e.Root is ILayoutRoot r)
+ if (e.Root is ILayoutRoot r)
{
- r.LayoutManager.LayoutUpdated += LayoutManagedLayoutUpdated;
+ if (_layoutUpdated is object)
+ {
+ r.LayoutManager.LayoutUpdated += LayoutManagedLayoutUpdated;
+ }
+
+ if (_effectiveViewportChanged is object)
+ {
+ r.LayoutManager.RegisterEffectiveViewportListener(this);
+ }
}
}
@@ -727,12 +760,27 @@ namespace Avalonia.Layout
{
base.OnDetachedFromVisualTreeCore(e);
- if (_layoutUpdated is object && e.Root is ILayoutRoot r)
+ if (e.Root is ILayoutRoot r)
{
- r.LayoutManager.LayoutUpdated -= LayoutManagedLayoutUpdated;
+ if (_layoutUpdated is object)
+ {
+ r.LayoutManager.LayoutUpdated -= LayoutManagedLayoutUpdated;
+ }
+
+ if (_effectiveViewportChanged is object)
+ {
+ r.LayoutManager.UnregisterEffectiveViewportListener(this);
+ }
}
}
+ ///
+ /// Called by InvalidateMeasure
+ ///
+ protected virtual void OnMeasureInvalidated()
+ {
+ }
+
///
protected sealed override void OnVisualParentChanged(IVisual oldParent, IVisual newParent)
{
diff --git a/tests/Avalonia.Layout.UnitTests/LayoutableTests_EffectiveViewportChanged.cs b/tests/Avalonia.Layout.UnitTests/LayoutableTests_EffectiveViewportChanged.cs
new file mode 100644
index 0000000000..8226eb9c2a
--- /dev/null
+++ b/tests/Avalonia.Layout.UnitTests/LayoutableTests_EffectiveViewportChanged.cs
@@ -0,0 +1,333 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Layout.UnitTests
+{
+ public class LayoutableTests_EffectiveViewportChanged
+ {
+ [Fact]
+ public void EffectiveViewportChanged_Not_Raised_When_Control_Added_To_Tree()
+ {
+ var root = new TestRoot();
+ var canvas = new Canvas();
+ var raised = 0;
+
+ canvas.EffectiveViewportChanged += (s, e) =>
+ {
+ ++raised;
+ };
+
+ root.Child = canvas;
+
+ Assert.Equal(0, raised);
+ }
+
+ [Fact]
+ public void EffectiveViewportChanged_Raised_Before_LayoutUpdated()
+ {
+ var root = new TestRoot();
+ var canvas = new Canvas();
+ var raised = 0;
+ var layoutUpdatedRaised = 0;
+
+ canvas.LayoutUpdated += (s, e) =>
+ {
+ Assert.Equal(1, raised);
+ ++layoutUpdatedRaised;
+ };
+
+ canvas.EffectiveViewportChanged += (s, e) =>
+ {
+ ++raised;
+ };
+
+ root.Child = canvas;
+ root.LayoutManager.ExecuteInitialLayoutPass(root);
+
+ Assert.Equal(1, layoutUpdatedRaised);
+ Assert.Equal(1, raised);
+ }
+
+ [Fact]
+ public void Invalidating_In_Handler_Causes_Layout_To_Be_Rerun_Before_LayoutUpdated()
+ {
+ var root = new TestRoot();
+ var canvas = new TestCanvas();
+ var raised = 0;
+ var layoutUpdatedRaised = 0;
+
+ canvas.LayoutUpdated += (s, e) =>
+ {
+ Assert.Equal(2, canvas.MeasureCount);
+ Assert.Equal(2, canvas.ArrangeCount);
+ ++layoutUpdatedRaised;
+ };
+
+ canvas.EffectiveViewportChanged += (s, e) =>
+ {
+ canvas.InvalidateMeasure();
+ ++raised;
+ };
+
+ root.Child = canvas;
+ root.LayoutManager.ExecuteInitialLayoutPass(root);
+
+ Assert.Equal(1, raised);
+ Assert.Equal(1, layoutUpdatedRaised);
+ }
+
+ [Fact]
+ public void Viewport_Extends_Beyond_Centered_Control()
+ {
+ var root = new TestRoot
+ {
+ Width = 1200,
+ Height = 900,
+ };
+
+ var canvas = new Canvas
+ {
+ Width = 52,
+ Height = 52,
+ };
+ var raised = 0;
+
+ canvas.EffectiveViewportChanged += (s, e) =>
+ {
+ Assert.Equal(new Rect(-574, -424, 1200, 900), e.EffectiveViewport);
+ ++raised;
+ };
+
+ root.Child = canvas;
+ root.LayoutManager.ExecuteInitialLayoutPass(root);
+
+ Assert.Equal(1, raised);
+ }
+
+ [Fact]
+ public void Viewport_Extends_Beyond_Nested_Centered_Control()
+ {
+ var root = new TestRoot
+ {
+ Width = 1200,
+ Height = 900,
+ };
+
+ var canvas = new Canvas
+ {
+ Width = 52,
+ Height = 52,
+ };
+
+ var outer = new Border
+ {
+ Width = 100,
+ Height = 100,
+ Child = canvas,
+ };
+
+ var raised = 0;
+
+ canvas.EffectiveViewportChanged += (s, e) =>
+ {
+ Assert.Equal(new Rect(-574, -424, 1200, 900), e.EffectiveViewport);
+ ++raised;
+ };
+
+ root.Child = outer;
+ root.LayoutManager.ExecuteInitialLayoutPass(root);
+
+ Assert.Equal(1, raised);
+ }
+
+ [Fact]
+ public void ScrollViewer_Determines_EffectiveViewport()
+ {
+ var root = new TestRoot
+ {
+ Width = 1200,
+ Height = 900,
+ };
+
+ var canvas = new Canvas
+ {
+ Width = 200,
+ Height = 200,
+ };
+
+ var outer = new ScrollViewer
+ {
+ Width = 100,
+ Height = 100,
+ Content = canvas,
+ Template = ScrollViewerTemplate(),
+ };
+
+ var raised = 0;
+
+ canvas.EffectiveViewportChanged += (s, e) =>
+ {
+ Assert.Equal(new Rect(0, 0, 100, 100), e.EffectiveViewport);
+ ++raised;
+ };
+
+ root.Child = outer;
+ root.LayoutManager.ExecuteInitialLayoutPass(root);
+
+ Assert.Equal(1, raised);
+ }
+
+ [Fact]
+ public void Scrolled_ScrollViewer_Determines_EffectiveViewport()
+ {
+ var root = new TestRoot
+ {
+ Width = 1200,
+ Height = 900,
+ };
+
+ var canvas = new Canvas
+ {
+ Width = 200,
+ Height = 200,
+ };
+
+ var outer = new ScrollViewer
+ {
+ Width = 100,
+ Height = 100,
+ Content = canvas,
+ Template = ScrollViewerTemplate(),
+ };
+
+ var raised = 0;
+
+ root.Child = outer;
+ root.LayoutManager.ExecuteInitialLayoutPass(root);
+
+ canvas.EffectiveViewportChanged += (s, e) =>
+ {
+ Assert.Equal(new Rect(0, 10, 100, 100), e.EffectiveViewport);
+ ++raised;
+ };
+
+ outer.Offset = new Vector(0, 10);
+ root.LayoutManager.ExecuteLayoutPass();
+
+ Assert.Equal(1, raised);
+ }
+
+ [Fact]
+ public void Moving_Parent_Updates_EffectiveViewport()
+ {
+ var root = new TestRoot
+ {
+ Width = 1200,
+ Height = 900,
+ };
+
+ var canvas = new Canvas
+ {
+ Width = 100,
+ Height = 100,
+ };
+
+ var outer = new Border
+ {
+ Width = 200,
+ Height = 200,
+ Child = canvas,
+ };
+
+ var raised = 0;
+
+ root.Child = outer;
+ root.LayoutManager.ExecuteInitialLayoutPass(root);
+
+ canvas.EffectiveViewportChanged += (s, e) =>
+ {
+ Assert.Equal(new Rect(-554, -400, 1200, 900), e.EffectiveViewport);
+ ++raised;
+ };
+
+ // Change the parent margin to move it.
+ outer.Margin = new Thickness(8, 0, 0, 0);
+ root.LayoutManager.ExecuteLayoutPass();
+
+ Assert.Equal(1, raised);
+ }
+
+ private IControlTemplate ScrollViewerTemplate()
+ {
+ return new FuncControlTemplate((control, scope) => new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions
+ {
+ new ColumnDefinition(1, GridUnitType.Star),
+ new ColumnDefinition(GridLength.Auto),
+ },
+ RowDefinitions = new RowDefinitions
+ {
+ new RowDefinition(1, GridUnitType.Star),
+ new RowDefinition(GridLength.Auto),
+ },
+ Children =
+ {
+ new ScrollContentPresenter
+ {
+ Name = "PART_ContentPresenter",
+ [~ContentPresenter.ContentProperty] = control[~ContentControl.ContentProperty],
+ [~~ScrollContentPresenter.ExtentProperty] = control[~~ScrollViewer.ExtentProperty],
+ [~~ScrollContentPresenter.OffsetProperty] = control[~~ScrollViewer.OffsetProperty],
+ [~~ScrollContentPresenter.ViewportProperty] = control[~~ScrollViewer.ViewportProperty],
+ [~ScrollContentPresenter.CanHorizontallyScrollProperty] = control[~ScrollViewer.CanHorizontallyScrollProperty],
+ [~ScrollContentPresenter.CanVerticallyScrollProperty] = control[~ScrollViewer.CanVerticallyScrollProperty],
+ }.RegisterInNameScope(scope),
+ new ScrollBar
+ {
+ Name = "horizontalScrollBar",
+ Orientation = Orientation.Horizontal,
+ [~RangeBase.MaximumProperty] = control[~ScrollViewer.HorizontalScrollBarMaximumProperty],
+ [~~RangeBase.ValueProperty] = control[~~ScrollViewer.HorizontalScrollBarValueProperty],
+ [~ScrollBar.ViewportSizeProperty] = control[~ScrollViewer.HorizontalScrollBarViewportSizeProperty],
+ [~ScrollBar.VisibilityProperty] = control[~ScrollViewer.HorizontalScrollBarVisibilityProperty],
+ [Grid.RowProperty] = 1,
+ }.RegisterInNameScope(scope),
+ new ScrollBar
+ {
+ Name = "verticalScrollBar",
+ Orientation = Orientation.Vertical,
+ [~RangeBase.MaximumProperty] = control[~ScrollViewer.VerticalScrollBarMaximumProperty],
+ [~~RangeBase.ValueProperty] = control[~~ScrollViewer.VerticalScrollBarValueProperty],
+ [~ScrollBar.ViewportSizeProperty] = control[~ScrollViewer.VerticalScrollBarViewportSizeProperty],
+ [~ScrollBar.VisibilityProperty] = control[~ScrollViewer.VerticalScrollBarVisibilityProperty],
+ [Grid.ColumnProperty] = 1,
+ }.RegisterInNameScope(scope),
+ },
+ });
+ }
+
+
+ private class TestCanvas : Canvas
+ {
+ public int MeasureCount { get; private set; }
+ public int ArrangeCount { get; private set; }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ ++MeasureCount;
+ return base.MeasureOverride(availableSize);
+ }
+
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ ++ArrangeCount;
+ return base.ArrangeOverride(finalSize);
+ }
+ }
+ }
+}