Browse Source

Fix some layout rounding issues.

Fixes for #8092:

- Always round sizes up, not to the nearest pixel, thereby ensuring that `DesiredSize`s don't get rounded down where possible.
- Apply rounding to `Padding` and `BorderThickness` in measure pass as well as arrange pass, to ensure that `DesiredSize` takes this rounding into account.
pull/8146/head
Steven Kirk 4 years ago
parent
commit
b2556d62f5
  1. 71
      src/Avalonia.Base/Layout/LayoutHelper.cs
  2. 24
      src/Avalonia.Base/Layout/Layoutable.cs
  3. 14
      src/Avalonia.Base/Point.cs
  4. 2
      src/Avalonia.Controls.DataGrid/DataGridColumn.cs
  5. 4
      src/Avalonia.Controls/Presenters/ContentPresenter.cs
  6. 31
      tests/Avalonia.Base.UnitTests/Layout/LayoutableTests.cs
  7. 140
      tests/Avalonia.Base.UnitTests/Layout/LayoutableTests_LayoutRounding.cs
  8. 1
      tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/SceneBuilderTests.cs
  9. 65
      tests/Avalonia.Controls.UnitTests/BorderTests.cs
  10. 41
      tests/Avalonia.Controls.UnitTests/DecoratorTests.cs
  11. 66
      tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs
  12. 4
      tests/Avalonia.Controls.UnitTests/Primitives/TrackTests.cs
  13. 7
      tests/Avalonia.UnitTests/TestRoot.cs
  14. BIN
      tests/TestFiles/Direct2D1/Controls/TextBlock/RestrictedHeight_VerticalAlign.expected.png
  15. BIN
      tests/TestFiles/Skia/Controls/TextBlock/RestrictedHeight_VerticalAlign.expected.png

71
src/Avalonia.Base/Layout/LayoutHelper.cs

@ -36,11 +36,28 @@ namespace Avalonia.Layout
public static Size MeasureChild(ILayoutable? control, Size availableSize, Thickness padding,
Thickness borderThickness)
{
return MeasureChild(control, availableSize, padding + borderThickness);
if (IsParentLayoutRounded(control, out double scale))
{
padding = RoundLayoutThickness(padding, scale, scale);
borderThickness = RoundLayoutThickness(borderThickness, scale, scale);
}
if (control != null)
{
control.Measure(availableSize.Deflate(padding + borderThickness));
return control.DesiredSize.Inflate(padding + borderThickness);
}
return new Size().Inflate(padding + borderThickness);
}
public static Size MeasureChild(ILayoutable? control, Size availableSize, Thickness padding)
{
if (IsParentLayoutRounded(control, out double scale))
{
padding = RoundLayoutThickness(padding, scale, scale);
}
if (control != null)
{
control.Measure(availableSize.Deflate(padding));
@ -137,7 +154,7 @@ namespace Avalonia.Layout
/// <summary>
/// Rounds a size to integer values for layout purposes, compensating for high DPI screen
/// coordinates.
/// coordinates by rounding the size up to the nearest pixel.
/// </summary>
/// <param name="size">Input size.</param>
/// <param name="dpiScaleX">DPI along x-dimension.</param>
@ -149,9 +166,9 @@ namespace Avalonia.Layout
/// associated with the UseLayoutRounding property and should not be used as a general rounding
/// utility.
/// </remarks>
public static Size RoundLayoutSize(Size size, double dpiScaleX, double dpiScaleY)
public static Size RoundLayoutSizeUp(Size size, double dpiScaleX, double dpiScaleY)
{
return new Size(RoundLayoutValue(size.Width, dpiScaleX), RoundLayoutValue(size.Height, dpiScaleY));
return new Size(RoundLayoutValueUp(size.Width, dpiScaleX), RoundLayoutValueUp(size.Height, dpiScaleY));
}
/// <summary>
@ -178,10 +195,9 @@ namespace Avalonia.Layout
);
}
/// <summary>
/// Calculates the value to be used for layout rounding at high DPI.
/// Calculates the value to be used for layout rounding at high DPI by rounding the value
/// up or down to the nearest pixel.
/// </summary>
/// <param name="value">Input value to be rounded.</param>
/// <param name="dpiScale">Ratio of screen's DPI to layout DPI</param>
@ -217,7 +233,46 @@ namespace Avalonia.Layout
return newValue;
}
/// <summary>
/// Calculates the value to be used for layout rounding at high DPI by rounding the value up
/// to the nearest pixel.
/// </summary>
/// <param name="value">Input value to be rounded.</param>
/// <param name="dpiScale">Ratio of screen's DPI to layout DPI</param>
/// <returns>Adjusted value that will produce layout rounding on screen at high dpi.</returns>
/// <remarks>
/// This is a layout helper method. It takes DPI into account and also does not return
/// the rounded value if it is unacceptable for layout, e.g. Infinity or NaN. It's a helper
/// associated with the UseLayoutRounding property and should not be used as a general rounding
/// utility.
/// </remarks>
public static double RoundLayoutValueUp(double value, double dpiScale)
{
double newValue;
// If DPI == 1, don't use DPI-aware rounding.
if (!MathUtilities.IsOne(dpiScale))
{
newValue = Math.Ceiling(value * dpiScale) / dpiScale;
// If rounding produces a value unacceptable to layout (NaN, Infinity or MaxValue),
// use the original value.
if (double.IsNaN(newValue) ||
double.IsInfinity(newValue) ||
MathUtilities.AreClose(newValue, double.MaxValue))
{
newValue = value;
}
}
else
{
newValue = Math.Ceiling(value);
}
return newValue;
}
/// <summary>
/// Calculates the min and max height for a control. Ported from WPF.
/// </summary>

