From 9c7aeaf71311f14e404112d3630fe0219a80da0c Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 24 Jun 2020 11:34:39 +0200 Subject: [PATCH] Initial implementation of EffectiveViewportChanged. --- .../EffectiveViewportChangedEventArgs.cs | 24 ++ src/Avalonia.Layout/ILayoutManager.cs | 12 + src/Avalonia.Layout/ILayoutable.cs | 7 + src/Avalonia.Layout/LayoutManager.cs | 124 ++++++- src/Avalonia.Layout/Layoutable.cs | 70 +++- ...ayoutableTests_EffectiveViewportChanged.cs | 333 ++++++++++++++++++ 6 files changed, 544 insertions(+), 26 deletions(-) create mode 100644 src/Avalonia.Layout/EffectiveViewportChangedEventArgs.cs create mode 100644 tests/Avalonia.Layout.UnitTests/LayoutableTests_EffectiveViewportChanged.cs 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); + } + } + } +}