[Delphi] VCL 无边框窗口:自绘标题栏、缩放命中与 DWM 阴影

VCL 无边框窗口:自绘标题栏、缩放命中与 DWM 阴影

VCL 的 bsNone 窗体可以很容易做成“无边框”外观,但要同时保留系统阴影、系统拖拽行为和可靠的缩放,关键不在于手工调整客户区,而在于明确区分三个区域的所有权:

  • Windows 的非客户区样式负责可缩放能力与 DWM 阴影;
  • VCL 客户区负责绘制自定义标题栏与内容;
  • WM_NCHITTEST 只负责告诉系统鼠标所在区域的语义。

image

下面给出一个最小实现。它适用于 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 的客户区绘制竞争。

更稳妥的做法是:

  1. 保持 WS_THICKFRAME | WS_CAPTION;
  2. 显式启用 DWMWA_NCRENDERING_POLICY;
  3. 仅保留 1px 顶部扩展以触发 DWM 阴影;
  4. 自绘标题栏仍完整位于 VCL 客户区。

不同 Windows 版本、主题和 DPI 设置对 DWM 的细节略有差异。发布前至少应测试普通窗口、最大化、最大化恢复、四边缩放、多显示器切换和高 DPI 场景。

7. 两个容易忽略的细节

  • 命中测试的坐标是屏幕坐标,因此应使用 GetWindowRect,不要直接以控件的局部坐标判断。
  • 如果客户区中放置了会覆盖整块区域的原生子 HWND,应在边缘保留很小的命中带,或让子窗口把 WM_NCHITTEST 转发给父窗体;不要为此额外叠加大面积透明原生窗口。

这个模式将系统窗口行为和应用绘制严格分开:系统负责外框语义与阴影,VCL 负责可见标题栏与客户区内容,最终能获得接近原生窗口的交互体验。

posted @ 2026-09-24 16:58  我爱我家喵喵  阅读(29)  评论(0)    收藏  举报