可定制性极高! 不用设计器,教你纯靠代码添加控件并完成布局

本文最后更新于:6 天前

文章较长,请耐心阅读~

前言

如果你不喜欢 WinForms 仅仅是因为控件的创建是从工具箱里拖出来的太幼稚了,没有声明式 UI (XAML) 那么爽,那一定要看看这篇文章。

几天前我在清理 PlainCEETimer 项目的时候,发现有一类代码我忍它很久了可是都无从下手,那就是设计器生成的代码。于是为了减轻代码量达到优化的目的,我就在想有没有方法可以在不依赖设计器的情况下完成控件的添加以及排版布局。直到今天,它终于来了,我将它们命名为 Builder 模式 (通过模板构建控件) 和 半自动布局模式 (手动让各控件之间自动进行水平或竖直排列)。

如果你玩过 WPF 的 XAML 声明式 UI,使用 Grid 和 StackPanel 等借助 Margins 等属性就可以实现各种复杂布局,这里我就姑且称它为自动布局。而我的半自动布局模式只是多了一步人工让控件 “活起来” 的步骤,虽然有一定局限性和瑕疵,但扩展空间极大。这里就分享给大家,各位如果有这方面需求的话,可以参考一下,以及进行扩展并加入自己想要的功能。

在正式开始前,提到 WinForms 自动布局,你可能会想到 WinForms 内置的 TableLayoutPanel 和 FlowLayoutPanel,但我只能说这两个东西无法我预期的效果,不如自己来写布局逻辑。

其实要脱离设计器添加控件也不算什么难办的事,控件本身也是一种类,创建它们就像创建类实例一样,只不过额外要指定一些属性,以及事件绑定等。因此我们可以制作一个模板,其实就是一些方法罢了,不用每次都去 new 一个控件。然后主要的就是不使用设计器进行布局了,具体思路的话但凡你了解 UI 坐标系的话都能秒懂——相对坐标式布局。也就是说相对于一个控件的位置来决定另一个控件的位置,这与设计器采用的绝对坐标式布局不同。总之我们先来了解一些基础知识。

基础知识

Control 类

WinForms 几乎所有控件 (包括窗体,后文一律称之为控件) 都派生自这个基类 (ContextMenu、NotifyIcon 等例外,派生自 Component),它可以表示所有的控件,并提供一些通用的属性 (位置,大小,文字)、事件 (Click,MouseMove 等) 和方法 (Focus,Invalidate 等)。

坐标系

在 Windows 系统中,不管是控件、应用程序,还是屏幕等,每一个元素都有一套自己的 UI 坐标系,且原点始终是左上角的位置,接着从左向右是 x 轴,从上而下是 y 轴,如图:

Bounds (Rectangle/RECT) 属性

Bounds 是 Control 类下的一个属性,类型为 Rectangle,表示对象的位置 (Location)、大小 (Size)。另外如果你做过与 Win32 的 Interop 或者对 Win32 有一定的了解,你应该也见到过 RECT 这个类型,它也是用于存储位置和大小的数据结构。它们的不同在于决定 Rectangle 的主要参数也就是 LeftTopWidthHeight,而 RECTLeftTopRightBottom,后面 2 个在 Rectangle 里面也定义了,可以直接拿出来用,只是不那么常用罢了。两者各个参数的定义如图所示:

由此我们可以得到以下结论:

  • Left = X、Top = Y
  • Right = Left + Width、Bottom = Top + Height

这里多说几个技巧与冷知识。

  1. 当我们只需要改变控件的 X 或 Y 坐标时,我们可以直接设置 Left 或者是 Top 属性,而不是去设置 Location 属性并 new 一个 Point。同样的,只更改长或宽也可以直接设置 Width 或 Height 属性,而不是设置 Size 属性并 new 一个 Size。如果我们同时要设置位置和大小或者说混搭 (比如只想改变 Y 和 宽度),这时即不建议大家设置 Location 和 Size 属性,也不建议分开设置 Left、Top、Width、Height 属性,而是直接调用 SetBounds 方法 (其实设置上述属性内部走的也是 SetBounds),如果四个参数都需要更改,直接用第一个重载:void SetBounds(int x, int y, int width, int height)。除此之外如果只需要更改2个及以上的参数的话,一般用第二个重载:void SetBounds(int x, int y, int width, int height, BoundsSpecified specified),这里的 specified 参数表示你要更改的是哪个参数,传入即可。比如要更改 Y 坐标和高度,可以用 SetBounds(0, y值, 0, 高度, BoundsSpecified.Y | BoundsSpecified.Height)
  2. 你可能会疑惑为什么控件的 RightBottom 属性是只读的?仔细想想,这两个分别表示 X + 宽度 和 Y + 高度,如果这两个属性可赋值的话,那 WinForms 怎么知道你传入的到底是 X?还是 Y?还是 W?还是 H?所以设置为只读也是可以理解的。一般情况下,我们只有在需要针对同一个控件的 X 和宽度 或 Y 和高度进行求和的,就可以直接去拿 RightBottom 属性,避免手动计算。
  3. 关于 RECT 转 Rectangle:其实 .NET 内置了将 RECT 转换为 Rectangle 的方法,并不需要你手动在 RECT 上面做加减法。只不过这个方法的名字不是 ToRectangle 这么直接明了的,而是 Reactangle.FormLTRB(int left, int top, int right, int bottom),其中 LTRB 不就是表示 RECT 结构的四个参数吗?于是我们可以在 RECT 结构体里自己写一个 ToRectangle 方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