24
src/Avalonia.Base/Layout/Layoutable.cs

@ -549,6 +549,14 @@ namespace Avalonia.Layout
if (IsVisible)
{
var margin = Margin;
var useLayoutRounding = UseLayoutRounding;
var scale = 1.0;
if (useLayoutRounding)
{
scale = LayoutHelper.GetLayoutScale(this);
margin = LayoutHelper.RoundLayoutThickness(margin, scale, scale);
}
ApplyStyling();
ApplyTemplate();
@ -585,16 +593,14 @@ namespace Avalonia.Layout
height = Math.Min(height, MaxHeight);
height = Math.Max(height, MinHeight);
width = Math.Min(width, availableSize.Width);
height = Math.Min(height, availableSize.Height);
if (UseLayoutRounding)
if (useLayoutRounding)
{
var scale = LayoutHelper.GetLayoutScale(this);
width = LayoutHelper.RoundLayoutValue(width, scale);
height = LayoutHelper.RoundLayoutValue(height, scale);
(width, height) = LayoutHelper.RoundLayoutSizeUp(new Size(width, height), scale, scale);
}
width = Math.Min(width, availableSize.Width);
height = Math.Min(height, availableSize.Height);
return NonNegative(new Size(width, height).Inflate(margin));
}
else
@ -679,8 +685,8 @@ namespace Avalonia.Layout
if (useLayoutRounding)
{
size = LayoutHelper.RoundLayoutSize(size, scale, scale);
availableSizeMinusMargins = LayoutHelper.RoundLayoutSize(availableSizeMinusMargins, scale, scale);
size = LayoutHelper.RoundLayoutSizeUp(size, scale, scale);
availableSizeMinusMargins = LayoutHelper.RoundLayoutSizeUp(availableSizeMinusMargins, scale, scale);
}
size = ArrangeOverride(size).Constrain(size);

14
src/Avalonia.Base/Point.cs

@ -192,7 +192,7 @@ namespace Avalonia
}
/// <summary>
/// Returns a boolean indicating whether the point is equal to the other given point.
/// Returns a boolean indicating whether the point is equal to the other given point (bitwise).
/// </summary>
/// <param name="other">The other point to test equality against.</param>
/// <returns>True if this point is equal to other; False otherwise.</returns>
@ -204,6 +204,18 @@ namespace Avalonia
// ReSharper enable CompareOfFloatsByEqualityOperator
}
/// <summary>
/// Returns a boolean indicating whether the point is equal to the other given point
/// (numerically).
/// </summary>
/// <param name="other">The other point to test equality against.</param>
/// <returns>True if this point is equal to other; False otherwise.</returns>
public bool NearlyEquals(Point other)
{
return MathUtilities.AreClose(_x, other._x) &&
MathUtilities.AreClose(_y, other._y);
}
/// <summary>
/// Checks for equality between a point and an object.
/// </summary>

2
src/Avalonia.Controls.DataGrid/DataGridColumn.cs

@ -855,7 +855,7 @@ namespace Avalonia.Controls
if (OwningGrid != null && OwningGrid.UseLayoutRounding)
{
var scale = LayoutHelper.GetLayoutScale(HeaderCell);
var roundSize = LayoutHelper.RoundLayoutSize(new Size(leftEdge + ActualWidth, 1), scale, scale);
var roundSize = LayoutHelper.RoundLayoutSizeUp(new Size(leftEdge + ActualWidth, 1), scale, scale);
LayoutRoundedWidth = roundSize.Width - leftEdge;
}
else

4
src/Avalonia.Controls/Presenters/ContentPresenter.cs

@ -635,8 +635,8 @@ namespace Avalonia.Controls.Presenters
if (useLayoutRounding)
{
sizeForChild = LayoutHelper.RoundLayoutSize(sizeForChild, scale, scale);
availableSize = LayoutHelper.RoundLayoutSize(availableSize, scale, scale);
sizeForChild = LayoutHelper.RoundLayoutSizeUp(sizeForChild, scale, scale);
availableSize = LayoutHelper.RoundLayoutSizeUp(availableSize, scale, scale);
}
switch (horizontalContentAlignment)

31
tests/Avalonia.Base.UnitTests/Layout/LayoutableTests.cs

@ -173,37 +173,6 @@ namespace Avalonia.Base.UnitTests.Layout
target.Verify(x => x.InvalidateMeasure(root), Times.Once());
}
[Theory]
[InlineData(16, 6, 5.333333333333333)]
[InlineData(18, 10, 4)]
public void UseLayoutRounding_Arranges_Center_Alignment_Correctly_With_Fractional_Scaling(
double containerWidth,
double childWidth,
double expectedX)
{
Border target;
var root = new TestRoot
{
LayoutScaling = 1.5,
UseLayoutRounding = true,
Child = new Decorator
{
Width = containerWidth,
Height = 100,
Child = target = new Border
{
Width = childWidth,
HorizontalAlignment = HorizontalAlignment.Center,
}
}
};
root.Measure(new Size(100, 100));
root.Arrange(new Rect(target.DesiredSize));
Assert.Equal(new Rect(expectedX, 0, childWidth, 100), target.Bounds);
}
[Fact]
public void LayoutUpdated_Is_Called_At_End_Of_Layout_Pass()
{

140
tests/Avalonia.Base.UnitTests/Layout/LayoutableTests_LayoutRounding.cs

@ -0,0 +1,140 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.UnitTests;
using Xunit;
using Xunit.Sdk;
namespace Avalonia.Base.UnitTests.Layout
{
public class LayoutableTests_LayoutRounding
{
[Theory]
[InlineData(100, 100)]
[InlineData(101, 101.33333333333333)]
[InlineData(103, 103.33333333333333)]
public void Measure_Adjusts_DesiredSize_Upwards_When_Constraint_Allows(double desiredSize, double expectedSize)
{
var target = new TestLayoutable(new Size(desiredSize, desiredSize));
var root = CreateRoot(1.5, target);
root.LayoutManager.ExecuteInitialLayoutPass();
Assert.Equal(new Size(expectedSize, expectedSize), target.DesiredSize);
}
[Fact]
public void Measure_Constrains_Adjusted_DesiredSize_To_Constraint()
{
var target = new TestLayoutable(new Size(101, 101));
var root = CreateRoot(1.5, target, constraint: new Size(101, 101));
root.LayoutManager.ExecuteInitialLayoutPass();
// Desired width/height with layout rounding is 101.3333 but constraint is 101,101 so
// layout rounding should be ignored.
Assert.Equal(new Size(101, 101), target.DesiredSize);
}
[Fact]
public void Measure_Adjusts_DesiredSize_Upwards_When_Margin_Present()
{
var target = new TestLayoutable(new Size(101, 101), margin: 1);
var root = CreateRoot(1.5, target);
root.LayoutManager.ExecuteInitialLayoutPass();
// - 1 pixel margin is rounded up to 1.3333; for both sides it is 2.6666
// - Size of 101 gets rounded up to 101.3333
// - Final size = 101.3333 + 2.6666 = 104
AssertEqual(new Size(104, 104), target.DesiredSize);
}
[Fact]
public void Arrange_Adjusts_Bounds_Upwards_With_Margin()
{
var target = new TestLayoutable(new Size(101, 101), margin: 1);
var root = CreateRoot(1.5, target);
root.LayoutManager.ExecuteInitialLayoutPass();
// - 1 pixel margin is rounded up to 1.3333
// - Size of 101 gets rounded up to 101.3333
AssertEqual(new Point(1.3333333333333333, 1.3333333333333333), target.Bounds.Position);
AssertEqual(new Size(101.33333333333333, 101.33333333333333), target.Bounds.Size);
}
[Theory]
[InlineData(16, 6, 5.333333333333333)]
[InlineData(18, 10, 4)]
public void Arranges_Center_Alignment_Correctly_With_Fractional_Scaling(
double containerWidth,
double childWidth,
double expectedX)
{
Border target;
var root = new TestRoot
{
LayoutScaling = 1.5,
UseLayoutRounding = true,
Child = new Decorator
{
Width = containerWidth,
Height = 100,
Child = target = new Border
{
Width = childWidth,
HorizontalAlignment = HorizontalAlignment.Center,
}
}
};
root.Measure(new Size(100, 100));
root.Arrange(new Rect(target.DesiredSize));
Assert.Equal(new Rect(expectedX, 0, childWidth, 100), target.Bounds);
}
private static TestRoot CreateRoot(
double scaling,
Control child,
Size? constraint = null)
{
return new TestRoot
{
LayoutScaling = scaling,
UseLayoutRounding = true,
Child = child,
ClientSize = constraint ?? new Size(1000, 1000),
};
}
private static void AssertEqual(Point expected, Point actual)
{
if (!expected.NearlyEquals(actual))
{
throw new EqualException(expected, actual);
}
}
private static void AssertEqual(Size expected, Size actual)
{
if (!expected.NearlyEquals(actual))
{
throw new EqualException(expected, actual);
}
}
private class TestLayoutable : Control
{
private Size _desiredSize;
public TestLayoutable(Size desiredSize, double margin = 0)
{
_desiredSize = desiredSize;
Margin = new Thickness(margin);
}
protected override Size MeasureOverride(Size availableSize) => _desiredSize;
}
}
}

1
tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/SceneBuilderTests.cs

@ -805,6 +805,7 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph
Canvas canvas;
var tree = new TestRoot
{
ClientSize = new Size(100, 100),
Child = decorator = new Decorator
{
Margin = new Thickness(0, 10, 0, 0),

65
tests/Avalonia.Controls.UnitTests/BorderTests.cs

@ -1,6 +1,8 @@
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Rendering;
using Avalonia.UnitTests;
using Avalonia.VisualTree;
using Moq;
using Xunit;
@ -60,5 +62,68 @@ namespace Avalonia.Controls.UnitTests
renderer.Verify(x => x.AddDirty(target), Times.Once);
}
public class UseLayoutRounding
{
[Fact]
public void Measure_Rounds_Padding()
{
var target = new Border
{
Padding = new Thickness(1),
Child = new Canvas
{
Width = 101,
Height = 101,
}
};
var root = CreatedRoot(1.5, target);
root.LayoutManager.ExecuteInitialLayoutPass();
// - 1 pixel padding is rounded up to 1.3333; for both sides it is 2.6666
// - Size of 101 gets rounded up to 101.3333
// - Desired size = 101.3333 + 2.6666 = 104
Assert.Equal(new Size(104, 104), target.DesiredSize);
}
[Fact]
public void Measure_Rounds_BorderThickness()
{
var target = new Border
{
BorderThickness = new Thickness(1),
Child = new Canvas
{
Width = 101,
Height = 101,
}
};
var root = CreatedRoot(1.5, target);
root.LayoutManager.ExecuteInitialLayoutPass();
// - 1 pixel border thickness is rounded up to 1.3333; for both sides it is 2.6666
// - Size of 101 gets rounded up to 101.3333
// - Desired size = 101.3333 + 2.6666 = 104
Assert.Equal(new Size(104, 104), target.DesiredSize);
}
private static TestRoot CreatedRoot(
double scaling,
Control child,
Size? constraint = null)
{
return new TestRoot
{
LayoutScaling = scaling,
UseLayoutRounding = true,
Child = child,
ClientSize = constraint ?? new Size(1000, 1000),
};
}
}
}
}

41
tests/Avalonia.Controls.UnitTests/DecoratorTests.cs

@ -1,6 +1,7 @@
using System.Collections.Specialized;
using System.Linq;
using Avalonia.LogicalTree;
using Avalonia.UnitTests;
using Xunit;
namespace Avalonia.Controls.UnitTests
@ -116,5 +117,45 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(new Size(16, 16), target.DesiredSize);
}
public class UseLayoutRounding
{
[Fact]
public void Measure_Rounds_Padding()
{
var target = new Decorator
{
Padding = new Thickness(1),
Child = new Canvas
{
Width = 101,
Height = 101,
}
};
var root = CreatedRoot(1.5, target);
root.LayoutManager.ExecuteInitialLayoutPass();
// - 1 pixel padding is rounded up to 1.3333; for both sides it is 2.6666
// - Size of 101 gets rounded up to 101.3333
// - Desired size = 101.3333 + 2.6666 = 104
Assert.Equal(new Size(104, 104), target.DesiredSize);
}
private static TestRoot CreatedRoot(
double scaling,
Control child,
Size? constraint = null)
{
return new TestRoot
{
LayoutScaling = scaling,
UseLayoutRounding = true,
Child = child,
ClientSize = constraint ?? new Size(1000, 1000),
};
}
}
}
}

66
tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs

@ -1,5 +1,6 @@
using Avalonia.Controls.Presenters;
using Avalonia.Layout;
using Avalonia.UnitTests;
using Xunit;
namespace Avalonia.Controls.UnitTests.Presenters
@ -232,5 +233,68 @@ namespace Avalonia.Controls.UnitTests.Presenters
Assert.Equal(new Rect(32, 32, 0, 0), content.Bounds);
}
public class UseLayoutRounding
{
[Fact]
public void Measure_Rounds_Padding()
{
var target = new ContentPresenter
{
Padding = new Thickness(1),
Content = new Canvas
{
Width = 101,
Height = 101,
}
};
var root = CreatedRoot(1.5, target);
root.LayoutManager.ExecuteInitialLayoutPass();
// - 1 pixel padding is rounded up to 1.3333; for both sides it is 2.6666
// - Size of 101 gets rounded up to 101.3333
// - Desired size = 101.3333 + 2.6666 = 104
Assert.Equal(new Size(104, 104), target.DesiredSize);
}
[Fact]
public void Measure_Rounds_BorderThickness()
{
var target = new ContentPresenter
{
BorderThickness = new Thickness(1),
Content = new Canvas
{
Width = 101,
Height = 101,
}
};
var root = CreatedRoot(1.5, target);
root.LayoutManager.ExecuteInitialLayoutPass();
// - 1 pixel border thickness is rounded up to 1.3333; for both sides it is 2.6666
// - Size of 101 gets rounded up to 101.3333
// - Desired size = 101.3333 + 2.6666 = 104
Assert.Equal(new Size(104, 104), target.DesiredSize);
}
private static TestRoot CreatedRoot(
double scaling,
Control child,
Size? constraint = null)
{
return new TestRoot
{
LayoutScaling = scaling,
UseLayoutRounding = true,
Child = child,
ClientSize = constraint ?? new Size(1000, 1000),
};
}
}
}
}
}

4
tests/Avalonia.Controls.UnitTests/Primitives/TrackTests.cs

@ -67,7 +67,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
target.Measure(new Size(100, 100));
target.Arrange(new Rect(0, 0, 100, 100));
Assert.Equal(new Rect(33, 0, 33, 12), thumb.Bounds);
Assert.Equal(new Rect(33, 0, 34, 12), thumb.Bounds);
}
[Fact]
@ -92,7 +92,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
target.Measure(new Size(100, 100));
target.Arrange(new Rect(0, 0, 100, 100));
Assert.Equal(new Rect(0, 33, 12, 33), thumb.Bounds);
Assert.Equal(new Rect(0, 33, 12, 34), thumb.Bounds);
}
[Fact]

7
tests/Avalonia.UnitTests/TestRoot.cs

@ -41,7 +41,7 @@ namespace Avalonia.UnitTests
Child = child;
}
public Size ClientSize { get; set; } = new Size(100, 100);
public Size ClientSize { get; set; } = new Size(1000, 1000);
public Size MaxClientSize { get; set; } = Size.Infinity;
@ -110,5 +110,10 @@ namespace Avalonia.UnitTests
}
Visit(this, true);
}
protected override Size MeasureOverride(Size availableSize)
{
return base.MeasureOverride(ClientSize);
}
}
}

BIN
tests/TestFiles/Direct2D1/Controls/TextBlock/RestrictedHeight_VerticalAlign.expected.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 752 B

After

Width:  |  Height:  |  Size: 767 B

BIN
tests/TestFiles/Skia/Controls/TextBlock/RestrictedHeight_VerticalAlign.expected.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 557 B

After

Width:  |  Height:  |  Size: 532 B

Loading…
Cancel
Save