Browse Source

Overhauled layout system.

From lessons learnt from porting moonlight's Grid tests. Instead of
making a child invalidate its parents directly, send a message from the
child to the parent when its DesiredSize changes. LayoutManager also
needed rewriting to allow this.
pull/387/merge
Steven Kirk 10 years ago
parent
commit
0e2cee8810
  1. 6
      src/Perspex.Layout/ILayoutable.cs
  2. 306
      src/Perspex.Layout/LayoutManager.cs
  3. 66
      src/Perspex.Layout/Layoutable.cs
  4. 38
      tests/Perspex.Layout.UnitTests/LayoutManagerTests.cs
  5. 21
      tests/Perspex.Layout.UnitTests/MeasureTests.cs
  6. 2
      tests/Perspex.Layout.UnitTests/Perspex.Layout.UnitTests.csproj
  7. 27
      tests/Perspex.Layout.UnitTests/TestLayoutRoot.cs
  8. 4
      tests/Perspex.SceneGraph.UnitTests/VisualTree/BoundsTrackerTests.cs

6
src/Perspex.Layout/ILayoutable.cs

@ -112,5 +112,11 @@ namespace Perspex.Layout
/// Invalidates the arrangement of the control and queues a new layout pass.
/// </summary>
void InvalidateArrange();
/// <summary>
/// Called when a child control's desired size changes.
/// </summary>
/// <param name="control">The child control.</param>
void ChildDesiredSizeChanged(ILayoutable control);
}
}

306
src/Perspex.Layout/LayoutManager.cs

