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; + } +}