[Delphi] VCL 无边框窗口:自绘标题栏、缩放命中与 DWM 阴影
VCL 无边框窗口:自绘标题栏、缩放命中与 DWM 阴影
VCL 的 bsNone 窗体可以很容易做成“无边框”外观,但要同时保留系统阴影、系统拖拽行为和可靠的缩放,关键不在于手工调整客户区,而在于明确区分三个区域的所有权:
- Windows 的非客户区样式负责可缩放能力与 DWM 阴影;
- VCL 客户区负责绘制自定义标题栏与内容;
WM_NCHITTEST只负责告诉系统鼠标所在区域的语义。

下面给出一个最小实现。它适用于 Delphi 10.4+;旧版 Delphi 只需把 Winapi.* 单元名改为对应的非命名空间单元名。
1. 设计原则
无边框不等于不需要窗口样式。窗体外观设为 bsNone 后,仍应为原生 HWND 保留:
WS_THICKFRAME or WS_CAPTION or WS_SYSMENU
其中 WS_THICKFRAME 让 Windows 保留原生缩放语义,WS_CAPTION 让 DWM 将窗口识别为可绘制非客户区的顶级窗口,WS_SYSMENU 保留标准窗口行为。标准标题栏本身不会显示,因为后续由 WM_NCCALCSIZE 将窗口区域交给客户区绘制。
不要把系统标题栏的完整高度再通过 DwmExtendFrameIntoClientArea 扩展进客户端。这样会让 DWM 和自绘标题栏同时拥有同一段顶部区域;在实时缩放时,合成器缓存与客户区重绘可能短暂不同步,表现为残影或“幽灵窗口”。
2. 最小窗体代码
DFM 中设置:
object BorderlessForm: TBorderlessForm
BorderStyle = bsNone
Position = poScreenCenter
end
窗体类只需处理样式、最大化范围、非客户区计算、命中测试和 DWM:
unit uBorderlessForm;
interface
uses
Winapi.Windows, Winapi.Messages, Winapi.DwmApi, Winapi.UxTheme,
System.SysUtils, System.Classes, Vcl.Controls, Vcl.Forms;
type
TBorderlessForm = class(TForm)
private
procedure ApplyDwmShadow;
procedure WMNCCalcSize(var Msg: TWMNCCalcSize); message WM_NCCALCSIZE;
procedure WMGetMinMaxInfo(var Msg: TWMGetMinMaxInfo); message WM_GETMINMAXINFO;
procedure WMNCHitTest(var Msg: TWMNCHitTest); message WM_NCHITTEST;
protected
procedure CreateParams(var Params: TCreateParams); override;
procedure CreateWnd; override;
end;
implementation
const
ResizeGrip = 8;
TitleBarHeight = 32;
procedure TBorderlessForm.CreateParams(var Params: TCreateParams);
begin
inherited CreateParams(Params);
Params.Style := Params.Style or WS_THICKFRAME or WS_CAPTION or WS_SYSMENU;
end;
procedure TBorderlessForm.CreateWnd;
begin
inherited CreateWnd;
ApplyDwmShadow;
end;
procedure TBorderlessForm.WMGetMinMaxInfo(var Msg: TWMGetMinMaxInfo);
var
MonitorInfo: TMonitorInfo;
Monitor: HMONITOR;
begin
Monitor := MonitorFromWindow(Handle, MONITOR_DEFAULTTONEAREST);
MonitorInfo.cbSize := SizeOf(MonitorInfo);
if GetMonitorInfo(Monitor, @MonitorInfo) then
begin
Msg.MinMaxInfo.ptMaxPosition.X :=
MonitorInfo.rcWork.Left - MonitorInfo.rcMonitor.Left;
Msg.MinMaxInfo.ptMaxPosition.Y :=
MonitorInfo.rcWork.Top - MonitorInfo.rcMonitor.Top;
Msg.MinMaxInfo.ptMaxSize.X :=
MonitorInfo.rcWork.Right - MonitorInfo.rcWork.Left;
Msg.MinMaxInfo.ptMaxSize.Y :=
MonitorInfo.rcWork.Bottom - MonitorInfo.rcWork.Top;
end;
end;
procedure TBorderlessForm.WMNCCalcSize(var Msg: TWMNCCalcSize);
var
MonitorInfo: TMonitorInfo;
begin
// 不调用默认非客户区计算:整个窗口改由客户端绘制。
if (Msg.CalcValidRects and IsZoomed(Handle)) then
begin
MonitorInfo.cbSize := SizeOf(MonitorInfo);
if GetMonitorInfo(MonitorFromWindow(Handle, MONITOR_DEFAULTTONEAREST),
@MonitorInfo) then
Msg.CalcSize_Params^.rgrc[0] := MonitorInfo.rcWork;
end;
Msg.Result := 0;
end;
procedure TBorderlessForm.WMNCHitTest(var Msg: TWMNCHitTest);
var
HitPoint: TPoint;
WindowRect: TRect;
begin
HitPoint := Point(Msg.XPos, Msg.YPos);
GetWindowRect(Handle, WindowRect);
// 最大化状态绝不能返回 HTLEFT、HTBOTTOM 等缩放命中。
if IsZoomed(Handle) then
begin
if HitPoint.Y - WindowRect.Top < TitleBarHeight then
Msg.Result := HTCAPTION
else
Msg.Result := HTCLIENT;
Exit;
end;
if HitPoint.Y - WindowRect.Top < ResizeGrip then
begin
if HitPoint.X - WindowRect.Left < ResizeGrip then
Msg.Result := HTTOPLEFT
else if WindowRect.Right - HitPoint.X < ResizeGrip then
Msg.Result := HTTOPRIGHT
else
Msg.Result := HTTOP;
end
else if WindowRect.Bottom - HitPoint.Y < ResizeGrip then
begin
if HitPoint.X - WindowRect.Left < ResizeGrip then
Msg.Result := HTBOTTOMLEFT
else if WindowRect.Right - HitPoint.X < ResizeGrip then
Msg.Result := HTBOTTOMRIGHT
else
Msg.Result := HTBOTTOM;
end
else if HitPoint.X - WindowRect.Left < ResizeGrip then
Msg.Result := HTLEFT
else if WindowRect.Right - HitPoint.X < ResizeGrip then
Msg.Result := HTRIGHT
else if HitPoint.Y - WindowRect.Top < TitleBarHeight then
Msg.Result := HTCAPTION
else
Msg.Result := HTCLIENT;
end;
procedure TBorderlessForm.ApplyDwmShadow;
var
CompositionEnabled: BOOL;
Margins: UxTheme.TMargins;
NCPolicy: Integer;
begin
if not (Succeeded(DwmIsCompositionEnabled(CompositionEnabled)) and
CompositionEnabled) then
Exit;
// 单独请求 DWM 非客户区渲染,用于系统阴影。
NCPolicy := DWMNCRP_ENABLED;
DwmSetWindowAttribute(Handle, DWMWA_NCRENDERING_POLICY, @NCPolicy,
SizeOf(NCPolicy));
// 1px 只作为 DWM 阴影触发,不把系统标题栏扩展进自绘客户区。
Margins.cxLeftWidth := 0;
Margins.cxRightWidth := 0;
Margins.cyTopHeight := 1;
Margins.cyBottomHeight := 0;
DwmExtendFrameIntoClientArea(Handle, Margins);
SetWindowPos(Handle, 0, 0, 0, 0, 0,
SWP_FRAMECHANGED or SWP_NOMOVE or SWP_NOSIZE or SWP_NOZORDER or
SWP_NOACTIVATE);
end;
end.
3. 自绘标题栏如何发起拖拽
标题栏可以是一个 TPanel 或任何 VCL 控件,设置 Align = alTop、高度为 TitleBarHeight。在其 OnMouseDown 中把拖拽交还给系统:
procedure TBorderlessForm.TitleBarMouseDown(Sender: TObject;
Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
begin
if Button <> mbLeft then
Exit;
ReleaseCapture;
SendMessage(Handle, WM_NCLBUTTONDOWN, HTCAPTION, 0);
end;
这样窗口移动、贴靠、最大化恢复等行为仍由 Windows 处理,不需要自行计算鼠标偏移或屏幕边界。
4. 为什么 WM_GETMINMAXINFO 不能省略
WM_NCCALCSIZE 解决的是“窗口的哪一部分属于客户端”,并不决定最大化后窗口应该占用哪一块屏幕。带自定义非客户区的窗体应在 WM_GETMINMAXINFO 中,以当前显示器的 rcWork 明确设置:
ptMaxPosition:工作区相对监视器左上角的偏移;ptMaxSize:工作区的宽高。
这会正确避开任务栏、停靠栏,并适用于任务栏不在主屏幕底部及多显示器场景。该处理还应与 WM_NCCALCSIZE 中最大化时使用的同一个 rcWork 保持一致。
5. 为什么最大化时必须禁用缩放命中
即使窗口处于最大化状态,WS_THICKFRAME 仍然存在。如果命中测试继续返回 HTTOP、HTRIGHT 或角落命中,系统会尝试进入缩放循环;最大化窗口随后可能以异常矩形恢复或出现布局错误。
因此最大化分支应只返回:
- 标题栏区域:
HTCAPTION; - 其他客户区:
HTCLIENT。
不要在最大化状态返回任意 HT*LEFT、HT*RIGHT、HTTOP 或 HTBOTTOM。
6. DWM 阴影与残影的平衡
DwmExtendFrameIntoClientArea 的边距有两层含义:它既影响 DWM 接管的客户区范围,也可能影响系统是否为自绘窗口提供阴影。
常见但不稳定的做法是把 SM_CYCAPTION 作为 cyTopHeight。这等于把一整段自绘标题栏交给 DWM,实时缩放时很容易与 VCL 的客户区绘制竞争。
更稳妥的做法是:
- 保持
WS_THICKFRAME | WS_CAPTION; - 显式启用
DWMWA_NCRENDERING_POLICY; - 仅保留 1px 顶部扩展以触发 DWM 阴影;
- 自绘标题栏仍完整位于 VCL 客户区。
不同 Windows 版本、主题和 DPI 设置对 DWM 的细节略有差异。发布前至少应测试普通窗口、最大化、最大化恢复、四边缩放、多显示器切换和高 DPI 场景。
7. 两个容易忽略的细节
- 命中测试的坐标是屏幕坐标,因此应使用
GetWindowRect,不要直接以控件的局部坐标判断。 - 如果客户区中放置了会覆盖整块区域的原生子 HWND,应在边缘保留很小的命中带,或让子窗口把
WM_NCHITTEST转发给父窗体;不要为此额外叠加大面积透明原生窗口。
这个模式将系统窗口行为和应用绘制严格分开:系统负责外框语义与阴影,VCL 负责可见标题栏与客户区内容,最终能获得接近原生窗口的交互体验。

浙公网安备 33010602011771号