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 SplitContainer
s 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...
The animation shows one possibility of a very compact GUI. The general layout problem is: some single-lined-TextBox
es 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 (TextBox
es, ComboBox
es, DateTimePicker
s ...) 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 Label
s, and when you drag a data source in Detail
mode from the DataSource window to your Form
, the Designer generates Label
s 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:
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
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 Label
s, is set to True
by default, so by default, you can't resize Label
s 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 Component
s 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.
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
:
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.Width
s to Size.Height
, all Height
s to Width
, all X
s to Y
, all Top
s 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 Rectangle
s can be modeled by a Vector
, namely by two Vector
s: one Vector
for the Location
and one for the Size
.
Now I write my layout code with Vector
s, 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:
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 BoundsEx
es belonging to a line of Control
s, 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.