diff --git a/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj b/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj
index c163498a562..222f112324b 100644
--- a/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj
+++ b/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj
@@ -272,6 +272,7 @@
+
@@ -522,6 +523,9 @@
FocusBehaviorPage.xaml
+
+ FormPanelPage.xaml
+
TilesBrushPage.xaml
@@ -1014,6 +1018,13 @@
MSBuild:Compile
Designer
+
+ Designer
+ MSBuild:Compile
+
+
+ Designer
+
MSBuild:Compile
Designer
@@ -1576,7 +1587,6 @@
Visual C++ 2015 Runtime for Universal Windows Platform Apps
-
14.0
diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/FormPanel/FormPanel.png b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/FormPanel/FormPanel.png
new file mode 100644
index 00000000000..3f3e3bf6772
Binary files /dev/null and b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/FormPanel/FormPanel.png differ
diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/FormPanel/FormPanelCode.bind b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/FormPanel/FormPanelCode.bind
new file mode 100644
index 00000000000..9a8cee74e26
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/FormPanel/FormPanelCode.bind
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/FormPanel/FormPanelPage.xaml b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/FormPanel/FormPanelPage.xaml
new file mode 100644
index 00000000000..154240aac06
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/FormPanel/FormPanelPage.xaml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/FormPanel/FormPanelPage.xaml.cs b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/FormPanel/FormPanelPage.xaml.cs
new file mode 100644
index 00000000000..bdcda5f7cbb
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/FormPanel/FormPanelPage.xaml.cs
@@ -0,0 +1,16 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Windows.UI.Xaml.Controls;
+
+namespace Microsoft.Toolkit.Uwp.SampleApp.SamplePages
+{
+ ///
+ /// A page that shows how to use the FormPanel.
+ ///
+ public sealed partial class FormPanelPage : Page
+ {
+ public FormPanelPage() => InitializeComponent();
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/samples.json b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/samples.json
index dd893d84ea6..70f1ff552ad 100644
--- a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/samples.json
+++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/samples.json
@@ -227,6 +227,16 @@
"Icon": "/SamplePages/WrapPanel/WrapPanel.png",
"DocumentationUrl": "https://raw.githubusercontent.com/MicrosoftDocs/WindowsCommunityToolkitDocs/master/docs/controls/WrapPanel.md"
},
+ {
+ "Name": "FormPanel",
+ "Type": "FormPanelPage",
+ "Subcategory": "Layout",
+ "About": "The FormPanel Control positions child elements in fixed size columns.",
+ "CodeUrl": "https://github.com/windows-toolkit/WindowsCommunityToolkit/tree/master/Microsoft.Toolkit.Uwp.UI.Controls/FormPanel",
+ "XamlCodeFile": "FormPanelCode.bind",
+ "Icon": "/SamplePages/FormPanel/FormPanel.png",
+ "DocumentationUrl": "https://raw.githubusercontent.com/MicrosoftDocs/WindowsCommunityToolkitDocs/master/docs/controls/FormPanel.md"
+ },
{
"Name": "WrapLayout",
"Type": "WrapLayoutPage",
diff --git a/Microsoft.Toolkit.Uwp.UI.Controls/FormPanel/FormPanel.cs b/Microsoft.Toolkit.Uwp.UI.Controls/FormPanel/FormPanel.cs
new file mode 100644
index 00000000000..e83bff10625
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.UI.Controls/FormPanel/FormPanel.cs
@@ -0,0 +1,311 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Linq;
+using Windows.Foundation;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+
+namespace Microsoft.Toolkit.Uwp.UI.Controls
+{
+ ///
+ /// positions its child elements vertically in one or several columns based on the property.
+ ///
+ public class FormPanel : Panel
+ {
+ ///
+ /// The DP to store the MaxColumnWidth value.
+ ///
+ public static readonly DependencyProperty MaxColumnWidthProperty = DependencyProperty.Register(
+ nameof(MaxColumnWidth),
+ typeof(double),
+ typeof(FormPanel),
+ new PropertyMetadata(0.0, OnLayoutPropertyChanged));
+
+ ///
+ /// The DP to store the HorizontalSpacing value.
+ ///
+ public static readonly DependencyProperty HorizontalSpacingProperty = DependencyProperty.Register(
+ nameof(HorizontalSpacing),
+ typeof(double),
+ typeof(FormPanel),
+ new PropertyMetadata(0.0, OnLayoutPropertyChanged));
+
+ ///
+ /// The DP to store the VerticalSpacing value.
+ ///
+ public static readonly DependencyProperty VerticalSpacingProperty = DependencyProperty.Register(
+ nameof(VerticalSpacing),
+ typeof(double),
+ typeof(FormPanel),
+ new PropertyMetadata(0.0, OnLayoutPropertyChanged));
+
+ ///
+ /// The DP to store the Padding value.
+ ///
+ public static readonly DependencyProperty PaddingProperty = DependencyProperty.Register(
+ nameof(Padding),
+ typeof(Thickness),
+ typeof(FormPanel),
+ new PropertyMetadata(new Thickness(0), OnLayoutPropertyChanged));
+
+ ///
+ /// Gets or sets the distance between the border and its child object.
+ ///
+ ///
+ /// The dimensions of the space between the border and its child as a Thickness value.
+ /// Thickness is a structure that stores dimension values using pixel measures.
+ ///
+ public Thickness Padding
+ {
+ get => (Thickness)GetValue(PaddingProperty);
+ set => SetValue(PaddingProperty, value);
+ }
+
+ ///
+ /// Gets or sets the maximum width for the columns.
+ /// If the value is 0, it will display a single column (like a vertical ).
+ ///
+ public double MaxColumnWidth
+ {
+ get => (double)GetValue(MaxColumnWidthProperty);
+ set => SetValue(MaxColumnWidthProperty, value);
+ }
+
+ ///
+ /// Gets or sets the spacing between two columns.
+ ///
+ public double HorizontalSpacing
+ {
+ get => (double)GetValue(HorizontalSpacingProperty);
+ set => SetValue(HorizontalSpacingProperty, value);
+ }
+
+ ///
+ /// Gets or sets the spacing between two items.
+ ///
+ public double VerticalSpacing
+ {
+ get => (double)GetValue(VerticalSpacingProperty);
+ set => SetValue(VerticalSpacingProperty, value);
+ }
+
+ private static void OnLayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ var control = (FormPanel)d;
+ control.InvalidateMeasure();
+ }
+
+ ///
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ // We measure all our children with our column width. We let them have the height they want.
+ var (columnsCount, columnsWidth) = GetAvailableColumnsInformation(availableSize);
+ var childAvailableSize = new Size(columnsWidth, double.PositiveInfinity);
+ foreach (var child in Children)
+ {
+ child.Measure(childAvailableSize);
+ }
+
+ // We evaluate how the children are filling the columns to get the size that we will use.
+ var (_, columnHeight) = Partition(columnsCount, availableSize.Height);
+ return GetSize(columnsCount, columnsWidth, columnHeight);
+ }
+
+ ///
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ // We measure all our children with the minimum width between MaxColumnWidth and availableSize.Width.
+ var (columnsCount, columnsWidth) = GetAvailableColumnsInformation(finalSize);
+
+ // We split the items across all the columns
+ var (columnLastIndex, columnHeight) = Partition(columnsCount, finalSize.Height);
+
+ var rect = new Rect(Padding.Left, Padding.Top, columnsWidth, 0);
+ var currentColumnIndex = 0;
+ for (var childIndex = 0; childIndex < Children.Count; childIndex++)
+ {
+ var child = Children[childIndex];
+ rect.Height = child.DesiredSize.Height;
+
+ child.Arrange(rect);
+
+ if (currentColumnIndex < columnLastIndex.Length && childIndex == columnLastIndex[currentColumnIndex])
+ {
+ // We've reached the last item for the current column. We move to the next one.
+ currentColumnIndex++;
+
+ rect.X += columnsWidth + HorizontalSpacing;
+ rect.Y = Padding.Top;
+ }
+ else
+ {
+ rect.Y += child.DesiredSize.Height + VerticalSpacing;
+ }
+ }
+
+ return GetSize(columnsCount, columnsWidth, columnHeight);
+ }
+
+ private Size GetSize(int columnsCount, double columnsWidth, double columnHeight)
+ {
+ // We use this trick to fix rounding errors when scaling is greater than 100%
+ // The value we return from MeasureOverride() if converted using floor((value * scalefactor) + .5)/scalefactor before being provided to ArrangeOverride() and in some
+ // cases, we are receiving a value lower than what we're expecting. For example, when we return a desired width of 707 px, we receive 706,8571 in arrange and we're dropping
+ // one column. Forcing even numbers is an easy way to limit the issue.
+ // See: https://github.com/microsoft/microsoft-ui-xaml/issues/1441
+ var requiredColumnWidth = Math.Ceiling((columnsCount * columnsWidth) + (Math.Max(0, columnsCount - 1) * HorizontalSpacing) + Padding.Left + Padding.Right);
+ var evenColumnWidth = requiredColumnWidth % 2 == 0 ? requiredColumnWidth : (requiredColumnWidth + 1);
+
+ return new Size(
+ width: evenColumnWidth,
+ height: columnHeight);
+ }
+
+ private double GetChildrenHeight(int index) => Children[index].DesiredSize.Height;
+
+ ///
+ /// Partition our list into columns.
+ ///
+ /// The number of columns.
+ /// The available height for the columns.
+ ///
+ /// - columnLastIndexes: An array containing the index of the last item of each column or -1 if the column is not used and
+ /// the required height for the columns.
+ /// - columnHeight: the height required to draw our columns.
+ ///
+ private (int[] columnLastIndexes, double columnHeight) Partition(int columnsCount, double availableColumnHeight)
+ {
+ availableColumnHeight = availableColumnHeight - Padding.Top - Padding.Bottom;
+
+ var columnLastIndexes = new int[columnsCount];
+
+ var totalHeight = Children.Sum(child => child.DesiredSize.Height) + (Math.Max(Children.Count - 1, 0) * VerticalSpacing);
+ var expectedColumnHeight = totalHeight / columnsCount;
+ if (!double.IsInfinity(availableColumnHeight))
+ {
+ expectedColumnHeight = Math.Max(availableColumnHeight, expectedColumnHeight);
+ }
+
+ // If we are wrapped in a scroll viewer, we get the size of the scroll viewer to fill as much as possible the columns
+ if (Parent is ScrollViewer scrollviewer)
+ {
+ expectedColumnHeight = Math.Max(expectedColumnHeight, scrollviewer.ViewportHeight);
+ }
+
+ // We ensure that we have enough space to place the first item.
+ if (Children.Count > 0)
+ {
+ expectedColumnHeight = Math.Max(expectedColumnHeight, GetChildrenHeight(0));
+ }
+
+ var columnIndex = 0;
+ var (hasFoundPartition, adjustedExpectedColumnHeight) = DoPartition(
+ columnLastIndexes,
+ columnIndex,
+ childStartIndex: 0,
+ expectedColumnHeight: expectedColumnHeight);
+
+ // We have some overflow items, we move the first element of column 1 to column 0 and restart the logic.
+ if (columnLastIndexes.Length > 1)
+ {
+ while (!hasFoundPartition)
+ {
+ columnLastIndexes[0]++;
+ expectedColumnHeight = GetColumnHeight(columnLastIndexes[0]);
+
+ columnIndex = 1;
+ (hasFoundPartition, adjustedExpectedColumnHeight) = DoPartition(
+ columnLastIndexes,
+ columnIndex,
+ childStartIndex: columnLastIndexes[0] + 1,
+ expectedColumnHeight: expectedColumnHeight);
+ }
+ }
+
+ return (columnLastIndexes, adjustedExpectedColumnHeight);
+ }
+
+ ///
+ /// Partition our list in buckets which have all a size lower than .
+ ///
+ /// The array containing the indexes of the last item of each column.
+ /// The index of the first column where to apply the partition logic.
+ /// The index of the first child to consider.
+ /// The expected height for our columns.
+ ///
+ /// - partitionSuceeded: true if we've been able to partition all the children in columns.
+ /// - expectedColumnHeight: the adjusted height of the first column (fitting exactly all the first column items).
+ ///
+ private (bool partitionSuceeded, double expectedColumnHeight) DoPartition(
+ int[] columnLastIndexes,
+ int columnIndex,
+ int childStartIndex,
+ double expectedColumnHeight)
+ {
+ var currentColumnHeight = Padding.Top + Padding.Bottom;
+ var partitionSucceeded = true;
+
+ for (var i = childStartIndex; i < Children.Count; i++)
+ {
+ var currentChildHeight = GetChildrenHeight(i);
+ var columnHeightAfterAdd = currentColumnHeight + currentChildHeight;
+
+ if (columnHeightAfterAdd > expectedColumnHeight)
+ {
+ if (columnIndex == 0)
+ {
+ // Now that we have the items for our first column, we adjust expectedColumnHeight
+ // to be the height of this first column in order to have a more natural layout.
+ expectedColumnHeight = currentColumnHeight - VerticalSpacing;
+ }
+
+ columnIndex++;
+ if (columnIndex < columnLastIndexes.Length)
+ {
+ columnLastIndexes[columnIndex] = i;
+ currentColumnHeight = Padding.Top + Padding.Bottom + currentChildHeight + VerticalSpacing;
+ }
+ else
+ {
+ partitionSucceeded = false;
+ break;
+ }
+ }
+ else
+ {
+ columnLastIndexes[columnIndex] = i;
+ currentColumnHeight = columnHeightAfterAdd + VerticalSpacing;
+ }
+ }
+
+ // We set the indexes of the empty columns to -1
+ columnIndex++;
+ for (; columnIndex < columnLastIndexes.Length; columnIndex++)
+ {
+ columnLastIndexes[columnIndex] = -1;
+ }
+
+ return (partitionSucceeded, expectedColumnHeight);
+ }
+
+ private (int columnsCount, double columnsWidth) GetAvailableColumnsInformation(Size availableSize)
+ {
+ var availableWidth = Math.Max(availableSize.Width - Padding.Left - Padding.Right, 0);
+ var columnsWidth = MaxColumnWidth > 0 ? Math.Min(MaxColumnWidth, availableWidth) : availableWidth;
+ var columnsCount = 1;
+ if (columnsWidth < availableWidth)
+ {
+ var additionalColumns = (int)((availableWidth - columnsWidth) / (columnsWidth + HorizontalSpacing));
+ columnsCount += additionalColumns;
+ }
+
+ return (columnsCount, columnsWidth);
+ }
+
+ private double GetColumnHeight(int columnLastIndex)
+ => Children.Take(columnLastIndex + 1).Sum(child => child.DesiredSize.Height) + (columnLastIndex * VerticalSpacing) + Padding.Top + Padding.Bottom;
+ }
+}