[StructLayout(LayoutKind.Sequential)]
private struct RECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;

public readonly Rectangle ToRectangle()
{
return Rectangle.FromLTRB(Left, Top, Right, Bottom);
}
}
  1. Rectangle 还内置了其他许多有用的属性和方法,值得大家逐个去理解和使用,这将带来很多简化:

Form 的一些重要属性

Form 类表示的是 WinForms 的窗体,也就是窗口。以下是常见且重要的属性,后面会用到。

  • AutoScaleDimensions = new(96F, 96F)
    表示设计时的 DPI 值,有助于以后在高 DPI 下执行正确的缩放,96 表示屏幕 100% 缩放下的 DPI 值,以及后续设置控件的 Bounds 都是基于 AutoScaleDimensions 和系统 DPI 进行调整的。保持默认即可
  • AutoScaleMode = AutoScaleMode.Dpi
    表示控件缩放模式为 Dpi。如果你的应用程序有运行在高缩放 (高 DPI,100% 以上) 环境的需求时,就选择 Dpi 模式。如果是 Font 模式的话,WinForms 将根据系统字体大小来调整控件字体大小,基本不会调整控件的大小,除非设置了 AutoSize。大多数情况一般用 Dpi
  • AutoSize = true; AutoSizeMode = AutoSizeMode.GrowAndShrink
    启用窗体的自适应大小,在本文中推荐开启;GrowAndShrink 表示窗体可以动态的缩小或变大,GrowOnly 表示只动态变大,不进行缩小。根据实际需求决定。
  • Font = new("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point, 0)
    设置窗体及所有子控件的字体。Segoe UI 是 Windows 在英文界面下的默认字体,中文下是 Microsoft YaHei (微软雅黑)。不过我还是推荐直接用 Segoe UI,此时系统会让英文/数字等使用 Segoe UI,中文字符使用微软雅黑 (类似于复合字体,但实际上是系统的字体回退机制,确保以最佳的字体渲染相应的字符集)。大小就用 Windows 默认的 9pt,约 12px。

Suspend/Resume/PerformLayout() 方法

如果你好奇打开过设计器生成的源文件,那么在 InitializeComponent() 方法中,你一定见过这三个方法必定会先后出现。

1
2
3
4
5
6
7
8
this.SuspendLayout();

// 初始化控件实例

// 设置控件属性

this.ResumeLayout(false);
this.PerformLayout();

那么这三个方法有什么用呢?直译的话就是 挂起/恢复/执行布局。通俗的讲就是好比你做饭,你是打算做完一盘菜就开始吃饭然后洗碗接着再做一盘菜然后开吃然后洗碗,还是把所有菜都做好再吃饭,吃完饭菜再洗碗?因此 SuspendLayout() 的作用就是告诉所在容器 (窗体、Panel 等) 你要开始做饭 (添加控件) 了,让其从现在起处于挂起 (可以理解为等待、待机) 状态,不要边做边吃 (立即将每行代码响应在 UI 上);然后就是 ResumeLayout(false) 了,相当于告诉容器你的饭菜都做好了 (控件、属性等设置完成),但还没让开吃;最后 PerformLayout() 相当于告诉容器可以开吃了 (立即开始布局)

实际上这三个方法最终目的还是将控件的 Bounds (Size、Location) 、Anchor、Dock 等属性生效 (特别是高 DPI 环境,这将有助于自动对控件进行缩放),但凡你要设置这些属性,都必须在这三个方法之间完成。不过有些其他属性是会间接影响上述属性的,比如 Text 属性,如果启用了 AutoSize,那么控件的 Size 就会去适应 Text 渲染后的 Size,相当于在设置 Size 属性,也需要在此期间完成。事件的绑定理论上来说与布局无关,在任何地方都可以进行,设计器生成的代码中事件绑定与属性设置先后紧挨着进行也是顺便的事。总之在这三个方法之间对控件进行各种操作是利大于弊的,可以放心使用。

热知识:ResumeLayout(false) + PerformLayout() = ResumeLayout(true)。也就是说,调用 ResumeLayout(true) 之后,就可以不用调用 PerformLayout() 了。

创建控件

首先我们来看看如何创建控件,基本思路就是尽可能不使用坐标,启用自适应大小,并保留常用的参数。

创建 ControlBuilder 实例类

为了统一存放这些创建控件的方法,我建议直接将其放到一个单独的实例类里面,名叫 ControlBuilder。并且为了简化调用,我推荐大家可以省略方法名的动词,比如 CreateLabel() 之类的直接命名为 Label() 就行,毕竟这样也不会发生冲突。

接下来就是创建各控件的方法了,我们需要先预设常用属性和事件 (获取这些东西的途径可以靠经验,也可以打开设计器生成的代码看看),尽可能不显式设置 Location 和 Size 属性,并打开 AutoSize。为什么说尽可能呢?这就是我的这套机制的缺点了,不过问题不大。因为只需要给第一个要排列的控件设置一个初始坐标,就可以使后续排列的控件与容器边缘有一定的距离 (留白);对于某些复杂容器的话,也推荐设置一个固定的大小 (或者必须设置宽度,高度的话后期可以做自适应,具体看情况),这也算是意料之中的事,可以接受。

你可能有疑惑,如果是在 WPF 中,可以设置控件 (应该叫 UI 元素) 的 Margin 属性来使其主动与父容器留白。但在 WinForms 其实也有这个属性,但不是预期的行为,这个属性是给具有完全自动布局的容器 (就比如上文提到的那两个 LayoutPanel) 使用的,在普通的控件里设置 Margin 是不会生效的。

1
2
3
4
public class ControlBuilder
{

}

Label

常用属性:Text、AutoSize

要注意的是,根据微软官方描述,Label 在设计器中添加时,设计器把默认把其 AutoSize 属性设置为 true,而通过代码创建的话,默认为 false。故需要我们手动将其设置为 true。

1
2
3
4
public Label Label(string text)
{
return new() { Text = text, AutoSize = true };
}

有时我们的 Label 或许还需要自动换行的特性,这个的话推荐后期封装到单独的方法中,而不是再添加一个创建 Label 的重载 (因为此时不能判断父容器),除非你确定自动换行时的宽度是固定的。但多数情况下,我们需要的应该是达到父容器宽度后就换行。此时可以在窗体/基类/派生类或者是其他地方 (作为扩展方法) 添加以下方法:

1
2
3
4
private void SetLabelAutoWrap(Label target)
{
target.MaximumSize = new(target.Parent.Width - target.Left, 0);
}

Button

常用属性:Text、AutoSize、AutoSizeMode、Enabled
常用事件:Click

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public Button Button(string text, EventHandler onClick)
{
return Button(text, true, false, onClick);
}

public Button Button(string text, bool enabled, EventHandler onClick)
{
return Button(text, enabled, false, onClick);
}

public Button Button(string text, bool enabled, bool autoSize, EventHandler onClick)
{
var ctrl = new Button() { Text = text, Enabled = enabled };

if (autoSize)
{
ctrl.AutoSize = true;
ctrl.AutoSizeMode = AutoSizeMode.GrowAndShrink;
}

ctrl.Click += onClick;
return ctrl;
}

注意,(75, 23) 是按钮的默认大小,也挺合理的,可以保持默认。

事件绑定时不需要对事件处理器进行 null 检查?—— 不需要。
1. 这些方法基本上仅我们自己使用,谁自己会闲得没事传个 null 进去?
2. 本来也没事,+= null 是合法的
3. 最多也只会对触发端造成影响导致抛空引用异常 (NullReferenceException)。因为如果 onClick 为 null 的话,直接 onClick(this, e) 确实会出错,但绝大多数情况下触发端的代码一定是这样写的 onClick?.Invoke(this, e),因此也不会有任何问题

CheckBox

常用属性:Text
常用事件:CheckedChanged

1
2
3
4
5
6
public CheckBox CheckBox(string text, EventHandler onCheckedChanged)
{
var ctrl = new CheckBox { Text = text, AutoSize = true };
ctrl.CheckedChanged += onCheckedChanged;
return ctrl;
}

注意 CheckBox 需要手动设置 AutoSize。

RadioButton

常用属性:Text
常用事件:Click

1
2
3
4
5
6
public RadioButton RadioButton(string text, EventHandler onClick)
{
var ctrl = new RadioButton { Text = text, AutoSize = true };
ctrl.Click += onClick;
return ctrl;
}

注意 RadioButton 也需要手动设置 AutoSize。

ComboBox

常用属性:DataSource、DisplayMember、ValueMember、Enabled
常用事件:SelectedIndexChanged

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public ComboBox ComboBox(int w, bool enabled, EventHandler onSelectedIndexChanged, params string[] strings)
{
var ctrl = new ComboBox() { Enabled = enabled };
ctrl.SetBounds(0, 0, w, 23);

var dataLength = strings.Length;
var data = new ComboData[dataLength];

for (int i = 0; i < dataLength; i++)
{
data[i] = new(strings[i], i);
}

ctrl.DataSource = data;
ctrl.DisplayMember = nameof(ComboData.Display);
ctrl.ValueMember = nameof(ComboData.Value);
ctrl.SelectedIndexChanged += onSelectedIndexChanged;
return ctrl;
}

ComboBox 在这里需要手动指定宽度,高度 23 是设计器默认高度,可以保持默认。string[] 表示下拉列表的各项。此处还封装了将 string[] 转换为 ComboData[] 的功能,用于自动填充索引。ComboData 是一个自定义的 ComboBox 的数据源类型,包含了下拉菜单显示的文字以及后台的 “值”,便于保存配置。这里以 int 充当索引为例,以下是 ComboData 的实体类,请根据实际需求决定值的类型。

1
2
3
4
5
public class ComboData(string display, int value)
{
public string Display { get; } = display;
public int Value { get; } = value;
}

TextBox

常用属性:MaxLength
常用事件:TextChanged

1
2
3
4
5
6
7
8
public TextBox TextBox(int w, EventHandler onTextChanged)
{
var ctrl = new TextBox();
ctrl.SetBounds(0, 0, w, 23);
ctrl.MaxLength = <你想要的值>;
ctrl.TextChanged += onTextChanged;
return ctrl;
}

TextBox 在这里需要手动指定宽度,高度 23 是设计器默认高度,可以保持默认。有时你或许需要限定 TextBox 的最大字符数,若在应用程序各个地方都固定不变的话,可以不提供为方法参数,根据具体需求来决定。

GroupBox

常用属性:Text、Controls

1
2
3
4
5
6
7
public PlainGroupBox GroupBox(string text, Control[] controls)
{
var ctrl = new PlainGroupBox() { Text = text };
ctrl.SetBounds(6, 6, 323, 0);
ctrl.Controls.AddRange(controls);
return ctrl;
}

这里的 GroupBox 具有初始坐标 (6, 6) 和 323 宽度,因为这在我的场景中都是不变的,可以作为默认值。Control[] 即为要包含的子控件。

(后文提到了添加控件最好用 Add) 为什么不用 Add 而是 AddRange?
因为容器也是窗体的子控件,让它内部提前开始布局也无妨,不会引起整个窗体布局提前就行。

Panel

常用属性:Controls

1
2
3
4
5
6
7
public Panel Panel(int x, int y, int w, int h, Control[] controls)
{
var ctrl = new Panel();
ctrl.SetBounds(x, y, w, h);
ctrl.Controls.AddRange(controls);
return ctrl;
}

和 GroupBox 类似,就不多介绍了。

万能模板

如果你觉得上述方法没有出现你需要的控件,除了自行编写新方法以外,还可以使用 “万能模板” 来应付那些只需要一次的控件。

1
2
3
4
5
6
7
public TControl New<TControl>(int w, int h, string text)
where TControl : Control, new()
{
var ctrl = new TControl() { Text = text };
ctrl.SetBounds(0, 0, w, h);
return ctrl;
}

比如可以用来创建一个进度条控件 (ProgressBar):

1
New<ProgressBar>(344, 22, null)

如果需要特殊的属性也不用担心,也并不需要写在 New 方法里,可以用扩展方法:

1
New<ProgressBar>(344, 22, null).With(c => c.Style = ProgressBarStyle.Continuous)

这样就可以创建一个样式为 Continuous 的进度条,有关该扩展方法详见下方 编辑现有控件

添加控件

创建 Extensions 静态类

创建一个名为 Extensions 的静态类可用于存放一些好用的扩展方法,这样可以方便开发,减少代码重复,提高可复用性。

1
2
3
4
public static class Extensions
{

}

添加控件

首先可以写一个用于构建 Control[] 数组并向目标控件添加子控件的扩展方法,以后就可以在窗体构造函数里调用了,取代原来的 InitializeComponent():

1
2
3
4
5
6
7
8
9
10
public static void AddControls(this Control ctrl, Func<ControlBuilder, Control[]> builder)
{
var ctrls = builder?.Invoke(new());
var collection = ctrl.Controls;

for (int i = 0; i < ctrls.Length; i++)
{
collection.Add(ctrls[i]);
}
}

为什么不用 Controls.AddRange,一次性传完整个 Control[] 数组?
因为 Add 方法内部最终会调用 ResumeLayout(false),而 AddRange 方法内部最终调用的是 ResumeLayout(true),会导致 PerformLayout 提前,故不推荐。

为什么不用 foreach 循环
有关循环方式的选择方面的问题我自己也无法准确回答,只能说凭感觉 (大致方向就是需要索引就 for,反之 foreach),但大多数情况下两种循环方式在 遍历数组 时的性能几乎一样,另外 WinForms 源码在 AddRange 里也用的是 for 循环。有关更多信息的话如果不嫌弃可以参考我写的 【C#】for 和 foreach 循环的选择 (仅个人观点)。

为什么要用 Func 委托?
Func 是具有返回值的委托,这里用它来提供 ControlBuilder 的方法来创建控件并返回 Control[] 数组拿到控件集合。(Func 委托在 (后续) 使用 Builder 模式让右键菜单的创建更上一层楼 中提到过,感兴趣的可以去看看)

为什么该扩展方法基于 Control 而不是基于 Form?
的确,在本文中,只会向 Form 添加子控件,其他容器的话 ControlBuilder 有专门的方法,但为了某种约定俗成,还是优先选择 Control,这个就看个人喜好了

由于我们取代了 InitializeComponent,其中对窗体的某些设置也没了,所以我们需要手动编写以下必要属性并挂起布局,其他属性的话根据情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
public Form1()
{
SuspendLayout();
AutoScaleDimensions = new(96F, 96F);
AutoScaleMode = AutoScaleMode.Dpi;
AutoSize = true;
AutoSizeMode = AutoSizeMode.GrowAndShrink;
Font = new("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point, 0);

this.AddControl(/* ... */);

ResumeLayout(true);
}

编辑现有控件

如果你觉得 ControlBuilder 提供的方法预设不满足一些特殊情况 (需要额外的属性、事件等),但又不想为单一的特殊情况专门写一个重载的话,可以添加以下扩展方法:

1
2
3
4
5
6
public static TControl With<TControl>(this TControl control, Action<TControl> additions)
where TControl : Control
{
additions?.Invoke(control);
return control;
}

为什么返回值要用泛型 TControl,直接用 Control 不行吗?
Control 是各种控件的基类,代表了几乎所有的控件。但 Control 和 各控件 (比如 Label 等) 是相对独立的,并不是同一个类,因此直接使用 Control 的话在传入时会进行一次隐式转换 (Label -> Control)。并且返回时应当返回具体控件类型而不是 Control,这里要达到预期的话又要进行一次显式转换 (Control -> Label),将出现些许性能开销。故使用 TControl 泛型并约束为 Control 可以让 C# 直接推断出控件具体类型并只对派生自 Control 的类型显示扩展方法,同时也能确保拿到该控件特有的属性。

为什么用 Action<> 而不是 Action 或 Func<>?
与 Func 相反,Action 是没有返回值的委托,这里我们目的只是为了传入控件实例并在 Lambda 表达式里获取到其实例,然后执行修改属性或者绑定事件等操作,无需返回值,且用 Action 的话不能直接访问到控件实例哦,你可以试试,故应该用 Action<>

最终调用时的效果可以看看:PlainCEETimer/SettingsForm.cs: 142~144 行

进行布局

有关布局的方法推荐放在你的应用程序的所有窗体的基类中。

布局的方法在前面简单提过了,主要就是灵活的运用控件的 Left、Top、Width、Height 等属性,唯一的缺点就是每个方法只能对一个控件进行布局,参考的控件可能会有一个或两个,但也能接受。在开始之前,我们首先要清楚,第一,WinForms 控件不显式指定坐标的话,默认坐标为 (0, 0),这意味着所有控件在添加完成后全部挤在了左上角,因此我们需要进行相对布局;第二,这样的布局方式不进行微调的话控件可能不会按照预期的那样对齐,特指不同类型控件之间的布局,主要也是因为 WinForms 不同控件的最小最适的高度是不一样的,不过微调之后都可以完美的对齐。废话不多说,直接进入正题。

获取 DPI 值

首先我们最好要考虑高 DPI 的场景,因为微调的值必须手动进行缩放,Bounds 等属性将由 WinForms 自动完成缩放。获取 DPI 的方式有很多,你可以调用 Win32 API GetDpiForWindow/System,也可以创建一个 Graphics 访问其 DpiX 属性,但前者只能在 Windows 10 1607 之后才生效,如果你的应用程序需要兼容 Windows 7 或其他系统的话,还是用后者比较保险。另外你或许也听说过 DeviceDpi 属性,这个的话我测试了很多次获取到的始终是 96,如果你测试没问题的话那就用它也可以。

这里就以第二种方法为例,首先我们重写窗体的 OnHandleCreated 方法 (大致相当于订阅 HandleCreated 事件),因为创建 Graphics 对象需要窗口句柄,在此方法触发时获取 DPI 是最安全的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private float CurrentDpiRatio;
private bool IsHighDpi;

protected override void OnHandleCreated(EventArgs e)
{
if (CurrentDpiRatio == 0F) // 防止重复查询 DPI 值,因为我们的应用程序最好使用 System aware 的 DPI 感知
// ,如果你要用 Per-Monitor (V2) 的话,可以不用判断该条件。
{
var g = CreateGraphics();
DpiRatio = g.DpiX / 96F;
IsHighDpi = DpiRatio > 1F;
g.Dispose();
}

base.OnHandleCreated(e);
}

要注意的是 DpiX 是当前窗体所在屏幕的 DPI 值,我们在后面用的时候主要使用 DPI 比值来缩放一些数值,这个比值是相对于 100% (96 DPI) 的,故这里需要除以 96,F 表示单精度浮点数 (float)。创建完 Graphics 后建议显式 Dispose 它。

现在我们获取到了 DpiRatio 就可以编写一个方法用于缩放微调的数值了:

1
2
3
4
protected int ScaleToDpi(int px)
{
return (int)(px * CurrentDpiRatio);
}

这个方法后面会用到。

开始布局虚方法

推荐写一个虚方法 (抽象方法也行,根据具体情况) 在基类里,用于子类重写并在此调用各种布局方法。

1
2
3
4
protected virtual void StartLayout(bool isHighDpi)
{
// 不用实现,保持空就行
}

StartLayout 自己也得有个执行时机吧?应该在什么时候执行呢?我推荐在 OnLoad 里,注意需要挂起布局。

1
2
3
4
5
6
7
protected sealed override void OnLoad(EventArgs e)
{
SuspendLayout();
StartLayout(IsHighDpi);
ResumeLayout(true);
base.OnLoad(e);
}

将第一个控件放置到容器

也就是放置第一个控件,不然默认坐标是 (0, 0),这样离容器边缘太近了,也对后期的布局产生印象,我建议使用 (3, 3),或者是 (6, 3)。加上 x, y 可选参数以便特殊情况调整。

1
2
3
4
protected void ArrangeFirstControl(Control control, int x = 3, int y = 3)
{
control.SetBounds(x, y, 0, 0, BoundsSpecified.Location);
}

一般水平排列

打开你的空间思维,打不开的话就画在草稿纸上。现在我们要把第二个控件 (c2) 放在第一个控件 (上面提到的那个) (c1) 的右侧,且两者不能重叠,顶部也要对齐。于是聪明的你就可以得出,c2 的 X 坐标应该为 c1 的 X 加上 c1 的宽度 (WinForms 控件自带间隙,不用担心它们贴的太近了),简化一下,本文开头就提到过,对于同一个控件,X 加上宽度就等于 Right。综上所述,c2.X = c1.Right。现在来处理 Y 坐标,我们要求 c2 的顶部与 c1 对齐,这就更简单了,直接让它等于 c1 的 Y,也就是 Top (也提到过,忘了快去看看)。于是 c2.Y = c1.Top。

恭喜你,你制作出了第一个布局方法!我们可以给他命名为 ArrangeControlXT (排列 控件 X轴 Top对齐),同时加上偏移参数,以便进行微调,因为不同的控件高度或宽度是不一样的,有时会出现顶部并不对齐或者有重叠的情况,别忘了要乘以 DPI 哦。

1
2
3
4
5
6
7
/// <summary>
/// 参考指定控件,在 X 方向上水平排列目标控件,并在 Y 方向上与指定控件的上边缘对齐
/// </summary>
protected void ArrangeControlXT(Control target, Control reference, int xOffset = 0, int yOffset = 0)
{
target.SetBounds(reference.Right + ScaleToDpi(xOffset), reference.Top + ScaleToDpi(yOffset), 0, 0, BoundsSpecified.Location);
}

特殊水平排列

我们来进阶一下,因为有时仅靠 ArrangeControlXT 是不够用的,这里就以我遇到的情况为例:要求目标控件 (t) 与 c1 左边缘对齐,并与 c2 顶部对齐。同样打开思维,你就会得出 t.X = c1.Left,t.Y = c2.Top。

我们将它命名为 ArrangeControlXLT (排列 控件 X轴 左对齐 上对齐),同样加上偏移参数。

1
2
3
4
5
6
7
/// <summary>
/// 参考指定控件,在 X 方向上水平排列目标控件,与 <paramref name="reference1"/> 左边缘对齐,并在 Y 方向上与 <paramref name="reference2"/> 上边缘对齐。
/// </summary>
protected void ArrangeControlXLT(Control target, Control reference1, Control reference2, int xOffset = 0, int yOffset = 0)
{
target.SetBounds(reference1.Left + ScaleToDpi(xOffset), reference2.Top + ScaleToDpi(yOffset), 0, 0, BoundsSpecified.Location);
}

同样的,我们也可以制造出这样的效果:t 与 c1 右边缘对齐,与 c2 上边缘对齐。如法炮制:

我们将它命名为 ArrangeControlXRT (排列 控件 X轴 右对齐 上对齐),也加上偏移参数。

1
2
3
4
5
6
7
/// <summary>
/// 参考指定控件,在 X 方向上水平排列目标控件,与 <paramref name="reference1"/> 右边缘对齐,并在 Y 方向上与 <paramref name="reference2"/> 上边缘对齐。
/// </summary>
protected void ArrangeControlXRT(Control target, Control reference1, Control reference2, int xOffset = 0, int yOffset = 0)
{
target.SetBounds(reference1.Right + ScaleToDpi(xOffset), reference2.Top + ScaleToDpi(yOffset), 0, 0, BoundsSpecified.Location);
}

竖直排列

上面我们讲完了水平排列,怎么能少竖直排列呢?来看看,现在我们要把 c2 放在 c1 的下面,并要求 c2 与 c1 左对齐。打开思维,得出 c2.X = c1.Left,c2.Y = c2.Top + c2.Height = c2.Bottom。

我们将它命名为 ArrangeControlYL (排列 控件 Y轴 左对齐)

1
2
3
4
5
6
7
/// <summary>
/// 参考指定控件,在 Y 方向上竖直排列目标控件,并在 X 方向上与指定控件的左边缘对齐
/// </summary>
protected void ArrangeControlYL(Control target, Control reference, int xOffset = 0, int yOffset = 0)
{
target.SetBounds(reference.Left + ScaleToDpi(xOffset), reference.Bottom + ScaleToDpi(yOffset), 0, 0, BoundsSpecified.Location);
}

水平对齐

注意,新概念来了,这叫 对齐,不是排列。对齐表示指更改 X 或 Y,而排列两者都要更改。有时你可能不需要直接排列,对齐一下就行了。

我们将它命名为 AlignControlXL (水平左对齐):

1
2
3
4
protected void AlignControlXL(Control target, Control reference, int xOffset = 0)
{
target.Left = reference.Left + ScaleToDpi(xOffset);
}

什么?你感觉应该叫 AlignControlYL?那我问你,Left 是什么,是不是对应 X?所以就叫 XL 没毛病。

同样,也可以制作出水平右对齐,命名为 AlignControlXR:

1
2
3
4
protected void AlignControlXR(Control target, Control reference, int xOffset = 0)
{
target.Left = reference.Right - target.Width + ScaleToDpi(xOffset);
}

竖直居中

这里才是重中之重,因为我们总不可能只排列同样的控件吧,如果是不同的控件的话,上述没有一个方法能保证竖直居中。这个方法需要与 ArrangeControlX 系列方法搭配使用,也就是先排列再居中。来打开你的思维,让你把 c2 与 c1 保持竖直居中,此时 c2 已经在 c1 后面了且上对齐,是不是只需要更改 Y 坐标就行了?假设 c2 高于 c1,… emmm,貌似有点难想象,来画个草图分析分析:

问题就在于这里的长度怎么确定?一旦确定,我们就能得出 c2 的 Y 坐标。

聪明的你一下就发现了,这玩意儿不就是 c2 与 c1 的高度差的一半嘛?

于是你得出:c2.Y = c1.Y + (c2.H - c1.H) / 2

我们将它命名为 CenterControlY:

1
2
3
4
protected void CenterControlY(Control target, Control reference, int yOffset = 0)
{
target.Top = reference.Top + (target.Height - reference.Height) / 2 + ScaleToDpi(yOffset);
}

水平紧凑

同样的,我们有时发现在水平排列时,只需要把 c2 放在 c1 后面就行了,不用管 Y 坐标。于是你得出:c2.X = c1.X + c1.W = c1.R

我们将它命名为 CompactControlX

1
2
3
4
protected void CompactControlX(Control target, Control reference, int xOffset = 0)
{
target.Left = reference.Right + ScaleToDpi(xOffset);
}

竖直紧凑

有水平紧凑那必然有竖直紧凑,现将 c2 放在 c1 下面就行,不用管 X 坐标。于是你得出:c2.Y = c1.Y + c1.H = c1.B

我们将它命名为 CompactControlY

1
2
3
4
protected void CompactControlY(Control target, Control reference, int yOffset = 0)
{
target.Top = reference.Bottom + ScaleToDpi(yOffset);
}

GroupBox 中的特殊情况

你知道吗,像 GroupBox 这样的容器有着自己独立的一套坐标系,也就是 (0, 0) 在它们自己的左上角而不是在上级容器的左上角。

但是,在 GroupBox 中,(0, 0) 并不是你想的位置。你会发现 (0, 0) 在其边框之外,也就是此时将一个 Label 放在 (0, 0) 的位置,它正好与 GroupBox 的标题文字重合。但我们希望它出现在标题文字的下方,也就是边框以内,怎么办呢?

聪明的你又双叒叕想到了,如果能获取标题文字的高度不就行了吗?但怎么获取?

控件是不是都有 Font 属性来存储当前控件的字体?一般情况下,若无专门设置,所有的控件均继承自窗体的字体。而窗体的 Font 里面是不是可以拿到 Height 属性?因此我们可以在一开始,即窗体启动的时候,就获取 Font.Height 并缓存下来,避免后续多次访问 Font.Height。

但此时你可能想到了一个 “更好” 的方法,我们不是在前文说过字体大小默认为 9pt 吗?直接用就行,理论上可以,但是第一,pt 是 磅 (point),不是 px,像素 (pixel),而坐标布局应该使用 px,所以你还要进行单位换算;第二,高 DPI 怎么办,此时就不是 9pt 了。所以建议大家从 Font 里直接拿它的 Height:

1
2
3
4
5
6
private int FontHeight;

protected override void OnLoad(EventArgs e)
{
FontHeight = Font.Height;
}

现在我们确定了 Y 坐标,即字体高度,那 X 坐标呢?在我看来在 GroupBox 中,空出 6px 间隙最好。

于是我们可以制作出适用于 GroupBox 的 “将第一个控件放置到容器” 的专属方法,我们将它命名为:GroupBoxArrageFirstControl

1
2
3
4
protected void GroupBoxArrageFirstControl(Control target, int xOffset = 0, int yOffset = 0)
{
target.SetBounds(6 + ScaleToDpi(xOffset), FontHeight + ScaleToDpi(yOffset), 0, 0, BoundsSpecified.Location);
}

当然,GroupBox 还得具有自适应高度的功能,怎么实现?一个一个累加在竖直方向上的所有控件的高度?大戳特戳!开启 AutoSize?也戳!因为这样宽度也自适应了。

是不是只需要获取在竖直方向上的最后一个控件的 Y 和 H 就行了?也就是 GroupBox.H = c.Y + c.H = c.B。

我们将它命名为:GroupBoxAutoAdjustHeight

1
2
3
4
protected void GroupBoxAutoAdjustHeight(GroupBox groupBox, Control yLast, int yOffset = 0)
{
groupBox.Height = yLast.Bottom + ScaleToDpi(yOffset);
}

效果

这下写起来就很爽了,根本不用设计器。

总结

  • 省去了所有设计器生成的代码并改用新方法后,PlainCEETimer 的主程序体积降低了 13KB (9%, 142KB -> 129KB)。
  • ControlBuilder 的一系列方法基本上可以满足大多数情况下的需求,如果需要创建其他控件的话,可以自行进行扩展。或者说使用 New 方法或直接在 AddControls 方法里传入控件实例,不依赖 ControlBuilder。比如在 PlainCEETimer/ListViewDialog.cs: 第 90 行 创建 ListView 就没有使用 ControlBuilder。
  • 各种有关布局的方法目前只适用于窗体大小固定的场景下,其他场景的话可以自行编写更多方法,注意要使用 Anchor 固定控件等。

链接


可定制性极高! 不用设计器,教你纯靠代码添加控件并完成布局
https://wanghaonie.github.io/posts/fa75e0e82b60/
作者
WangHaonie
发布于
2025-06-18 21:28:01
更新于
2025-07-26 11:32:45
许可协议