Easy form layout in WPF Part 1 – Introducing FormPanel

The WPF layout system is extremely powerful, there’s almost nothing you can’t do with Grid and maybe a few DockPanel objects – but that power comes at a price and that price is a lot of typing.

I find it hard to believe that anyone who has written any form in WPF isn’t sick of  <RowDefinition Height="Auto"/> and Grid.Column=”1” Grid.Row=”1” – and of course things get worse when you have to add a new row at the beginning of the form and you have to manually update all those Grid.Row definitions.

So, in this series I will try to solve the problem.

Now it’s important to remember we are trying to simplify our code here, we will not write a powerful do-everything control, we will write something that will cover the simple cases (that are around 80% of cases) and the rest we will code with Grid.

This series has 3 parts:

  1. Easy form layout in WPF Part 1 – Introducing FormPanel (You are here).
  2. Easy form layout in WPF Part 2 – How to deal with more complicated scenarios.
  3. Easy form layout in WPF Part 3 – Adding Groups.

You can find the complete source code with a sample project at the end of the last post.

Let’s start from the desired end result and work back to the code:

I’m writing a bug tracking product and I want a form with the usual bug tracking fields:

I want all the labels to be the same size and all the text boxes and combo boxes to be the same size, I want the labels centered vertically and the text/combo boxes to fill all the available width, I want constant spacing between labels and controls and between row and columns.

And most of all I want everything without writing any code or XAML on every windows.

This is what I want the XAML for the window above to look like (a real app would obviously need some data binding code to fill the controls):

<Window x:Class="FormPanelApp.Window3"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:l="clr-namespace:FormPanelApp"
    Title="Window3" Height="300" Width="500">
    <l:FormPanel Margin="10">
        <TextBlock Text="Title:"/>
        <TextBox/>
        <TextBlock Text="Area:"/>
        <ComboBox/>
        <TextBlock Text="Category:"/>
        <ComboBox/>
        <TextBlock Text="Assigned To:"/>
        <ComboBox/>
        <TextBlock Text="Status:"/>
        <ComboBox/>
        <TextBlock Text="Estimate:"/>
        <TextBox/>
        <TextBlock Text="Tags:"/>
        <TextBox/>
        <TextBlock Text="Version:"/>
        <TextBox/>
    </l:FormPanel>
</Window>

So what am I going to do? easy, write a custom panel.

We will Create a FormPanel class that inherits from the WPF Panel.

using System;
using System.Windows.Controls;
using System.Windows;

namespace FormPanelApp
{
    public class FormPanel : Panel
    {

Now we will add some dependency properties for things we would like to be configurable in the panel.

The first and most important is the number of columns (each column is a label/control pair):

        public static readonly DependencyProperty ColumnsProperty =
            DependencyProperty.Register("Columns", typeof(int), typeof(FormPanel),
            new FrameworkPropertyMetadata(2, FrameworkPropertyMetadataOptions.AffectsMeasure));
        public int Columns
        {
            get { return (int)GetValue(ColumnsProperty); }
            set { SetValue(ColumnsProperty, value); }
        }

The spacing between rows and columns:

        public static readonly DependencyProperty ColumnSpacingProperty =
            DependencyProperty.Register("ColumnSpacing", typeof(double), typeof(FormPanel),
            new FrameworkPropertyMetadata(15.0, FrameworkPropertyMetadataOptions.AffectsMeasure));
        public double ColumnSpacing
        {
            get { return (double)GetValue(ColumnSpacingProperty); }
            set { SetValue(ColumnSpacingProperty, value); }
        }

        public static readonly DependencyProperty RowSpacingProperty =
            DependencyProperty.Register("RowSpacing", typeof(double), typeof(FormPanel),
            new FrameworkPropertyMetadata(10.0, FrameworkPropertyMetadataOptions.AffectsMeasure));
        public double RowSpacing
        {
            get { return (double)GetValue(RowSpacingProperty); }
            set { SetValue(RowSpacingProperty, value); }
        }

And the space between labels and controls:

