Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WinForms

SizerPanel and CaptionPanel

4.44/5 (7 votes)
28 Dec 2009CPOL9 min read 36.7K   2.2K  
Two WinForms controls that help to develop compact and flexible user interfaces.

Introduction

Very often, you need to display several controls in your form, e.g., to edit data records with many fields. And, you want to give the user the option to resize those editing controls depending on whether there is a lot of content to display or not. You can use a SplitContainer to implement this, but a SplitContainer only makes two controls resizeable. To get multiple editing controls resizeable, you may use many SplitContainers and nest them within each other. But that leads to a complex structure of controls, and changes in the GUI design can become difficult.

Introduction II - SizerPanel / CaptionPanel - Picture and Mini-Webcast

A picture tells more than a thousand words...

SizerPanel.gif

The animation shows one possibility of a very compact GUI. The general layout problem is: some single-lined-TextBoxes need to be long, to show their full content, and others can be shorter. So this layout puts all "single-liners" together in a single long line of controls with inserted line-breaks. A more eye-kind option would be to set FlowDirection.TopDown so that the textboxes get arranged in several columns. But, for instance, the column with the "Straße" textbox must be very wide, and so must be all the controls of that column (which don't need that width) - you see: the shown one is the most compact option.

... and a video consists of thousands of pictures ;).

If you want to get a taste of how to work with the SizerPanel and CaptionPanel, take a look at my YouTube-Mini-Webcast before reading my exhausting explanations.

The SizerPanel

You can put all editing controls into a SizerPanel, and each will be sizeable. The sizing-behavior can be very different depending on some properties you can set:

  • TopDown As Boolean
  • Simplifies the inherited FlowDirection property. SizerPanel only supports TopDown or not ("not TopDown" means: FlowDirection.LeftToRight). A control is horizontal sizeable as well as vertical. And SizerPanel can layout horizontally as well as vertically. What does this mean? If your flow layout is TopDown and you enlarge a control in the horizontal direction, it enlarges the whole column.

  • WrapContents As Boolean
  • If True, wrapping its contents is a typical FlowlayoutPanel task: The contained controls are aligned in lines (or columns, if FlowDirection.TopDown), and at the end of a line, there is a line break. And, the next control is placed at the first position of the next line. This continues until FlowlayoutPanel is completely filled (and if you go on, scrollbars may appear). But what happens when WrapContents is set to False? Then, there will be only one line with all the controls, no line break. And what happens to the space where the other lines have been?

    If False: Filling mode. When WrapContents is off, SizerPanel automatically switches to a filling mode and resizes the height of the controls to its ClientSize's Height.

  • IsFillControl As Boolean
  • The Filling mode above can be topped: you can set one of the contained controls as the "FillControl". That means, it will always be resized in a way that the SizerPanel is exactly filled out. If the user enlarges another control, the FillControl will shrink, and vice versa. Now, SizerPanel behaves like a SplitContainer, but with more panels. (See the behavior of the upper panel in the animation.) The property "IsFillControl" is implemented as an ExtenderProvider property. For that, in the propertygrid, this property appears as a property of the contained controls (although it is implemented in the container).

    A last minute patched feature to the FillControl: if you set a FillControl in MultiColumn mode, that column is anchored to the left and right of the parent control.

  • AutoScroll As Boolean
  • If True: Appearance of srollbars, when the SizerPanel is filled out and you still add controls or enlarge one of them. But what happens when AutoScroll is off? Will the added controls be placed outside the visible range, and no scrollbar can get it back? No!

    If False: Auto-size. SizerPanel simply switches to an AutoSize mode, and enlarges itself to keep the new control accessible. (See the behavior of the lower panel in the animation.).

Sizing at Runtime

Instead of using a special SplitterControl, SizerPanel's layout-logic respects the Margin property of the contained controls. That means, between two controls with Margin = 3, there will appear a space of 6 pixels. Within that space, MouseMove events reach the underlying SizerPanel, and its MouseMove handler checks whether there is a sizeable control nearby. If so, the Cursor is set to Cursor.HSplit / .VSplit to inform the user about the sizeability option.

CaptionPanel