@ -3,10 +3,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive;
using System.Reactive.Disposables;
using System.Reactive.Subjects;
using Perspex.VisualTree;
using Perspex.Threading;
using Serilog;
using Serilog.Core.Enrichers;
@ -15,53 +15,15 @@ namespace Perspex.Layout
/// <summary>
/// Manages measuring and arranging of controls.
/// </summary>
/// <remarks>
/// Each layout root element such as a window has its own LayoutManager that is responsible
/// for laying out its child controls. When a layout is required the <see cref="LayoutNeeded"/>
/// observable will fire and the root element should respond by calling
/// <see cref="ExecuteLayoutPass"/> at the earliest opportunity to carry out the layout.
/// </remarks>
public class LayoutManager : ILayoutManager
{
/// <summary>
/// The maximum number of times a measure/arrange loop can be retried.
/// </summary>
private const int MaxTries = 3;
/// <summary>
/// Called when a layout is needed.
/// </summary>
private readonly Subject<Unit> _layoutNeeded;
/// <summary>
/// Called when a layout is completed.
/// </summary>
private readonly Subject<Unit> _layoutCompleted;
/// <summary>
/// Whether a measure is needed on the next layout pass.
/// </summary>
private bool _measureNeeded = true;
/// <summary>
/// The controls that need to be measured.
/// </summary>
private List<Item> _toMeasure = new List<Item>();
/// <summary>
/// The controls that need to be arranged.
/// </summary>
private List<Item> _toArrange = new List<Item>();
/// <summary>
/// Prevents re-entrancy.
/// </summary>
private bool _running;
/// <summary>
/// The logger to use.
/// </summary>
private readonly Queue<ILayoutable> _toMeasure = new Queue<ILayoutable>();
private readonly Queue<ILayoutable> _toArrange = new Queue<ILayoutable>();
private readonly Subject<Unit> _layoutNeeded = new Subject<Unit>();
private readonly Subject<Unit> _layoutCompleted = new Subject<Unit>();
private readonly ILogger _log;
private bool _first = true;
private bool _running;
/// <summary>
/// Initializes a new instance of the <see cref="LayoutManager"/> class.
@ -74,9 +36,6 @@ namespace Perspex.Layout
new PropertyEnricher("SourceContext", GetType()),
new PropertyEnricher("Id", GetHashCode()),
});
_layoutNeeded = new Subject<Unit>();
_layoutCompleted = new Subject<Unit>();
}
/// <summary>
@ -114,20 +73,52 @@ namespace Perspex.Layout
private set;
}
/// <summary>
/// Notifies the layout manager that a control requires a measure.
/// </summary>
/// <param name="control">The control.</param>
/// <param name="distance">The control's distance from the layout root.</param>
public void InvalidateMeasure(ILayoutable control, int distance)
{
Contract.Requires<ArgumentNullException>(control != null);
Dispatcher.UIThread.VerifyAccess();
_toMeasure.Enqueue(control);
_toArrange.Enqueue(control);
FireLayoutNeeded();
}
/// <summary>
/// Notifies the layout manager that a control requires an arrange.
/// </summary>
/// <param name="control">The control.</param>
/// <param name="distance">The control's distance from the layout root.</param>
public void InvalidateArrange(ILayoutable control, int distance)
{
Contract.Requires<ArgumentNullException>(control != null);
Dispatcher.UIThread.VerifyAccess();
_toArrange.Enqueue(control);
FireLayoutNeeded();
}
/// <summary>
/// Executes a layout pass.
/// </summary>
public void ExecuteLayoutPass()
{
if (_running)
const int MaxPasses = 3;
Dispatcher.UIThread.VerifyAccess();
if (Root == null)
{
return;
throw new InvalidOperationException("Root must be set before executing layout pass.");
}
using (Disposable.Create(() => _running = false))
if (!_running)
{
_running = true;
LayoutQueued = false;
_log.Information(
"Started layout pass. To measure: {Measure} To arrange: {Arrange}",
@ -137,21 +128,31 @@ namespace Perspex.Layout
var stopwatch = new System.Diagnostics.Stopwatch();
stopwatch.Start();
for (int i = 0; i < MaxTries; ++i)
try
{
if (_measureNeeded)
if (_first)
{
ExecuteMeasure();
_measureNeeded = false;
Measure(Root);
Arrange(Root);
_first = false;
}
ExecuteArrange();
if (_toMeasure.Count == 0)
for (var pass = 0; pass < MaxPasses; ++pass)
{
break;
ExecuteMeasurePass();
ExecuteArrangePass();
if (_toMeasure.Count == 0)
{
break;
}
}
}
finally
{
_running = false;
LayoutQueued = false;
}
stopwatch.Stop();
_log.Information("Layout pass finised in {Time}", stopwatch.Elapsed);
@ -160,181 +161,58 @@ namespace Perspex.Layout
}
}
/// <summary>
/// Notifies the layout manager that a control requires a measure.
/// </summary>
/// <param name="control">The control.</param>
/// <param name="distance">The control's distance from the layout root.</param>
public void InvalidateMeasure(ILayoutable control, int distance)
private void ExecuteMeasurePass()
{
var item = new Item(control, distance);
_toMeasure.Add(item);
_toArrange.Add(item);
_measureNeeded = true;
if (!LayoutQueued)
while (_toMeasure.Count > 0)
{
IVisual visual = control as IVisual;
_layoutNeeded.OnNext(Unit.Default);
LayoutQueued = true;
var next = _toMeasure.Dequeue();
Measure(next);
}
}
/// <summary>
/// Notifies the layout manager that a control requires an arrange.
/// </summary>
/// <param name="control">The control.</param>
/// <param name="distance">The control's distance from the layout root.</param>
public void InvalidateArrange(ILayoutable control, int distance)
private void ExecuteArrangePass()
{
_toArrange.Add(new Item(control, distance));
if (!LayoutQueued)
while (_toArrange.Count > 0 && _toMeasure.Count == 0)
{
IVisual visual = control as IVisual;
_layoutNeeded.OnNext(Unit.Default);
LayoutQueued = true;
var next = _toArrange.Dequeue();
Arrange(next);
}
}
/// <summary>
/// Executes the measure part of the layout pass.
/// </summary>
private void ExecuteMeasure()
private void Measure(ILayoutable control)
{
for (int i = 0; i < MaxTries; ++i)
{
var measure = _toMeasure;
_toMeasure = new List<Item>();
measure.Sort();
var root = control as ILayoutRoot;
if (!Root.IsMeasureValid)
{
var size = new Size(
double.IsNaN(Root.Width) ? double.PositiveInfinity : Root.Width,
double.IsNaN(Root.Height) ? double.PositiveInfinity : Root.Height);
Root.Measure(size);
}
foreach (var item in measure)
{
if (!item.Control.IsMeasureValid)
{
if (item.Control != Root)
{
var parent = item.Control.GetVisualParent<ILayoutable>();
while (parent != null && parent.PreviousMeasure == null)
{
parent = parent.GetVisualParent<ILayoutable>();
}
if (parent != null && parent.GetVisualRoot() == Root)
{
parent.Measure(parent.PreviousMeasure.Value, true);
}
}
}
}
if (_toMeasure.Count == 0)
{
break;
}
if (root != null)
{
root.Measure(Size.Infinity);
}
}
/// <summary>
/// Executes the arrange part of the layout pass.
/// </summary>
private void ExecuteArrange()
{
for (int i = 0; i < MaxTries; ++i)
else if (control.PreviousMeasure.HasValue)
{
var arrange = _toArrange;
_toArrange = new List<Item>();
arrange.Sort();
if (!Root.IsArrangeValid && Root.IsMeasureValid)
{
Root.Arrange(new Rect(Root.DesiredSize));
}
if (_toMeasure.Count > 0)
{
return;
}
foreach (var item in arrange)
{
if (!item.Control.IsArrangeValid)
{
if (item.Control != Root)
{
var control = item.Control;
while (control != null && control.PreviousArrange == null)
{
control = control.GetVisualParent<ILayoutable>();
}
if (control != null && control.GetVisualRoot() == Root)
{
control.Arrange(control.PreviousArrange.Value, true);
}
if (_toMeasure.Count > 0)
{
return;
}
}
}
}
if (_toArrange.Count == 0)
{
break;
}
control.Measure(control.PreviousMeasure.Value);
}
}
/// <summary>
/// An item to be layed-out.
/// </summary>
private class Item : IComparable<Item>
private void Arrange(ILayoutable control)
{
/// <summary>
/// Initializes a new instance of the <see cref="Item"/> class.
/// </summary>
/// <param name="control">The control.</param>
/// <param name="distance">The control's distance from the layout root.</param>
public Item(ILayoutable control, int distance)
var root = control as ILayoutRoot;
if (root != null)
{
Control = control;
Distance = distance;
root.Arrange(new Rect(root.DesiredSize));
}
else if (control.PreviousArrange.HasValue)
{
control.Arrange(control.PreviousArrange.Value);
}
}
/// <summary>
/// Gets the control.
/// </summary>
public ILayoutable Control { get; }
/// <summary>
/// Gets the control's distance from the layout root.
/// </summary>
public int Distance { get; }
/// <summary>
/// Compares the distance of two items.
/// </summary>
/// <param name="other">The other item/</param>
/// <returns>The comparison.</returns>
public int CompareTo(Item other)
private void FireLayoutNeeded()
{
if (!LayoutQueued)
{
return Distance - other.Distance;
_layoutNeeded.OnNext(Unit.Default);
LayoutQueued = true;
}
}
}

66
src/Perspex.Layout/Layoutable.cs

@ -132,12 +132,11 @@ namespace Perspex.Layout
public static readonly StyledProperty<bool> UseLayoutRoundingProperty =
PerspexProperty.Register<Layoutable, bool>(nameof(UseLayoutRounding), defaultValue: true, inherits: true);
private readonly ILogger _layoutLog;
private bool _measuring;
private Size? _previousMeasure;
private Rect? _previousArrange;
private readonly ILogger _layoutLog;
/// <summary>
/// Initializes static members of the <see cref="Layoutable"/> class.
/// </summary>
@ -320,9 +319,20 @@ namespace Perspex.Layout
if (force || !IsMeasureValid || _previousMeasure != availableSize)
{
var previousDesiredSize = DesiredSize;
var desiredSize = default(Size);
IsMeasureValid = true;
var desiredSize = MeasureCore(availableSize).Constrain(availableSize);
try
{
_measuring = true;
desiredSize = MeasureCore(availableSize).Constrain(availableSize);
}
finally
{
_measuring = false;
}
if (IsInvalidSize(desiredSize))
{
@ -333,6 +343,11 @@ namespace Perspex.Layout
_previousMeasure = availableSize;
_layoutLog.Verbose("Measure requested {DesiredSize}", DesiredSize);
if (DesiredSize != previousDesiredSize)
{
this.GetVisualParent<ILayoutable>()?.ChildDesiredSizeChanged(this);
}
}
}
@ -373,24 +388,13 @@ namespace Perspex.Layout
/// </summary>
public void InvalidateMeasure()
{
var parent = this.GetVisualParent<ILayoutable>();
if (IsMeasureValid)
{
_layoutLog.Verbose("Invalidated measure");
}
IsMeasureValid = false;
IsArrangeValid = false;
_previousMeasure = null;
_previousArrange = null;
IsMeasureValid = false;
IsArrangeValid = false;
if (parent != null && IsResizable(parent))
{
parent.InvalidateMeasure();
}
else
{
var root = GetLayoutRoot();
root?.Item1.LayoutManager?.InvalidateMeasure(this, root.Item2);
}
@ -401,16 +405,24 @@ namespace Perspex.Layout
/// </summary>
public void InvalidateArrange()
{
var root = GetLayoutRoot();
if (IsArrangeValid)
{
_layoutLog.Verbose("Arrange measure");
IsArrangeValid = false;
var root = GetLayoutRoot();
root?.Item1.LayoutManager?.InvalidateArrange(this, root.Item2);
}
}
IsArrangeValid = false;
_previousArrange = null;
root?.Item1.LayoutManager?.InvalidateArrange(this, root.Item2);
/// <inheritdoc/>
void ILayoutable.ChildDesiredSizeChanged(ILayoutable control)
{
if (!_measuring)
{
InvalidateMeasure();
}
}
/// <summary>
@ -624,16 +636,6 @@ namespace Perspex.Layout
control?.InvalidateArrange();
}
/// <summary>
/// Tests whether a control's size can be changed by a layout pass.
/// </summary>
/// <param name="control">The control.</param>
/// <returns>True if the control's size can change; otherwise false.</returns>
private static bool IsResizable(ILayoutable control)
{
return double.IsNaN(control.Width) || double.IsNaN(control.Height);
}
/// <summary>
/// Tests whether any of a <see cref="Rect"/>'s properties incude nagative values,
/// a NaN or Infinity.

38
tests/Perspex.Layout.UnitTests/LayoutManagerTests.cs

@ -0,0 +1,38 @@
// Copyright (c) The Perspex Project. All rights reserved.
// Licensed under the MIT license. See licence.md file in the project root for full license information.
using Perspex.Controls;
using Xunit;
namespace Perspex.Layout.UnitTests
{
public class LayoutManagerTests
{
[Fact]
public void Invalidating_Child_Should_Remeasure_Parent()
{
Border border;
StackPanel panel;
var root = new TestLayoutRoot
{
Child = panel = new StackPanel
{
Children = new Controls.Controls
{
(border = new Border())
}
}
};
root.LayoutManager.ExecuteLayoutPass();
Assert.Equal(new Size(0, 0), root.DesiredSize);
border.Width = 100;
border.Height = 100;
root.LayoutManager.ExecuteLayoutPass();
Assert.Equal(new Size(100, 100), panel.DesiredSize);
}
}
}

21
tests/Perspex.Layout.UnitTests/MeasureTests.cs

@ -8,6 +8,27 @@ namespace Perspex.Layout.UnitTests
{
public class MeasureTests
{
[Fact]
public void Invalidating_Child_Should_Not_Invalidate_Parent()
{
var panel = new StackPanel();
var child = new Border();
panel.Children.Add(child);
panel.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
Assert.Equal(new Size(0, 0), panel.DesiredSize);
child.Width = 100;
child.Height = 100;
Assert.True(panel.IsMeasureValid);
Assert.False(child.IsMeasureValid);
panel.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
Assert.Equal(new Size(0, 0), panel.DesiredSize);
}
[Fact]
public void Negative_Margin_Larger_Than_Constraint_Should_Request_Width_0()
{

2
tests/Perspex.Layout.UnitTests/Perspex.Layout.UnitTests.csproj

@ -86,8 +86,10 @@
</Choose>
<ItemGroup>
<Compile Include="FullLayoutTests.cs" />
<Compile Include="LayoutManagerTests.cs" />
<Compile Include="MeasureTests.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="TestLayoutRoot.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Perspex.Animation\Perspex.Animation.csproj">

27
tests/Perspex.Layout.UnitTests/TestLayoutRoot.cs

@ -0,0 +1,27 @@
// Copyright (c) The Perspex Project. All rights reserved.
// Licensed under the MIT license. See licence.md file in the project root for full license information.
using Perspex.Controls;
namespace Perspex.Layout.UnitTests
{
internal class TestLayoutRoot : Decorator, ILayoutRoot
{
public TestLayoutRoot()
{
ClientSize = new Size(500, 500);
LayoutManager = new LayoutManager { Root = this };
}
public Size ClientSize
{
get;
set;
}
public ILayoutManager LayoutManager
{
get;
}
}
}

4
tests/Perspex.SceneGraph.UnitTests/VisualTree/BoundsTrackerTests.cs

@ -40,13 +40,13 @@ namespace Perspex.SceneGraph.UnitTests.VisualTree
var results = new List<TransformedBounds>();
track.Subscribe(results.Add);
Assert.Equal(new Rect(42, 42, 15, 15), results.Last().Bounds);
Assert.Equal(new Rect(42, 42, 15, 15), results[0].Bounds);
tree.Padding = new Thickness(15);
tree.Measure(Size.Infinity);
tree.Arrange(new Rect(0, 0, 100, 100), true);
Assert.Equal(new Rect(42, 42, 15, 15), results.Last().Bounds);
Assert.Equal(new Rect(47, 47, 15, 15), results[1].Bounds);
}
}
}

Loading…
Cancel
Save