        public static readonly DependencyProperty LabelControlSpacingProperty =
            DependencyProperty.Register("LabelControlSpacing", typeof(double), typeof(FormPanel),
            new FrameworkPropertyMetadata(5.0, FrameworkPropertyMetadataOptions.AffectsMeasure));
        public double LabelControlSpacing
        {
            get { return (double)GetValue(LabelControlSpacingProperty); }
            set { SetValue(LabelControlSpacingProperty, value); }
        }

We will also create dependency properties for the size of labels and controls as calculated by the panel, in the next post we will see how useful this will be:

        public static readonly DependencyProperty LabelSizeProperty =
            DependencyProperty.Register("LabelSize", typeof(Size), typeof(FormPanel));
        public Size LabelSize
        {
            get { return (Size)GetValue(LabelSizeProperty); }
            set { SetValue(LabelSizeProperty, value); }
        }

        public static readonly DependencyProperty ControlSizeProperty =
            DependencyProperty.Register("ControlSize",typeof(Size),typeof(FormPanel));
        public Size ControlSize
        {
            get { return (Size)GetValue(ControlSizeProperty); }
            set { SetValue(ControlSizeProperty, value); }
        }

Now I'm going to add something called a coordinator, in the third post we will use it to quickly make the FormPanel even more useful - you can ignore it for now:

        public IFormPanelCoordinator Coordinator { get; set; }

All the actual work in a panel is done in two methods: MeasureOverride and ArrangeOverride.

The MeasureOverride method calculates the required size for the panel, in our case we just scan all the panel's children and look for the maximum label width and height and maximum control width and height.

We save the results in the LabelSize and ControlSize properties we defined earlier and calculate the required size based on those sizes, the number of columns and the spacing properties we defined.

        protected override System.Windows.Size MeasureOverride(System.Windows.Size availableSize)
        {
            double labelMaxWidth = 0;
            double labelMaxHeight = 0;
            double controlMaxWidth = 0;
            double controlMaxHeight = 0;
            for (int i = 0; i < Children.Count-1; i += 2)
            {
                Children[i].Measure(availableSize);
                Children[i + 1].Measure(availableSize);
                labelMaxWidth = Math.Max(labelMaxWidth, Children[i].DesiredSize.Width);
                labelMaxHeight = Math.Max(labelMaxHeight, Children[i].DesiredSize.Height);
                controlMaxWidth = Math.Max(controlMaxWidth, Children[i+1].DesiredSize.Width);
                controlMaxHeight = Math.Max(controlMaxHeight, Children[i+1].DesiredSize.Height);
            }

            var oldLabelSize = LabelSize;
            var oldControlSize = ControlSize;
            var newLabelSize = new Size(labelMaxWidth, labelMaxHeight);
            var newControlSize = new Size(controlMaxWidth, controlMaxHeight);
            LabelSize = newLabelSize;
            ControlSize = newControlSize;

            if (Coordinator != null &&
                (newLabelSize != oldLabelSize || newControlSize != oldControlSize))
            {
                Coordinator.ControlOrLabelSizeChanged(this);
            }

            return new Size(
                Columns * (LabelSize.Width + ControlSize.Width + LabelControlSpacing) + (Columns - 1) * ColumnSpacing,
                ((Children.Count/2) / Columns) * Math.Max(LabelSize.Height, ControlSize.Height) + (((Children.Count/2) / Columns) - 1) * RowSpacing);
        }

We also notify the coordinator if the size changed, but we will talk about that in the third post

The ArrangeOverride method actually places all the labels and controls, it just loops over all the panel's children and calculates their final location:

 

        protected override Size ArrangeOverride(Size finalSize)
        {
            double controlWidth = (finalSize.Width - (Columns - 1) * ColumnSpacing - Columns * (LabelSize.Width + LabelControlSpacing)) / Columns;
            double rowHeight = Math.Max(LabelSize.Height, ControlSize.Height) + RowSpacing;
            double columnWidth = LabelSize.Width + LabelControlSpacing + controlWidth + ColumnSpacing;
            for (int i = 0; i < Children.Count - 1; i += 2)
            {
                var labelRect = new Rect(
                    columnWidth * ((i / 2) % Columns), rowHeight * ((i / 2) / Columns),
                    LabelSize.Width, rowHeight - RowSpacing);
                Children[i].Arrange(
                    new Rect(
                        labelRect.Left, 
                        labelRect.Top+(labelRect.Height-Children[i].DesiredSize.Height)/2,
                        Children[i].DesiredSize.Width,Children[i].DesiredSize.Height));
                Children[i + 1].Arrange(new Rect(
                    columnWidth * ((i / 2) % Columns) + LabelSize.Width + LabelControlSpacing, rowHeight * ((i / 2) / Columns),
                    controlWidth, rowHeight - RowSpacing));
            }
            return new Size(finalSize.Width, rowHeight * ((Children.Count/2) / Columns + 1));
        }

and of course, we have to close the class:

    }
}