Assume that you have many controls (TextBoxes, ComboBoxes, DateTimePickers ...) to edit the properties of a data record. Then, each editing control needs a caption to let the user know which property it edits. Usually, that "Caption-Task" is done by Labels, and when you drag a data source in Detail mode from the DataSource window to your Form, the Designer generates Labels and editing controls appropriately. Unfortunately, in a flowing layout, that is not useful. Because a line break can come between a caption label and its captioned editing control, the caption appears at the right of the Panel and the editing control on the left, in the next line. So you see, it's necessary to get the caption and the editing control together, on one Panel. The idea of CaptionPanel is born.

But I added my CaptionPanel with one more feature than make it simply appear as one unit to the layout-engine: it can infer its caption from the contained control. I was surprised how easy that could be done:

VB
''' <summary>examine the childControl and propose a caption</summary>
Private Function InferCaption() As String
   If Me.Controls.Count = 0 Then Return AddColon(Name)
   With Controls(0)
      If .DataBindings.Count = 0 Then
         Return AddColon(.Name)
      Else
         Dim bnd = .DataBindings(0)
         Return AddColon(bnd.BindingMemberInfo.BindingMember)
      End If
   End With
End Function

'append/remove ':', depending wether the Caption is on the left side or on Top
Private Function AddColon(ByVal s As String) As String
   s = s.TrimEnd(":"c, " "c)
   Return If(_CaptionOnTop, s, s & ":")
End Function

That takes the data bound property as the caption, if available. But of course, you can also set the caption manually.

Trouble Comes Up

My CaptionPanel approach was simple: inherit from Panel, display the caption by owner-drawing, and layout the contained editing control centered in the remaining space. But what's to be done with editing controls which are not sizeable? Like MonthCalendar? Or a Textbox with Multiline.Off - you can size it only horizontal, not vertical. An intelligent CaptionPanel must infer its sizeability from its contained EditingControl, because it really looks awful when you enlarge a CaptionPanel, but the contained Combobox stays on Height=20.

And that is a task where you can learn to hate the WinForm.Control class: I've found no reliable way to anticipate whether a control will accept a size-change or not. In the end, you must assign the new size, and then check out whether it was accepted and in what direction! There is a Control.GetPreferredSize function, but - damned! - I couldn't figure out the meaning of the Size which is returned. For example: a multilined TextBox, with MinimumSize {10; 0} and current size {100; 15}, placed on a Panel. A call to Textbox1.GetPreferredSize(New Size(50, 50)) returns {10; 20} !! Can anyone explain the meaning of that value to me?

The property Control.AutoSize, which is seen in Labels, is set to True by default, so by default, you can't resize Labels in the Designer. But you can still resize them by code! In other words: Label.AutoSize lies at runtime.

What I really missed while implementing the layout-logic was a reliable property Control.CanSizeWidth/CanSizeHeight.

But let's stop talking about problems and start talk about...

...Solutions

BoundsEx...

...is one of my layout helper "inventions". It encapsulates some data and code to check out the sizeability of a control. For example, it provides the missing CanSize property (in a way). That's useful, but not that creative. How do we extend a given Control with the BoundEx property? Answer: Use...

...ComponentProperty

ComponentProperty inherits from Dictionary(Of Tcomponent, T), and T is the data type of the property. Dictionary.Item(key As Tcomponent) is re-implemented in a way such that a new entry is generated if it is missing and you try to access it. While generating that new entry, the key IComponent.Disposed event is subscripted by a handler, which removes the entry. So only valid Components stay in that Dictionary, and whatever Component you pass as a key - there will be an entry for it.

To extend the Control class (which, of course, implements IComponent) with a BoundsEx property, I instantiate a globally accessible ComponentProperty(Of Control, BoundsEx), and now each Control in the world is associated with a BoundsEx entry. To drive this approach to top, I implemented an extension function Control.BoundsX() As BoundsEx, with the effect that the BoundsEx access-code looks and feels like accessing a real Control property.

VB
Namespace System.ComponentModel

   Public Class ComponentProperty(Of Tcomp As IComponent, T)
      Inherits Dictionary(Of Tcomp, T)
      Private ReadOnly _Init As Func(Of Tcomp, T)

      Private ReadOnly cmp_Disposed As eventhandler = _
         Function(s, e) MyBase.Remove(DirectCast(s, Tcomp))

      Public Sub New(Optional ByVal init As Func(Of Tcomp, T) = Nothing)
         _Init = init
      End Sub

      Default Public Shadows Property Item(ByVal cmp As Tcomp) As T
         Get
            Dim ret As T = Nothing
            If Not MyBase.TryGetValue(cmp, ret) Then
               If _Init.NotNull Then ret = _Init(cmp)
               MyBase.Add(cmp, ret)
               AddHandler cmp.Disposed, cmp_Disposed
            End If
            Return ret
         End Get
         Set(ByVal value As T)
            If Not MyBase.ContainsKey(cmp) Then _
               AddHandler cmp.Disposed, cmp_Disposed
            MyBase.Item(cmp) = value
         End Set
      End Property

   End Class

End Namespace

Namespace System.Windows.Forms

   Public Module modBounds

      Private ReadOnly Boundses As _
         New ComponentProperty(Of Control, BoundsEx) _
         (Function(ctl) New BoundsEx(ctl, True))

      <System.Runtime.CompilerServices.Extension()> _
      Public Function BoundsX(ByVal ctl As Control) As BoundsEx
         Return Boundses(ctl)
      End Function

      Public Class BoundsEx
         '...
      End Class

   End Module

End Namespace

Here is how we access a BoundsEx:

VB
Dim bnd = TextBox1.BoundsX

Vector

Have you heard about the clean-code-principle "DRY" - ("Don't Repeat Yourself")? It really bothers me that I have to create a layout-code to align all the controls in a line properly and then I must write the exact same code again to support FlowDirection.TopDown. And, I must change all Size.Widths to Size.Height, all Heights to Width, all Xs to Y, all Tops to Left, all Vertical to Horizontal... ... ...

So I invented the Vector structure. Its properties: X and Y (wow!). And they can be accessed by index. Vector(0) means X, Vector(1) accesses Y. I added some operators, especially a bunch of widening CType() operators, to easily convert a Vector from and to Size as well as from and to Point.

Even Rectangles can be modeled by a Vector, namely by two Vectors: one Vector for the Location and one for the Size.

Now I write my layout code with Vectors, using indexed 0 or 1. And to make it work in FlowDirection.TopDown, I just swap the indices, and the task is done.

To have horizontal as index 0 and vertical as index 1 is very convenient. For instance, I can simply expose my (mentioned above) Control.CanSize property as a boolean array, and CanSize(0) indicates horizontal sizeability, and CanSize(1) vertical. Furthermore, both the FlowDirection enumeration members fit perfectly to this concept (FlowDirection.LeftToRight = 0, .TopDown = 1).

I hope you have understood the general approach of my SizerPanel layout algorithm:

VB
Private Sub LayoutWrapping(ByVal bnds As List(Of BoundsEx))
   Dim iFlow = MyBase.FlowDirection
   Dim notFlow = iFlow Xor 1
   Dim ubound = bnds.Count - 1
   Dim curr As Vector = Padding.Vector1 + AutoScrollPosition
   Dim available As Vector = ClientSize - Padding.Vector2
   Dim ground = curr(iFlow)
   Dim iLineStart As Integer = 0
   Dim max As Integer = 0
   For iBnd = 0 To ubound
      Dim bnd = bnds(iBnd)
      If curr(iFlow) + bnd.Fullsize(iFlow) > available(iFlow) Then
         AssignLineHeight(iLineStart, iBnd - 1, bnds, max)
         curr(iFlow) = ground
         curr(notFlow) += max
         iLineStart = iBnd
         max = 0
      End If
      max.Maximize(bnd.Fullsize(notFlow))
      bnd.TopLeft = curr
      curr(iFlow) += bnd.Fullsize(iFlow)
   Next
   AssignLineHeight(iLineStart, ubound, bnds, bndSizing, max)
End Sub

Padding.Vector1 is built from Padding.Left/.Top, and Padding.Vector2 from Padding.Right/.Bottom. AssignLineHeight's first three arguments specify the BoundsExes belonging to a line of Controls, and max specifies the desired control size in the notFlow Direction (if FlowDirection.LeftToRight max specifies the desired Height).

I'm ready

I wish you a lot of fun if you are trying out my controls, and please let me know if you find a bug or have a (not too complicated!) idea for an improvement.

History

  • 12-28-2009: First published.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)