From fccf7cc07f8e8a3765c7a6cb3bd9f19dd61b6605 Mon Sep 17 00:00:00 2001 From: "E.Z. Hart" Date: Wed, 24 Mar 2021 03:06:54 -0600 Subject: [PATCH] GridLayout with absolute and auto (#513) * Grid stuff, in progress * Clean up after rebase; fix arrange call in layout manager * Adding some notes * Update ArrangeChildren method calls * Add missing TypeConverters * Update test values in light of new Arrange call * Formatting fix Co-authored-by: Rui Marinho --- .../samples/Controls.Sample/Pages/MainPage.cs | 36 +- src/Controls/src/Core/ColumnDefinition.cs | 3 +- src/Controls/src/Core/Grid.cs | 46 +- src/Controls/src/Core/Layout/GridLayout.cs | 130 ++++ src/Controls/src/Core/Layout/Layout.cs | 4 +- src/Controls/src/Core/Layout/StackLayout.cs | 3 +- .../src/Core/Layout/VerticalStackLayout.cs | 1 - src/Controls/src/Core/RowDefinition.cs | 3 +- src/Core/src/Core/IGridColumnDefinition.cs | 7 + src/Core/src/Core/IGridLayout.cs | 19 + src/Core/src/Core/IGridRowDefinition.cs | 7 + src/Core/src/Core/IStackLayout.cs | 3 + src/Core/src/Layouts/GridLayoutManager.cs | 464 ++++++++++++ .../Layouts/HorizontalStackLayoutManager.cs | 2 +- src/Core/src/Layouts/ILayoutManager.cs | 2 +- src/Core/src/Layouts/LayoutManager.cs | 2 +- .../src/Layouts/VerticalStackLayoutManager.cs | 3 +- .../src/Primitives}/GridLength.cs | 5 +- .../src/Primitives}/GridUnitType.cs | 2 +- .../Layouts/GridLayoutManagerTests.cs | 688 ++++++++++++++++++ .../HorizontalStackLayoutManagerTests.cs | 16 +- .../UnitTests/Layouts/LayoutTestHelpers.cs | 34 + .../Layouts/StackLayoutManagerTests.cs | 4 +- .../VerticalStackLayoutManagerTests.cs | 12 +- 24 files changed, 1461 insertions(+), 35 deletions(-) create mode 100644 src/Controls/src/Core/Layout/GridLayout.cs create mode 100644 src/Core/src/Core/IGridColumnDefinition.cs create mode 100644 src/Core/src/Core/IGridLayout.cs create mode 100644 src/Core/src/Core/IGridRowDefinition.cs create mode 100644 src/Core/src/Layouts/GridLayoutManager.cs rename src/{Controls/src/Core => Core/src/Primitives}/GridLength.cs (92%) rename src/{Controls/src/Core => Core/src/Primitives}/GridUnitType.cs (63%) create mode 100644 src/Core/tests/UnitTests/Layouts/GridLayoutManagerTests.cs create mode 100644 src/Core/tests/UnitTests/Layouts/LayoutTestHelpers.cs diff --git a/src/Controls/samples/Controls.Sample/Pages/MainPage.cs b/src/Controls/samples/Controls.Sample/Pages/MainPage.cs index 321185658..1d11beba2 100644 --- a/src/Controls/samples/Controls.Sample/Pages/MainPage.cs +++ b/src/Controls/samples/Controls.Sample/Pages/MainPage.cs @@ -32,6 +32,9 @@ namespace Maui.Controls.Sample.Pages var verticalStack = new VerticalStackLayout() { Spacing = 5, BackgroundColor = Color.AntiqueWhite }; var horizontalStack = new HorizontalStackLayout() { Spacing = 2, BackgroundColor = Color.CornflowerBlue }; + + verticalStack.Add(CreateSampleGrid()); + verticalStack.Add(new Label { Text = " ", Padding = new Thickness(10) }); var label = new Label { Text = "End-aligned text", BackgroundColor = Color.Fuchsia, HorizontalTextAlignment = TextAlignment.End }; label.Margin = new Thickness(15, 10, 20, 15); @@ -47,7 +50,6 @@ namespace Maui.Controls.Sample.Pages verticalStack.Add(new Label { Text = loremIpsum, MaxLines = 2, LineBreakMode = LineBreakMode.TailTruncation }); verticalStack.Add(new Label { Text = "This should have five times the line height!", LineHeight = 5 }); - var paddingButton = new Button { Padding = new Thickness(40), @@ -157,6 +159,7 @@ namespace Maui.Controls.Sample.Pages Content = verticalStack }; } + void SetupCompatibilityLayout() { @@ -202,5 +205,36 @@ namespace Maui.Controls.Sample.Pages } public IView View { get => (IView)Content; set => Content = (View)value; } + + IView CreateSampleGrid() + { + var layout = new Microsoft.Maui.Controls.Layout2.GridLayout() { ColumnSpacing = 5, RowSpacing = 8 }; + + layout.AddRowDefinition(new RowDefinition() { Height = new GridLength(40) }); + layout.AddRowDefinition(new RowDefinition() { Height = GridLength.Auto }); + + layout.AddColumnDefinition(new ColumnDefinition() { Width = new GridLength(100) }); + layout.AddColumnDefinition(new ColumnDefinition() { Width = new GridLength(100) }); + + var topLeft = new Label { Text = "Top Left", BackgroundColor = Color.LightBlue }; + layout.Add(topLeft); + + var bottomLeft = new Label { Text = "Bottom Left", BackgroundColor = Color.Lavender }; + layout.Add(bottomLeft); + layout.SetRow(bottomLeft, 1); + + var topRight = new Label { Text = "Top Right", BackgroundColor = Color.Orange }; + layout.Add(topRight); + layout.SetColumn(topRight, 1); + + var bottomRight = new Label { Text = "Bottom Right", BackgroundColor = Color.MediumPurple }; + layout.Add(bottomRight); + layout.SetRow(bottomRight, 1); + layout.SetColumn(bottomRight, 1); + + layout.BackgroundColor = Color.Chartreuse; + + return layout; + } } } \ No newline at end of file diff --git a/src/Controls/src/Core/ColumnDefinition.cs b/src/Controls/src/Core/ColumnDefinition.cs index 20680af10..3d6eb3ba9 100644 --- a/src/Controls/src/Core/ColumnDefinition.cs +++ b/src/Controls/src/Core/ColumnDefinition.cs @@ -2,7 +2,7 @@ using System; namespace Microsoft.Maui.Controls { - public sealed class ColumnDefinition : BindableObject, IDefinition + public sealed class ColumnDefinition : BindableObject, IDefinition, IGridColumnDefinition { public static readonly BindableProperty WidthProperty = BindableProperty.Create("Width", typeof(GridLength), typeof(ColumnDefinition), new GridLength(1, GridUnitType.Star), propertyChanged: (bindable, oldValue, newValue) => ((ColumnDefinition)bindable).OnSizeChanged()); @@ -12,6 +12,7 @@ namespace Microsoft.Maui.Controls MinimumWidth = -1; } + [TypeConverter(typeof(GridLengthTypeConverter))] public GridLength Width { get { return (GridLength)GetValue(WidthProperty); } diff --git a/src/Controls/src/Core/Grid.cs b/src/Controls/src/Core/Grid.cs index 07a351442..e799dac84 100644 --- a/src/Controls/src/Core/Grid.cs +++ b/src/Controls/src/Core/Grid.cs @@ -5,9 +5,10 @@ using System.ComponentModel; using System.Linq; using Microsoft.Maui.Controls.Internals; + namespace Microsoft.Maui.Controls { - public partial class Grid : Layout, IGridController, IElementConfiguration + public partial class Grid : Layout, IGridController, IElementConfiguration, IGridLayout { public static readonly BindableProperty RowProperty = BindableProperty.CreateAttached("Row", typeof(int), typeof(Grid), default(int), validateValue: (bindable, value) => (int)value >= 0); @@ -381,5 +382,48 @@ namespace Microsoft.Maui.Controls Parent.ColumnDefinitions.Count ); } + + int IGridLayout.GetRow(IView view) + { + if (view is BindableObject bo) + { + return GetRow(bo); + } + + throw new InvalidEnumArgumentException($"{nameof(view)} must be a BindableObject"); + } + + int IGridLayout.GetColumn(IView view) + { + if (view is BindableObject bo) + { + return GetColumn(bo); + } + + throw new InvalidEnumArgumentException($"{nameof(view)} must be a BindableObject"); + } + + int IGridLayout.GetRowSpan(IView view) + { + if (view is BindableObject bo) + { + return GetRowSpan(bo); + } + + throw new InvalidEnumArgumentException($"{nameof(view)} must be a BindableObject"); + } + + int IGridLayout.GetColumnSpan(IView view) + { + if (view is BindableObject bo) + { + return GetColumnSpan(bo); + } + + throw new InvalidEnumArgumentException($"{nameof(view)} must be a BindableObject"); + } + + IReadOnlyList IGridLayout.RowDefinitions => RowDefinitions.ToList(); + IReadOnlyList IGridLayout.ColumnDefinitions => ColumnDefinitions.ToList(); } } \ No newline at end of file diff --git a/src/Controls/src/Core/Layout/GridLayout.cs b/src/Controls/src/Core/Layout/GridLayout.cs new file mode 100644 index 000000000..a5c15d71a --- /dev/null +++ b/src/Controls/src/Core/Layout/GridLayout.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using Microsoft.Maui.Layouts; + +// This is a temporary namespace until we rename everything and move the legacy layouts +namespace Microsoft.Maui.Controls.Layout2 +{ + public class GridLayout : Layout, IGridLayout + { + List _rowDefinitions = new List(); + List _columnDefinitions = new List(); + + public IReadOnlyList RowDefinitions => _rowDefinitions; + public IReadOnlyList ColumnDefinitions => _columnDefinitions; + + public double RowSpacing { get; set; } + public double ColumnSpacing { get; set; } + + Dictionary _viewInfo = new Dictionary(); + + // TODO ezhart This needs to override Remove and clean up any row/column/span info for the removed child + + public int GetColumn(IView view) + { + if (_viewInfo.TryGetValue(view, out GridInfo gridInfo)) + { + return gridInfo.Col; + } + + return 0; + } + + public int GetColumnSpan(IView view) + { + if (_viewInfo.TryGetValue(view, out GridInfo gridInfo)) + { + return gridInfo.ColSpan; + } + + return 1; + } + + public int GetRow(IView view) + { + if (_viewInfo.TryGetValue(view, out GridInfo gridInfo)) + { + return gridInfo.Row; + } + + return 0; + } + + public int GetRowSpan(IView view) + { + if (_viewInfo.TryGetValue(view, out GridInfo gridInfo)) + { + return gridInfo.RowSpan; + } + + return 1; + } + + protected override ILayoutManager CreateLayoutManager() => new GridLayoutManager(this); + + public void AddRowDefinition(IGridRowDefinition gridRowDefinition) + { + _rowDefinitions.Add(gridRowDefinition); + } + + public void AddColumnDefinition(IGridColumnDefinition gridColumnDefinition) + { + _columnDefinitions.Add(gridColumnDefinition); + } + + public void SetRow(IView view, int row) + { + if (_viewInfo.TryGetValue(view, out GridInfo gridInfo)) + { + gridInfo.Row = row; + } + else + { + _viewInfo[view] = new GridInfo { Row = row }; + } + } + + public void SetRowSpan(IView view, int span) + { + if (_viewInfo.TryGetValue(view, out GridInfo gridInfo)) + { + gridInfo.RowSpan = span; + } + else + { + _viewInfo[view] = new GridInfo { RowSpan = span }; + } + } + + public void SetColumn(IView view, int col) + { + if (_viewInfo.TryGetValue(view, out GridInfo gridInfo)) + { + gridInfo.Col = col; + } + else + { + _viewInfo[view] = new GridInfo { Col = col }; + } + } + + public void SetColumnSpan(IView view, int span) + { + if (_viewInfo.TryGetValue(view, out GridInfo gridInfo)) + { + gridInfo.ColSpan = span; + } + else + { + _viewInfo[view] = new GridInfo { ColSpan = span }; + } + } + + class GridInfo + { + public int Row { get; set; } + public int Col { get; set; } + public int RowSpan { get; set; } = 1; + public int ColSpan { get; set; } = 1; + } + } +} diff --git a/src/Controls/src/Core/Layout/Layout.cs b/src/Controls/src/Core/Layout/Layout.cs index 79f0bf932..b0ca5f07c 100644 --- a/src/Controls/src/Core/Layout/Layout.cs +++ b/src/Controls/src/Core/Layout/Layout.cs @@ -2,8 +2,6 @@ using System.Collections; using System.Collections.Generic; using Microsoft.Maui.Layouts; - - // This is a temporary namespace until we rename everything and move the legacy layouts namespace Microsoft.Maui.Controls.Layout2 { @@ -61,7 +59,7 @@ namespace Microsoft.Maui.Controls.Layout2 Arrange(bounds); - LayoutManager.Arrange(Frame); + LayoutManager.ArrangeChildren(Frame); IsArrangeValid = true; Handler?.SetFrame(Frame); } diff --git a/src/Controls/src/Core/Layout/StackLayout.cs b/src/Controls/src/Core/Layout/StackLayout.cs index 4b340a4d1..53fda6255 100644 --- a/src/Controls/src/Core/Layout/StackLayout.cs +++ b/src/Controls/src/Core/Layout/StackLayout.cs @@ -1,5 +1,4 @@ -using System.Linq; - +using System.Linq; // This is a temporary namespace until we rename everything and move the legacy layouts namespace Microsoft.Maui.Controls.Layout2 diff --git a/src/Controls/src/Core/Layout/VerticalStackLayout.cs b/src/Controls/src/Core/Layout/VerticalStackLayout.cs index 6ca8b65e1..d3c117ae7 100644 --- a/src/Controls/src/Core/Layout/VerticalStackLayout.cs +++ b/src/Controls/src/Core/Layout/VerticalStackLayout.cs @@ -1,4 +1,3 @@ -using Microsoft.Maui.Handlers; using Microsoft.Maui.Layouts; diff --git a/src/Controls/src/Core/RowDefinition.cs b/src/Controls/src/Core/RowDefinition.cs index 493cbae66..0a2f8c84d 100644 --- a/src/Controls/src/Core/RowDefinition.cs +++ b/src/Controls/src/Core/RowDefinition.cs @@ -2,7 +2,7 @@ using System; namespace Microsoft.Maui.Controls { - public sealed class RowDefinition : BindableObject, IDefinition + public sealed class RowDefinition : BindableObject, IDefinition, IGridRowDefinition { public static readonly BindableProperty HeightProperty = BindableProperty.Create("Height", typeof(GridLength), typeof(RowDefinition), new GridLength(1, GridUnitType.Star), propertyChanged: (bindable, oldValue, newValue) => ((RowDefinition)bindable).OnSizeChanged()); @@ -12,6 +12,7 @@ namespace Microsoft.Maui.Controls MinimumHeight = -1; } + [TypeConverter(typeof(GridLengthTypeConverter))] public GridLength Height { get { return (GridLength)GetValue(HeightProperty); } diff --git a/src/Core/src/Core/IGridColumnDefinition.cs b/src/Core/src/Core/IGridColumnDefinition.cs new file mode 100644 index 000000000..f2c87ecb6 --- /dev/null +++ b/src/Core/src/Core/IGridColumnDefinition.cs @@ -0,0 +1,7 @@ +namespace Microsoft.Maui +{ + public interface IGridColumnDefinition + { + GridLength Width { get; } + } +} \ No newline at end of file diff --git a/src/Core/src/Core/IGridLayout.cs b/src/Core/src/Core/IGridLayout.cs new file mode 100644 index 000000000..46a04f820 --- /dev/null +++ b/src/Core/src/Core/IGridLayout.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace Microsoft.Maui +{ + public interface IGridLayout : ILayout + { + IReadOnlyList RowDefinitions { get; } + IReadOnlyList ColumnDefinitions { get; } + + double RowSpacing { get; } + double ColumnSpacing { get; } + + int GetRow(IView view); + int GetRowSpan(IView view); + + int GetColumn(IView view); + int GetColumnSpan(IView view); + } +} \ No newline at end of file diff --git a/src/Core/src/Core/IGridRowDefinition.cs b/src/Core/src/Core/IGridRowDefinition.cs new file mode 100644 index 000000000..ce6905af4 --- /dev/null +++ b/src/Core/src/Core/IGridRowDefinition.cs @@ -0,0 +1,7 @@ +namespace Microsoft.Maui +{ + public interface IGridRowDefinition + { + GridLength Height { get; } + } +} \ No newline at end of file diff --git a/src/Core/src/Core/IStackLayout.cs b/src/Core/src/Core/IStackLayout.cs index c59aed8ef..61cc55e48 100644 --- a/src/Core/src/Core/IStackLayout.cs +++ b/src/Core/src/Core/IStackLayout.cs @@ -1,3 +1,6 @@ +using System.Collections.Generic; + + namespace Microsoft.Maui { /// diff --git a/src/Core/src/Layouts/GridLayoutManager.cs b/src/Core/src/Layouts/GridLayoutManager.cs new file mode 100644 index 000000000..d2fb8e2f4 --- /dev/null +++ b/src/Core/src/Layouts/GridLayoutManager.cs @@ -0,0 +1,464 @@ +using System; +using System.Collections.Generic; + +namespace Microsoft.Maui.Layouts +{ + public class GridLayoutManager : LayoutManager + { + public GridLayoutManager(IGridLayout layout) : base(layout) + { + Grid = layout; + } + + public IGridLayout Grid { get; } + + public override Size Measure(double widthConstraint, double heightConstraint) + { + var structure = new GridStructure(Grid, widthConstraint, heightConstraint); + + return new Size(structure.GridWidth(), structure.GridHeight()); + } + + public override void ArrangeChildren(Rectangle childBounds) + { + var structure = new GridStructure(Grid, childBounds.Width, childBounds.Height); + + foreach (var view in Grid.Children) + { + var cell = structure.ComputeFrameFor(view); + view.Arrange(cell); + } + } + + class GridStructure + { + readonly IGridLayout _grid; + readonly double _gridWidthConstraint; + readonly double _gridHeightConstraint; + + Row[] _rows { get; } + Column[] _columns { get; } + Cell[] _cells { get; } + + readonly Dictionary _spans = new Dictionary(); + + public GridStructure(IGridLayout grid, double widthConstraint, double heightConstraint) + { + _grid = grid; + _gridWidthConstraint = widthConstraint; + _gridHeightConstraint = heightConstraint; + _rows = new Row[_grid.RowDefinitions.Count]; + + for (int n = 0; n < _grid.RowDefinitions.Count; n++) + { + _rows[n] = new Row(_grid.RowDefinitions[n]); + } + + _columns = new Column[_grid.ColumnDefinitions.Count]; + + for (int n = 0; n < _grid.ColumnDefinitions.Count; n++) + { + _columns[n] = new Column(_grid.ColumnDefinitions[n]); + } + + _cells = new Cell[_grid.Children.Count]; + + InitializeCells(); + + MeasureCells(); + } + + void InitializeCells() + { + for (int n = 0; n < _grid.Children.Count; n++) + { + var view = _grid.Children[n]; + var column = _grid.GetColumn(view); + var columnSpan = _grid.GetColumnSpan(view); + + var columnGridLengthType = GridLengthType.None; + + for (int columnIndex = column; columnIndex < column + columnSpan; columnIndex++) + { + columnGridLengthType |= ToGridLengthType(_columns[columnIndex].ColumnDefinition.Width.GridUnitType); + } + + var row = _grid.GetRow(view); + var rowSpan = _grid.GetRowSpan(view); + + var rowGridLengthType = GridLengthType.None; + + for (int rowIndex = row; rowIndex < row + rowSpan; rowIndex++) + { + rowGridLengthType |= ToGridLengthType(_rows[rowIndex].RowDefinition.Height.GridUnitType); + } + + _cells[n] = new Cell(n, row, column, rowSpan, columnSpan, columnGridLengthType, rowGridLengthType); + } + } + + public Rectangle ComputeFrameFor(IView view) + { + var firstColumn = _grid.GetColumn(view); + var lastColumn = firstColumn + _grid.GetColumnSpan(view); + + var firstRow = _grid.GetRow(view); + var lastRow = firstRow + _grid.GetRowSpan(view); + + double top = TopEdgeOfRow(firstRow); + double left = LeftEdgeOfColumn(firstColumn); + + double width = 0; + + for (int n = firstColumn; n < lastColumn; n++) + { + width += _columns[n].Size; + } + + double height = 0; + + for (int n = firstRow; n < lastRow; n++) + { + height += _rows[n].Size; + } + + // TODO ezhart this isn't correctly accounting for row spacing when spanning multiple rows + // (and column spacing is probably wrong, too) + + return new Rectangle(left, top, width, height); + } + + public double GridHeight() + { + return SumDefinitions(_rows, _grid.RowSpacing); + } + + public double GridWidth() + { + return SumDefinitions(_columns, _grid.ColumnSpacing); + } + + double SumDefinitions(Definition[] definitions, double spacing) + { + double sum = 0; + + for (int n = 0; n < definitions.Length; n++) + { + var current = definitions[n].Size; + + if (current <= 0) + { + continue; + } + + sum += current; + + if (n > 0) + { + sum += spacing; + } + } + + return sum; + } + + void MeasureCells() + { + for (int n = 0; n < _cells.Length; n++) + { + var cell = _cells[n]; + + if (cell.ColumnGridLengthType == GridLengthType.Absolute + && cell.RowGridLengthType == GridLengthType.Absolute) + { + continue; + } + + var availableWidth = _gridWidthConstraint - GridWidth(); + var availableHeight = _gridHeightConstraint - GridHeight(); + + var measure = _grid.Children[cell.ViewIndex].Measure(availableWidth, availableHeight); + + if (cell.IsColumnSpanAuto) + { + if (cell.ColumnSpan == 1) + { + _columns[cell.Column].Update(measure.Width); + } + else + { + var span = new Span(cell.Column, cell.ColumnSpan, true, measure.Width); + TrackSpan(span); + } + } + + if (cell.IsRowSpanAuto) + { + if (cell.RowSpan == 1) + { + _rows[cell.Row].Update(measure.Height); + } + else + { + var span = new Span(cell.Row, cell.RowSpan, false, measure.Height); + TrackSpan(span); + } + } + } + + ResolveSpans(); + } + + void TrackSpan(Span span) + { + if (_spans.TryGetValue(span.Key, out Span? otherSpan)) + { + // This span may replace an equivalent but smaller span + if (span.Requested > otherSpan.Requested) + { + _spans[span.Key] = span; + } + } + else + { + _spans[span.Key] = span; + } + } + + void ResolveSpans() + { + foreach (var span in _spans.Values) + { + if (span.IsColumn) + { + ResolveSpan(_columns, span.Start, span.Length, _grid.ColumnSpacing, span.Requested); + } + else + { + ResolveSpan(_rows, span.Start, span.Length, _grid.RowSpacing, span.Requested); + } + } + } + + void ResolveSpan(Definition[] definitions, int start, int length, double spacing, double requestedSize) + { + double currentSize = 0; + var end = start + length; + + // Determine how large the spanned area currently is + for (int n = start; n < end; n++) + { + currentSize += definitions[n].Size; + + if (n > start) + { + currentSize += spacing; + } + } + + if (requestedSize <= currentSize) + { + // If our request fits in the current size, we're good + return; + } + + // Figure out how much more space we need in this span + double required = requestedSize - currentSize; + + // And how many parts of the span to distribute that space over + int autoCount = 0; + for (int n = start; n < end; n++) + { + if (definitions[n].IsAuto) + { + autoCount += 1; + } + } + + double distribution = required / autoCount; + + // And distribute that over the rows/columns in the span + for (int n = start; n < end; n++) + { + if (definitions[n].IsAuto) + { + definitions[n].Size += distribution; + } + } + } + + double LeftEdgeOfColumn(int column) + { + double left = 0; + + for (int n = 0; n < column; n++) + { + left += _columns[n].Size; + left += _grid.ColumnSpacing; + } + + return left; + } + + double TopEdgeOfRow(int row) + { + double top = 0; + + for (int n = 0; n < row; n++) + { + top += _rows[n].Size; + top += _grid.RowSpacing; + } + + return top; + } + } + + class Span + { + public int Start { get; } + public int Length { get; } + public bool IsColumn { get; } + public double Requested { get; } + + public SpanKey Key { get; } + + public Span(int start, int length, bool isColumn, double value) + { + Start = start; + Length = length; + IsColumn = isColumn; + Requested = value; + + Key = new SpanKey(Start, Length, IsColumn); + } + } + + class SpanKey + { + public SpanKey(int start, int length, bool isColumn) + { + Start = start; + Length = length; + IsColumn = isColumn; + } + + public int Start { get; } + public int Length { get; } + public bool IsColumn { get; } + + public override bool Equals(object? obj) + { + return obj is SpanKey key && + Start == key.Start && + Length == key.Length && + IsColumn == key.IsColumn; + } + + public override int GetHashCode() + { + return Start.GetHashCode() ^ Length.GetHashCode() ^ IsColumn.GetHashCode(); + } + } + + class Cell + { + public int ViewIndex { get; } + public int Row { get; } + public int Column { get; } + public int RowSpan { get; } + public int ColumnSpan { get; } + + public GridLengthType ColumnGridLengthType { get; } + public GridLengthType RowGridLengthType { get; } + + public Cell(int viewIndex, int row, int column, int rowSpan, int columnSpan, + GridLengthType columnGridLengthType, GridLengthType rowGridLengthType) + { + ViewIndex = viewIndex; + Row = row; + Column = column; + RowSpan = rowSpan; + ColumnSpan = columnSpan; + ColumnGridLengthType = columnGridLengthType; + RowGridLengthType = rowGridLengthType; + } + + public bool IsColumnSpanAuto => HasFlag(ColumnGridLengthType, GridLengthType.Auto); + public bool IsRowSpanAuto => HasFlag(RowGridLengthType, GridLengthType.Auto); + + bool HasFlag(GridLengthType a, GridLengthType b) + { + // Avoiding Enum.HasFlag here for performance reasons; we don't need the type check + return (a & b) == b; + } + } + + [Flags] + enum GridLengthType + { + None = 0, + Absolute = 1, + Auto = 2, + Star = 4 + } + + static GridLengthType ToGridLengthType(GridUnitType gridUnitType) + { + return gridUnitType switch + { + GridUnitType.Absolute => GridLengthType.Absolute, + GridUnitType.Star => GridLengthType.Star, + GridUnitType.Auto => GridLengthType.Auto, + _ => GridLengthType.None, + }; + } + + abstract class Definition + { + public double Size { get; set; } + + public void Update(double size) + { + if (size > Size) + { + Size = size; + } + } + + public abstract bool IsAuto { get; } + } + + class Column : Definition + { + public IGridColumnDefinition ColumnDefinition { get; set; } + + public override bool IsAuto => ColumnDefinition.Width.IsAuto; + + public Column(IGridColumnDefinition columnDefinition) + { + ColumnDefinition = columnDefinition; + if (columnDefinition.Width.IsAbsolute) + { + Size = columnDefinition.Width.Value; + } + } + } + + class Row : Definition + { + public IGridRowDefinition RowDefinition { get; set; } + + public override bool IsAuto => RowDefinition.Height.IsAuto; + + public Row(IGridRowDefinition rowDefinition) + { + RowDefinition = rowDefinition; + if (rowDefinition.Height.IsAbsolute) + { + Size = rowDefinition.Height.Value; + } + } + } + } +} diff --git a/src/Core/src/Layouts/HorizontalStackLayoutManager.cs b/src/Core/src/Layouts/HorizontalStackLayoutManager.cs index 21eb75baa..1879bb2f5 100644 --- a/src/Core/src/Layouts/HorizontalStackLayoutManager.cs +++ b/src/Core/src/Layouts/HorizontalStackLayoutManager.cs @@ -20,7 +20,7 @@ namespace Microsoft.Maui.Layouts return new Size(finalWidth, measure.Height); } - public override void Arrange(Rectangle bounds) + public override void ArrangeChildren(Rectangle childBounds) { if (Stack.FlowDirection == FlowDirection.LeftToRight) { diff --git a/src/Core/src/Layouts/ILayoutManager.cs b/src/Core/src/Layouts/ILayoutManager.cs index a3d80d31c..d344710a1 100644 --- a/src/Core/src/Layouts/ILayoutManager.cs +++ b/src/Core/src/Layouts/ILayoutManager.cs @@ -5,6 +5,6 @@ namespace Microsoft.Maui.Layouts public interface ILayoutManager { Size Measure(double widthConstraint, double heightConstraint); - void Arrange(Rectangle bounds); + void ArrangeChildren(Rectangle childBounds); } } diff --git a/src/Core/src/Layouts/LayoutManager.cs b/src/Core/src/Layouts/LayoutManager.cs index a9f1f6596..91c25e4b3 100644 --- a/src/Core/src/Layouts/LayoutManager.cs +++ b/src/Core/src/Layouts/LayoutManager.cs @@ -13,7 +13,7 @@ namespace Microsoft.Maui.Layouts public ILayout Layout { get; } public abstract Size Measure(double widthConstraint, double heightConstraint); - public abstract void Arrange(Rectangle bounds); + public abstract void ArrangeChildren(Rectangle childBounds); public static double ResolveConstraints(double externalConstraint, double desiredLength) { diff --git a/src/Core/src/Layouts/VerticalStackLayoutManager.cs b/src/Core/src/Layouts/VerticalStackLayoutManager.cs index 21d9bf6a4..1b2461c11 100644 --- a/src/Core/src/Layouts/VerticalStackLayoutManager.cs +++ b/src/Core/src/Layouts/VerticalStackLayoutManager.cs @@ -21,7 +21,7 @@ namespace Microsoft.Maui.Layouts return new Size(measure.Width, finalHeight); } - public override void Arrange(Rectangle bounds) => Arrange(Stack.Spacing, Stack.Children); + public override void ArrangeChildren(Rectangle childBounds) => Arrange(Stack.Spacing, Stack.Children); static Size Measure(double widthConstraint, int spacing, IReadOnlyList views) { @@ -52,6 +52,5 @@ namespace Microsoft.Maui.Layouts stackHeight += destination.Height + spacing; } } - } } diff --git a/src/Controls/src/Core/GridLength.cs b/src/Core/src/Primitives/GridLength.cs similarity index 92% rename from src/Controls/src/Core/GridLength.cs rename to src/Core/src/Primitives/GridLength.cs index c3df73c0d..721056b8e 100644 --- a/src/Controls/src/Core/GridLength.cs +++ b/src/Core/src/Primitives/GridLength.cs @@ -1,9 +1,8 @@ using System; using System.Diagnostics; -namespace Microsoft.Maui.Controls +namespace Microsoft.Maui { - [TypeConverter(typeof(GridLengthTypeConverter))] [DebuggerDisplay("{Value}.{GridUnitType}")] public struct GridLength { @@ -51,7 +50,7 @@ namespace Microsoft.Maui.Controls GridUnitType = type; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { return obj != null && obj is GridLength && Equals((GridLength)obj); } diff --git a/src/Controls/src/Core/GridUnitType.cs b/src/Core/src/Primitives/GridUnitType.cs similarity index 63% rename from src/Controls/src/Core/GridUnitType.cs rename to src/Core/src/Primitives/GridUnitType.cs index 888f0cc31..1582b466a 100644 --- a/src/Controls/src/Core/GridUnitType.cs +++ b/src/Core/src/Primitives/GridUnitType.cs @@ -1,4 +1,4 @@ -namespace Microsoft.Maui.Controls +namespace Microsoft.Maui { public enum GridUnitType { diff --git a/src/Core/tests/UnitTests/Layouts/GridLayoutManagerTests.cs b/src/Core/tests/UnitTests/Layouts/GridLayoutManagerTests.cs new file mode 100644 index 000000000..88f50da19 --- /dev/null +++ b/src/Core/tests/UnitTests/Layouts/GridLayoutManagerTests.cs @@ -0,0 +1,688 @@ +using System.Collections.Generic; +using NSubstitute; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Layouts; +using static Microsoft.Maui.UnitTests.Layouts.LayoutTestHelpers; +using Xunit; + +namespace Microsoft.Maui.UnitTests.Layouts +{ + [Category(TestCategory.Layout)] + public class GridLayoutManagerTests + { + const string GridSpacing = "GridSpacing"; + const string GridAutoSizing = "GridAutoSizing"; + const string GridStarSizing = "GridStarSizing"; + const string GridAbsoluteSizing = "GridAbsoluteSizing"; + const string GridSpan = "GridSpan"; + + IGridLayout CreateGridLayout(int rowSpacing = 0, int colSpacing = 0, + string rows = null, string columns = null) + { + IEnumerable rowDefs = null; + IEnumerable colDefs = null; + + if (rows != null) + { + rowDefs = CreateTestRows(rows.Split(",")); + } + + if (columns != null) + { + colDefs = CreateTestColumns(columns.Split(",")); + } + + var grid = Substitute.For(); + + grid.RowSpacing.Returns(rowSpacing); + grid.ColumnSpacing.Returns(colSpacing); + + SubRowDefs(grid, rowDefs); + SubColDefs(grid, colDefs); + + return grid; + } + + void SubRowDefs(IGridLayout grid, IEnumerable rows = null) + { + if (rows == null) + { + var rowDef = Substitute.For(); + rowDef.Height.Returns(GridLength.Auto); + var rowDefs = new List + { + rowDef + }; + grid.RowDefinitions.Returns(rowDefs); + } + else + { + grid.RowDefinitions.Returns(rows); + } + } + + void SubColDefs(IGridLayout grid, IEnumerable cols = null) + { + if (cols == null) + { + var colDefs = CreateTestColumns("auto"); + grid.ColumnDefinitions.Returns(colDefs); + } + else + { + grid.ColumnDefinitions.Returns(cols); + } + } + + List CreateTestColumns(params string[] columnWidths) + { + var converter = new GridLengthTypeConverter(); + + var colDefs = new List(); + + foreach (var width in columnWidths) + { + var gridLength = converter.ConvertFromInvariantString(width); + var colDef = Substitute.For(); + colDef.Width.Returns(gridLength); + colDefs.Add(colDef); + } + + return colDefs; + } + + List CreateTestRows(params string[] rowHeights) + { + var converter = new GridLengthTypeConverter(); + + var rowDefs = new List(); + + foreach (var height in rowHeights) + { + var gridLength = converter.ConvertFromInvariantString(height); + var rowDef = Substitute.For(); + rowDef.Height.Returns(gridLength); + rowDefs.Add(rowDef); + } + + return rowDefs; + } + + void SetLocation(IGridLayout grid, IView view, int row = 0, int col = 0, int rowSpan = 1, int colSpan = 1) + { + grid.GetRow(view).Returns(row); + grid.GetRowSpan(view).Returns(rowSpan); + grid.GetColumn(view).Returns(col); + grid.GetColumnSpan(view).Returns(colSpan); + } + + Size MeasureAndArrange(IGridLayout grid, double widthConstraint = double.PositiveInfinity, double heightConstraint = double.PositiveInfinity) + { + var manager = new GridLayoutManager(grid); + var measuredSize = manager.Measure(widthConstraint, heightConstraint); + manager.ArrangeChildren(new Rectangle(Point.Zero, measuredSize)); + + return measuredSize; + } + + void AssertArranged(IView view, double x, double y, double width, double height) + { + var expected = new Rectangle(x, y, width, height); + view.Received().Arrange(Arg.Is(expected)); + } + + [Category(GridAutoSizing)] + [Fact] + public void OneAutoRowOneAutoColumn() + { + // A one-row, one-column grid + var grid = CreateGridLayout(); + + // A 100x100 IView + var view = CreateTestView(new Size(100, 100)); + + // Set up the grid to have a single child + AddChildren(grid, view); + + // Set up the row/column values and spans + SetLocation(grid, view); + + MeasureAndArrange(grid, double.PositiveInfinity, double.PositiveInfinity); + + // We expect that the only child of the grid will be given its full size + AssertArranged(view, 0, 0, 100, 100); + } + + [Category(GridAbsoluteSizing)] + [Fact] + public void TwoAbsoluteColumnsOneAbsoluteRow() + { + var grid = CreateGridLayout(columns: "100, 100", rows: "10"); + + var viewSize = new Size(10, 10); + + var view0 = CreateTestView(viewSize); + var view1 = CreateTestView(viewSize); + + AddChildren(grid, view0, view1); + + SetLocation(grid, view0); + SetLocation(grid, view1, col: 1); + + // Assuming no constraints on space + MeasureAndArrange(grid, double.PositiveInfinity, double.NegativeInfinity); + + // Column width is 100, viewSize is less than that, so it should be able to layout out at full size + AssertArranged(view0, 0, 0, 100, 10); + + // Since the first column is 100 wide, we expect the view in the second column to start at x = 100 + AssertArranged(view1, 100, 0, 100, 10); + } + + [Category(GridAbsoluteSizing)] + [Fact] + public void TwoAbsoluteRowsAndColumns() + { + var grid = CreateGridLayout(columns: "100, 100", rows: "10, 30"); + + var viewSize = new Size(10, 10); + + var view0 = CreateTestView(viewSize); + var view1 = CreateTestView(viewSize); + var view2 = CreateTestView(viewSize); + var view3 = CreateTestView(viewSize); + + AddChildren(grid, view0, view1, view2, view3); + + SetLocation(grid, view0); + SetLocation(grid, view1, col: 1); + SetLocation(grid, view2, row: 1); + SetLocation(grid, view3, row: 1, col: 1); + + // Assuming no constraints on space + MeasureAndArrange(grid, double.PositiveInfinity, double.NegativeInfinity); + + AssertArranged(view0, 0, 0, 100, 10); + + // Since the first column is 100 wide, we expect the view in the second column to start at x = 100 + AssertArranged(view1, 100, 0, 100, 10); + + // First column, second row, so y should be 10 + AssertArranged(view2, 0, 10, 100, 30); + + // Second column, second row, so 100, 10 + AssertArranged(view3, 100, 10, 100, 30); + } + + [Category(GridAbsoluteSizing), Category(GridAutoSizing)] + [Fact] + public void TwoAbsoluteColumnsOneAutoRow() + { + var grid = CreateGridLayout(columns: "100, 100"); + + var viewSize = new Size(10, 10); + + var view0 = CreateTestView(viewSize); + var view1 = CreateTestView(viewSize); + + AddChildren(grid, view0, view1); + + SetLocation(grid, view0); + SetLocation(grid, view1, col: 1); + + // Assuming no constraints on space + MeasureAndArrange(grid, double.PositiveInfinity, double.NegativeInfinity); + + // Column width is 100, viewSize is less, so it should be able to layout at full size + AssertArranged(view0, 0, 0, 100, viewSize.Height); + + // Since the first column is 100 wide, we expect the view in the second column to start at x = 100 + AssertArranged(view1, 100, 0, 100, viewSize.Height); + } + + [Category(GridAbsoluteSizing), Category(GridAutoSizing)] + [Fact] + public void TwoAbsoluteRowsOneAutoColumn() + { + var grid = CreateGridLayout(rows: "100, 100"); + + var viewSize = new Size(10, 10); + + var view0 = CreateTestView(viewSize); + var view1 = CreateTestView(viewSize); + + AddChildren(grid, view0, view1); + + SetLocation(grid, view0); + SetLocation(grid, view1, row: 1); + + // Assuming no constraints on space + MeasureAndArrange(grid, double.PositiveInfinity, double.NegativeInfinity); + + // Row height is 100, so full view should fit + AssertArranged(view0, 0, 0, viewSize.Width, 100); + + // Since the first row is 100 tall, we expect the view in the second row to start at y = 100 + AssertArranged(view1, 0, 100, viewSize.Width, 100); + } + + [Category(GridSpacing)] + [Fact(DisplayName = "Row spacing shouldn't affect a single-row grid")] + public void SingleRowIgnoresRowSpacing() + { + var grid = CreateGridLayout(rowSpacing: 10); + var view = CreateTestView(new Size(100, 100)); + AddChildren(grid, view); + SetLocation(grid, view); + + MeasureAndArrange(grid, double.PositiveInfinity, double.PositiveInfinity); + AssertArranged(view, 0, 0, 100, 100); + } + + [Category(GridSpacing)] + [Fact(DisplayName = "Two rows should include the row spacing once")] + public void TwoRowsWithSpacing() + { + var grid = CreateGridLayout(rows: "100, 100", rowSpacing: 10); + var view0 = CreateTestView(new Size(100, 100)); + var view1 = CreateTestView(new Size(100, 100)); + AddChildren(grid, view0, view1); + SetLocation(grid, view0); + SetLocation(grid, view1, row: 1); + + MeasureAndArrange(grid, double.PositiveInfinity, double.PositiveInfinity); + AssertArranged(view0, 0, 0, 100, 100); + + // With column width 100 and spacing of 10, we expect the second column to start at 110 + AssertArranged(view1, 0, 110, 100, 100); + } + + [Category(GridSpacing)] + [Fact(DisplayName = "Measure should include row spacing")] + public void MeasureTwoRowsWithSpacing() + { + var grid = CreateGridLayout(rows: "100, 100", rowSpacing: 10); + var view0 = CreateTestView(new Size(100, 100)); + var view1 = CreateTestView(new Size(100, 100)); + AddChildren(grid, view0, view1); + SetLocation(grid, view0); + SetLocation(grid, view1, row: 1); + + var manager = new GridLayoutManager(grid); + var measure = manager.Measure(double.PositiveInfinity, double.PositiveInfinity); + + Assert.Equal(100 + 100 + 10, measure.Height); + } + + [Category(GridAutoSizing)] + [Fact(DisplayName = "Auto rows without content have height zero")] + public void EmptyAutoRowsHaveNoHeight() + { + var grid = CreateGridLayout(rows: "100, auto, 100"); + var view0 = CreateTestView(new Size(100, 100)); + var view2 = CreateTestView(new Size(100, 100)); + + AddChildren(grid, view0, view2); + SetLocation(grid, view0); + SetLocation(grid, view2, row: 2); + + var manager = new GridLayoutManager(grid); + var measure = manager.Measure(double.PositiveInfinity, double.PositiveInfinity); + manager.ArrangeChildren(new Rectangle(0, 0, measure.Width, measure.Height)); + + // Because the auto row has no content, we expect it to have height zero + Assert.Equal(100 + 100, measure.Height); + + // Verify the offset for the third row + AssertArranged(view2, 0, 100, 100, 100); + } + + [Category(GridSpacing)] + [Fact(DisplayName = "Empty rows should not incur additional row spacing")] + public void RowSpacingForEmptyRows() + { + var grid = CreateGridLayout(rows: "100, auto, 100", rowSpacing: 10); + var view0 = CreateTestView(new Size(100, 100)); + var view2 = CreateTestView(new Size(100, 100)); + + AddChildren(grid, view0, view2); + SetLocation(grid, view0); + SetLocation(grid, view2, row: 2); + + var manager = new GridLayoutManager(grid); + var measure = manager.Measure(double.PositiveInfinity, double.PositiveInfinity); + + // Because the auto row has no content, we expect it to have height zero + // and we expect that it won't add more row spacing + Assert.Equal(100 + 100 + 10, measure.Height); + } + + [Fact(DisplayName = "Column spacing shouldn't affect a single-column grid")] + public void SingleColumnIgnoresColumnSpacing() + { + var grid = CreateGridLayout(colSpacing: 10); + var view = CreateTestView(new Size(100, 100)); + AddChildren(grid, view); + SetLocation(grid, view); + + MeasureAndArrange(grid, double.PositiveInfinity, double.PositiveInfinity); + AssertArranged(view, 0, 0, 100, 100); + } + + [Fact(DisplayName = "Two columns should include the column spacing once")] + public void TwoColumnsWithSpacing() + { + var grid = CreateGridLayout(columns: "100, 100", colSpacing: 10); + var view0 = CreateTestView(new Size(100, 100)); + var view1 = CreateTestView(new Size(100, 100)); + AddChildren(grid, view0, view1); + SetLocation(grid, view0); + SetLocation(grid, view1, col: 1); + + MeasureAndArrange(grid, double.PositiveInfinity, double.PositiveInfinity); + AssertArranged(view0, 0, 0, 100, 100); + + // With column width 100 and spacing of 10, we expect the second column to start at 110 + AssertArranged(view1, 110, 0, 100, 100); + } + + [Fact(DisplayName = "Measure should include column spacing")] + public void MeasureTwoColumnsWithSpacing() + { + var grid = CreateGridLayout(columns: "100, 100", colSpacing: 10); + var view0 = CreateTestView(new Size(100, 100)); + var view1 = CreateTestView(new Size(100, 100)); + AddChildren(grid, view0, view1); + SetLocation(grid, view0); + SetLocation(grid, view1, col: 1); + + var manager = new GridLayoutManager(grid); + var measure = manager.Measure(double.PositiveInfinity, double.PositiveInfinity); + + Assert.Equal(100 + 100 + 10, measure.Width); + } + + [Category(GridAutoSizing)] + [Fact(DisplayName = "Auto columns without content have width zero")] + public void EmptyAutoColumnsHaveNoWidth() + { + var grid = CreateGridLayout(columns: "100, auto, 100"); + var view0 = CreateTestView(new Size(100, 100)); + var view2 = CreateTestView(new Size(100, 100)); + + AddChildren(grid, view0, view2); + SetLocation(grid, view0); + SetLocation(grid, view2, col: 2); + + var manager = new GridLayoutManager(grid); + var measure = manager.Measure(double.PositiveInfinity, double.PositiveInfinity); + manager.ArrangeChildren(new Rectangle(0, 0, measure.Width, measure.Height)); + + // Because the auto column has no content, we expect it to have width zero + Assert.Equal(100 + 100, measure.Width); + + // Verify the offset for the third column + AssertArranged(view2, 100, 0, 100, 100); + } + + [Category(GridSpacing)] + [Fact(DisplayName = "Empty columns should not incur additional column spacing")] + public void ColumnSpacingForEmptyColumns() + { + var grid = CreateGridLayout(columns: "100, auto, 100", colSpacing: 10); + var view0 = CreateTestView(new Size(100, 100)); + var view2 = CreateTestView(new Size(100, 100)); + + AddChildren(grid, view0, view2); + SetLocation(grid, view0); + SetLocation(grid, view2, col: 2); + + var manager = new GridLayoutManager(grid); + var measure = manager.Measure(double.PositiveInfinity, double.PositiveInfinity); + + // Because the auto column has no content, we expect it to have height zero + // and we expect that it won't add more row spacing + Assert.Equal(100 + 100 + 10, measure.Width); + } + + [Category(GridSpan)] + [Fact(DisplayName = "Simple row spanning")] + public void ViewSpansRows() + { + var grid = CreateGridLayout(rows: "auto, auto"); + var view0 = CreateTestView(new Size(100, 100)); + AddChildren(grid, view0); + SetLocation(grid, view0, rowSpan: 2); + + var measuredSize = MeasureAndArrange(grid); + + AssertArranged(view0, 0, 0, 100, 100); + Assert.Equal(100, measuredSize.Width); + + // We expect the rows to each get half the view height + Assert.Equal(100, measuredSize.Height); + } + + [Category(GridSpan)] + [Fact(DisplayName = "Simple row spanning with multiple views")] + public void ViewSpansRowsWhenOtherViewsPresent() + { + var grid = CreateGridLayout(rows: "auto, auto", columns: "auto, auto"); + var view0 = CreateTestView(new Size(100, 100)); + var view1 = CreateTestView(new Size(50, 50)); + AddChildren(grid, view0, view1); + + SetLocation(grid, view0, rowSpan: 2); + SetLocation(grid, view1, row: 1, col: 1); + + var measuredSize = MeasureAndArrange(grid); + + Assert.Equal(100 + 50, measuredSize.Width); + Assert.Equal(100, measuredSize.Height); + + AssertArranged(view0, 0, 0, 100, 100); + AssertArranged(view1, 100, 25, 50, 75); + } + + [Category(GridSpan)] + [Category(GridSpacing)] + [Fact(DisplayName = "Row spanning with row spacing")] + public void RowSpanningShouldAccountForSpacing() + { + var grid = CreateGridLayout(rows: "auto, auto", columns: "auto, auto", rowSpacing: 5); + var view0 = CreateTestView(new Size(100, 100)); + var view1 = CreateTestView(new Size(50, 50)); + var view2 = CreateTestView(new Size(50, 50)); + AddChildren(grid, view0, view1, view2); + + SetLocation(grid, view0, rowSpan: 2); + SetLocation(grid, view1, row: 0, col: 1); + SetLocation(grid, view2, row: 1, col: 1); + + var measuredSize = MeasureAndArrange(grid); + + Assert.Equal(150, measuredSize.Width); + Assert.Equal(50 + 50 + 5, measuredSize.Height); + + AssertArranged(view0, 0, 0, 100, 100); + AssertArranged(view1, 100, 0, 50, 50); + AssertArranged(view2, 100, 55, 50, 50); + } + + [Category(GridSpan)] + [Fact(DisplayName = "Simple column spanning with multiple views")] + public void ViewSpansColumnsWhenOtherViewsPresent() + { + var grid = CreateGridLayout(rows: "auto, auto", columns: "auto, auto"); + var view0 = CreateTestView(new Size(100, 100)); + var view1 = CreateTestView(new Size(50, 50)); + AddChildren(grid, view0, view1); + + SetLocation(grid, view0, colSpan: 2); + SetLocation(grid, view1, row: 1, col: 1); + + var measuredSize = MeasureAndArrange(grid); + + Assert.Equal(100, measuredSize.Width); + Assert.Equal(100 + 50, measuredSize.Height); + + AssertArranged(view0, 0, 0, 100, 100); + AssertArranged(view1, 25, 100, 75, 50); + } + + [Category(GridSpan)] + [Category(GridSpacing)] + [Fact(DisplayName = "Column spanning with column spacing")] + public void ColumnSpanningShouldAccountForSpacing() + { + var grid = CreateGridLayout(rows: "auto, auto", columns: "auto, auto", colSpacing: 5); + var view0 = CreateTestView(new Size(100, 100)); + var view1 = CreateTestView(new Size(50, 50)); + var view2 = CreateTestView(new Size(50, 50)); + AddChildren(grid, view0, view1, view2); + + SetLocation(grid, view0, colSpan: 2); + SetLocation(grid, view1, row: 1, col: 0); + SetLocation(grid, view2, row: 1, col: 1); + + var measuredSize = MeasureAndArrange(grid); + + Assert.Equal(50 + 50 + 5, measuredSize.Width); + Assert.Equal(100 + 50, measuredSize.Height); + + AssertArranged(view0, 0, 0, 100, 100); + AssertArranged(view1, 0, 100, 50, 50); + AssertArranged(view2, 55, 100, 50, 50); + } + + [Category(GridSpan)] + [Fact(DisplayName = "Row-spanning views smaller than the views confined to the row should not affect row size")] + public void SmallerSpanningViewsShouldNotAffectRowSize() + { + var grid = CreateGridLayout(rows: "auto, auto", columns: "auto, auto"); + var view0 = CreateTestView(new Size(30, 30)); + var view1 = CreateTestView(new Size(50, 50)); + AddChildren(grid, view0, view1); + + SetLocation(grid, view0, rowSpan: 2); + SetLocation(grid, view1, row: 0, col: 1); + + var measuredSize = MeasureAndArrange(grid); + + Assert.Equal(30 + 50, measuredSize.Width); + Assert.Equal(50, measuredSize.Height); + + AssertArranged(view0, 0, 0, 30, 50); + AssertArranged(view1, 30, 0, 50, 50); + } + + [Category(GridSpan)] + [Fact(DisplayName = "Column-spanning views smaller than the views confined to the column should not affect column size")] + public void SmallerSpanningViewsShouldNotAffectColumnSize() + { + var grid = CreateGridLayout(rows: "auto, auto", columns: "auto, auto"); + var view0 = CreateTestView(new Size(30, 30)); + var view1 = CreateTestView(new Size(50, 50)); + AddChildren(grid, view0, view1); + + SetLocation(grid, view0, colSpan: 2); + SetLocation(grid, view1, row: 1, col: 0); + + var measuredSize = MeasureAndArrange(grid); + + Assert.Equal(50, measuredSize.Width); + Assert.Equal(30 + 50, measuredSize.Height); + + AssertArranged(view0, 0, 0, 50, 30); + AssertArranged(view1, 0, 30, 50, 50); + } + + + [Category(GridAbsoluteSizing)] + [Fact(DisplayName = "Empty absolute rows/columns still affect Grid size")] + public void EmptyAbsoluteRowsAndColumnsAffectSize() + { + var grid = CreateGridLayout(rows: "10, 40", columns: "15, 85"); + var view0 = CreateTestView(new Size(30, 30)); + AddChildren(grid, view0); + + SetLocation(grid, view0, row: 1, col: 1); + + var measuredSize = MeasureAndArrange(grid); + + Assert.Equal(15 + 85, measuredSize.Width); + Assert.Equal(10 + 40, measuredSize.Height); + + AssertArranged(view0, 15, 10, 85, 40); + } + + [Category(GridSpan)] + [Fact(DisplayName = "Row and column spans should be able to mix")] + public void MixedRowAndColumnSpans() + { + var grid = CreateGridLayout(rows: "auto, auto", columns: "auto, auto"); + var view0 = CreateTestView(new Size(60, 30)); + var view1 = CreateTestView(new Size(30, 60)); + AddChildren(grid, view0, view1); + + SetLocation(grid, view0, row: 0, col: 0, colSpan: 2); + SetLocation(grid, view1, row: 0, col: 1, rowSpan: 2); + + var measuredSize = MeasureAndArrange(grid); + + Assert.Equal(60, measuredSize.Width); + Assert.Equal(60, measuredSize.Height); + + AssertArranged(view0, 0, 0, 60, 45); + AssertArranged(view1, 15, 0, 45, 60); + } + + [Category(GridSpan)] + [Fact(DisplayName = "Row span including absolute row should not modify absolute size")] + public void RowSpanShouldNotModifyAbsoluteRowSize() + { + var grid = CreateGridLayout(rows: "auto, 20", columns: "auto, auto"); + var view0 = CreateTestView(new Size(100, 100)); + var view1 = CreateTestView(new Size(50, 10)); + AddChildren(grid, view0, view1); + + SetLocation(grid, view0, rowSpan: 2); + SetLocation(grid, view1, row: 1, col: 1); + + var measuredSize = MeasureAndArrange(grid); + + Assert.Equal(100 + 50, measuredSize.Width); + Assert.Equal(100, measuredSize.Height); + + AssertArranged(view0, 0, 0, 100, 100); + + // The item in the second row starts at y = 80 because the auto row above had to distribute + // all the extra space into row 0; row 1 is absolute, so no tinkering with it to make stuff fit + AssertArranged(view1, 100, 80, 50, 20); + } + + [Category(GridSpan)] + [Fact(DisplayName = "Column span including absolute column should not modify absolute size")] + public void ColumnSpanShouldNotModifyAbsoluteColumnSize() + { + var grid = CreateGridLayout(rows: "auto, auto", columns: "auto, 20"); + var view0 = CreateTestView(new Size(100, 100)); + var view1 = CreateTestView(new Size(50, 10)); + AddChildren(grid, view0, view1); + + SetLocation(grid, view0, colSpan: 2); + SetLocation(grid, view1, row: 1, col: 1); + + var measuredSize = MeasureAndArrange(grid); + + Assert.Equal(100, measuredSize.Width); + Assert.Equal(100 + 10, measuredSize.Height); + + AssertArranged(view0, 0, 0, 100, 100); + + // The item in the second row starts at x = 80 because the auto column before it had to distribute + // all the extra space into column 0; column 1 is absolute, so no tinkering with it to make stuff fit + AssertArranged(view1, 80, 100, 20, 10); + } + } +} diff --git a/src/Core/tests/UnitTests/Layouts/HorizontalStackLayoutManagerTests.cs b/src/Core/tests/UnitTests/Layouts/HorizontalStackLayoutManagerTests.cs index f1567dfae..23d6c4bc6 100644 --- a/src/Core/tests/UnitTests/Layouts/HorizontalStackLayoutManagerTests.cs +++ b/src/Core/tests/UnitTests/Layouts/HorizontalStackLayoutManagerTests.cs @@ -37,7 +37,7 @@ namespace Microsoft.Maui.UnitTests.Layouts var manager = new HorizontalStackLayoutManager(stack); var measuredSize = manager.Measure(double.PositiveInfinity, 100); - manager.Arrange(new Rectangle(Point.Zero, measuredSize)); + manager.ArrangeChildren(new Rectangle(Point.Zero, measuredSize)); var expectedRectangle = new Rectangle(0, 0, 100, 100); stack.Children[0].Received().Arrange(Arg.Is(expectedRectangle)); @@ -53,7 +53,7 @@ namespace Microsoft.Maui.UnitTests.Layouts var manager = new HorizontalStackLayoutManager(stack); var measuredSize = manager.Measure(double.PositiveInfinity, 100); - manager.Arrange(new Rectangle(Point.Zero, measuredSize)); + manager.ArrangeChildren(new Rectangle(Point.Zero, measuredSize)); var expectedRectangle0 = new Rectangle(0, 0, 100, 100); stack.Children[0].Received().Arrange(Arg.Is(expectedRectangle0)); @@ -70,7 +70,7 @@ namespace Microsoft.Maui.UnitTests.Layouts { var stack = CreateTestLayout(); - var view = CreateTestView(new Size(viewWidth, 100)); + var view = LayoutTestHelpers.CreateTestView(new Size(viewWidth, 100)); var children = new List() { view }.AsReadOnly(); @@ -88,14 +88,14 @@ namespace Microsoft.Maui.UnitTests.Layouts var stack = CreateTestLayout(); var manager = new HorizontalStackLayoutManager(stack); - var view1 = CreateTestView(new Size(100, 200)); - var view2 = CreateTestView(new Size(100, 150)); + var view1 = LayoutTestHelpers.CreateTestView(new Size(100, 200)); + var view2 = LayoutTestHelpers.CreateTestView(new Size(100, 150)); var children = new List() { view1, view2 }.AsReadOnly(); stack.Children.Returns(children); var measurement = manager.Measure(double.PositiveInfinity, double.PositiveInfinity); - manager.Arrange(new Rectangle(Point.Zero, measurement)); + manager.ArrangeChildren(new Rectangle(Point.Zero, measurement)); // The tallest IView is 200, so the stack should be that tall Assert.Equal(200, measurement.Height); @@ -117,7 +117,7 @@ namespace Microsoft.Maui.UnitTests.Layouts var manager = new HorizontalStackLayoutManager(stack); var measuredSize = manager.Measure(double.PositiveInfinity, 100); - manager.Arrange(new Rectangle(Point.Zero, measuredSize)); + manager.ArrangeChildren(new Rectangle(Point.Zero, measuredSize)); // We expect that the starting view (0) should be arranged on the left, // and the next rectangle (1) should be on the right @@ -136,7 +136,7 @@ namespace Microsoft.Maui.UnitTests.Layouts var manager = new HorizontalStackLayoutManager(stack); var measuredSize = manager.Measure(double.PositiveInfinity, 100); - manager.Arrange(new Rectangle(Point.Zero, measuredSize)); + manager.ArrangeChildren(new Rectangle(Point.Zero, measuredSize)); // We expect that the starting view (0) should be arranged on the right, // and the next rectangle (1) should be on the left diff --git a/src/Core/tests/UnitTests/Layouts/LayoutTestHelpers.cs b/src/Core/tests/UnitTests/Layouts/LayoutTestHelpers.cs new file mode 100644 index 000000000..bdb635c56 --- /dev/null +++ b/src/Core/tests/UnitTests/Layouts/LayoutTestHelpers.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using NSubstitute; + +namespace Microsoft.Maui.UnitTests.Layouts +{ + public static class LayoutTestHelpers + { + public static IView CreateTestView() + { + var view = Substitute.For(); + + view.Height.Returns(-1); + view.Width.Returns(-1); + + return view; + } + + public static IView CreateTestView(Size viewSize) + { + var view = CreateTestView(); + + view.Measure(Arg.Any(), Arg.Any()).Returns(viewSize); + view.DesiredSize.Returns(viewSize); + + return view; + } + + public static void AddChildren(ILayout layout, params IView[] views) + { + var children = new List(views); + layout.Children.Returns(children.AsReadOnly()); + } + } +} diff --git a/src/Core/tests/UnitTests/Layouts/StackLayoutManagerTests.cs b/src/Core/tests/UnitTests/Layouts/StackLayoutManagerTests.cs index 42d713c05..fce44a265 100644 --- a/src/Core/tests/UnitTests/Layouts/StackLayoutManagerTests.cs +++ b/src/Core/tests/UnitTests/Layouts/StackLayoutManagerTests.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Microsoft.Maui; using NSubstitute; @@ -44,7 +44,7 @@ namespace Microsoft.Maui.UnitTests.Layouts for (int n = 0; n < viewCount; n++) { - var view = CreateTestView(new Size(viewWidth, viewHeight)); + var view = LayoutTestHelpers.CreateTestView(new Size(viewWidth, viewHeight)); children.Add(view); } diff --git a/src/Core/tests/UnitTests/Layouts/VerticalStackLayoutManagerTests.cs b/src/Core/tests/UnitTests/Layouts/VerticalStackLayoutManagerTests.cs index 673246b76..d9ca026a0 100644 --- a/src/Core/tests/UnitTests/Layouts/VerticalStackLayoutManagerTests.cs +++ b/src/Core/tests/UnitTests/Layouts/VerticalStackLayoutManagerTests.cs @@ -37,7 +37,7 @@ namespace Microsoft.Maui.UnitTests.Layouts var manager = new VerticalStackLayoutManager(stack); var measuredSize = manager.Measure(100, double.PositiveInfinity); - manager.Arrange(new Rectangle(Point.Zero, measuredSize)); + manager.ArrangeChildren(new Rectangle(Point.Zero, measuredSize)); var expectedRectangle = new Rectangle(0, 0, 100, 100); stack.Children[0].Received().Arrange(Arg.Is(expectedRectangle)); @@ -53,7 +53,7 @@ namespace Microsoft.Maui.UnitTests.Layouts var manager = new VerticalStackLayoutManager(stack); var measuredSize = manager.Measure(double.PositiveInfinity, 100); - manager.Arrange(new Rectangle(Point.Zero, measuredSize)); + manager.ArrangeChildren(new Rectangle(Point.Zero, measuredSize)); var expectedRectangle0 = new Rectangle(0, 0, 100, 100); stack.Children[0].Received().Arrange(Arg.Is(expectedRectangle0)); @@ -70,7 +70,7 @@ namespace Microsoft.Maui.UnitTests.Layouts { var stack = CreateTestLayout(); - var view = CreateTestView(new Size(100, viewHeight)); + var view = LayoutTestHelpers.CreateTestView(new Size(100, viewHeight)); var children = new List() { view }.AsReadOnly(); @@ -88,14 +88,14 @@ namespace Microsoft.Maui.UnitTests.Layouts var stack = CreateTestLayout(); var manager = new VerticalStackLayoutManager(stack); - var view1 = CreateTestView(new Size(200, 100)); - var view2 = CreateTestView(new Size(150, 100)); + var view1 = LayoutTestHelpers.CreateTestView(new Size(200, 100)); + var view2 = LayoutTestHelpers.CreateTestView(new Size(150, 100)); var children = new List() { view1, view2 }.AsReadOnly(); stack.Children.Returns(children); var measurement = manager.Measure(double.PositiveInfinity, double.PositiveInfinity); - manager.Arrange(new Rectangle(Point.Zero, measurement)); + manager.ArrangeChildren(new Rectangle(Point.Zero, measurement)); // The widest IView is 200, so the stack should be that wide Assert.Equal(200, measurement.Width);