also, I'm listing the IFormPanelCoordinator interface here because the FormPanel wouldn't compile without it.

 

    public interface IFormPanelCoordinator
    {
        void ControlOrLabelSizeChanged(FormPanel sender);
    }

And, as you see, we wrote a completely trivial class that take care of the annoying task of manually setting the grid layout.

I said in the beginning this is a simple class that will only cover the common cases, in the next post we will see how this class still saves us a lot of typing in the not-so-simple case.

posted @ Tuesday, July 27, 2010 2:27 PM

Comments on this entry:

# re: Easy form layout in WPF Part 1 – Introducing FormPanel

Left by Mike Strobel at 7/27/2010 3:49 PM

Nice! I had done something similar with an "AutoGrid" panel a while back. It's essentially a Grid with a fixed row or column count that grows horizontally or vertically, placing each element in a new cell. You can use the existing Grid.Row, Grid.Column, Grid.RowSpan, and Grid.ColumnSpan properties to override the default placement (i.e. if an item would normally be placed in Column 0, but you want to shift it over one cell, just set Grid.Column="1"). Automatic placement will then resume in the next cell (Column 3 of the same row if there are two columns, or Column 0 of the next row otherwise).

I've been planning to create a forms-oriented panel similar to what you've done here, but with support for nested "regions" or "groups". It looks like you're planning on adding this functionality later in the series, so I look forward to reading :).

# re: Easy form layout in WPF Part 1 – Introducing FormPanel

Left by Sakthivel at 7/16/2011 4:29 PM

This is really a good tutorial especially for a beginner. Thanks a lot.

# re: Easy form layout in WPF Part 1 – Introducing FormPanel

Left by Stefan Jope-Eser at 3/26/2013 12:29 PM

Great and flexible control. I was trying to do something like that with a grid, but had problems with the changing visibility in child controls.
You can make this FormPanel visibility aware by using a filtered Children collection instead:
var uiElementCollection = (from r in Children.OfType<UIElement>()
where r.Visibility != Visibility.Collapsed
select r).ToList();
This will not show, arrange or measure elements with collapsed visibility.

Also in the measure section you need to add space for another row if (uiElementCollection.Count / 2) % Columns > 0
Otherwise if you have 2 columns and 5 element pairs only 2 rows are shown, when you wanna have 3.

# re: Easy form layout in WPF Part 1 – Introducing FormPanel

Left by Nir at 3/26/2013 11:35 PM

Stefan - thank you, I'll look into it and maybe publish an update in a future blog post

# re: Easy form layout in WPF Part 1 – Introducing FormPanel

Left by Joe at 8/15/2013 9:38 PM

To fix the case where you have an uneven amount of control pairs, change the last lines in MeasureOverride to this:

var rows = Math.Ceiling(Math.Round((double)(Children.Count / 2), MidpointRounding.ToEven) / Columns);

return new Size(Math.Max(0, Columns * (LabelSize.Width + ControlSize.Width + LabelControlSpacing) + (Columns - 1) * ColumnSpacing), rows * Math.Max(LabelSize.Height, ControlSize.Height) + ((rows - 1) * RowSpacing));

# re: Easy form layout in WPF Part 1 – Introducing FormPanel

Left by Les Pinter at 5/8/2014 6:57 PM

This is almost exactly what I was looking for: Have you built this as a UserControl? And is the source code available? Thanks in advance!

Your comment:



 (will not be displayed)


 
 
 
Please add 3 and 3 and type the answer here: