Duilib 做的是「把系统标题栏去掉,自己画一个更好看的」。 代价是系统免费给你做了二十年的活,现在要自己写一遍。
上一节用 MFC 的原生窗口,这一节换成一个「无边框、可换肤」的界面库。
播放能力一点没变——还是那句 set_hwnd。变的全是「窗口长什么样、怎么被拖动」。
一、为什么要无边框
系统标题栏有两个问题:一是丑(灰底、方角、老式按钮),二是不可定制(没法做圆角、 没法做半透明、没法放自定义按钮)。
要去掉它,把窗口样式改成 WS_POPUP,然后自己画一个「假标题栏」——上面放最小化、最大化、
关闭三个按钮,都是你自己画的图。
代价立刻出现三条: 拖动要自己算(系统不再处理标题栏拖动)。 缩放要自己算(系统不再处理边框拖动)。 贴边、双击最大化、右键系统菜单全都没了,要做就自己做。
所以「无边框」不是免费的:它把「外观自由度」换成了「一堆必须自己实现的细节」。 界面库里这些都已经写好了,但你要知道它们存在。
二、拖动的坐标算法
算法只有三行,但很容易写错:
// 按下时:记住鼠标相对窗口左上角的偏移
void OnLButtonDown(POINT pt)
{
m_dragOffset = pt - m_winPos;
}
// 移动时:窗口左上角 = 当前鼠标 - 偏移
void OnMouseMove(POINT pt)
{
if (m_dragging)
MoveWindow(pt.x - m_dragOffset.x,
pt.y - m_dragOffset.y);
}
关键在「偏移只在按下时算一次」。 如果每次移动都重算偏移,窗口会「粘在鼠标上」——鼠标永远停在窗口左上角,窗口像被吸住一样跟着跑。
demo 里把四步坐标都算了一遍:按下时窗口在 (400,300)、鼠标在 (520,340),偏移就是 (120,40), 之后无论移到哪儿,偏移都不变。
三、命中测试与缩放
无边框窗口要自己判断「鼠标点在哪儿」,因为系统不再帮你区分「标题栏」与「边框」。
判断逻辑:取鼠标位置, 看它落在窗口的哪一个区域——边缘 N 像素算「边框」(要缩放),顶部 M 像素算「标题栏」(要拖动),其余算「客户区」(交给下面的控件)。
这个判断函数在 Windows 里叫 WM_NCHITTEST 的返回值:返回 HTCAPTION 系统就帮你拖动,
返回 HTLEFT 就帮你缩放。这是最省事的做法——不用自己写拖动循环,直接让系统干。
但要注意:一旦你把「客户区」也返回成 HTCAPTION,那个区域里的按钮就点不动了。
所以要精确划分区域,宁可多写几行判断。
四、渲染区怎么摆
无边框界面里,VLC 的画面该放在哪一层? 答案是「一个独立的子窗口」,不是「画在皮肤上」。
原因跟上一节一样:VLC 会每帧重绘它的整个客户区, 如果让它画在皮肤上,皮肤上的按钮、文字全都会被盖掉。
所以正确的层级是: 顶层是无边框窗口(自己画的皮肤)。 中间放一个子窗口当「画布」(它的句柄交给 VLC)。 控件放在子窗口之外(或者用独立的透明浮层)。
这个结构在 Duilib 里对应一个 Control,
但要注意:Duilib 的控件默认是「自绘」的,没有独立窗口句柄。
所以要么给它设 bkcolor 之外的特殊属性、要么自建一个子窗口。
这是 Duilib + LibVLC 组合最常见的卡点。
五、高 DPI 的坑
150% 缩放下,一个「逻辑单位」是 1.5 个物理像素。 如果你的坐标计算混了这两种单位,拖动就会「飘」。
具体表现:鼠标移动 100 个物理像素,窗口只移动 67 个——手感发涩; 或者移动 150 个——窗口跑得比鼠标快。
解决办法是「全程用一种单位」:要么全用物理像素(GetCursorPos 给你的就是物理像素),
要么全用逻辑单位(ScreenToClient 之后再换算)。混用必错。
还有一个相关的坑:
没有声明 DPI 感知的程序,会被系统「虚拟化」——系统把你的窗口按 96 DPI 渲染再放大,
结果整个界面发虚。所以要显式声明(清单文件里加 dpiAware 或 dpiAwareness)。
六、动手 Demo:无边框窗口,全靠自己算坐标
这段把「按下 → 移动三次」的完整坐标序列算一遍。 重点看最后一列:偏移自始至终不变。
win = [400, 300]
down = (520, 340)
off = (down[0] - win[0], down[1] - win[1]) # 只算这一次
for mx, my in ((560, 360), (700, 480), (900, 620)):
win = [mx - off[0], my - off[1]]
T([u"移到 (%d, %d)" % (mx, my), u"", u"(%d, %d)" % tuple(win),
u"(%d, %d)" % off], [0, 0, 0, 0])
实跑输出:
--------------------------------------------------------------------
无边框窗口,全靠自己算坐标
--------------------------------------------------------------------
**Duilib 这类界面库做的是「把标题栏去掉,自己画一个」。**
**于是「拖动窗口」这件事就得自己算。**
**拖动的算法只有三行**:
按下时记住「鼠标相对窗口左上角的偏移」。
移动时算「当前鼠标位置减这个偏移」,就是窗口新的左上角。
松开时清掉记录。
**真算一遍**:
时刻 鼠标 窗口左上角 偏移
---------------------------------------------------------------
按下 (520, 340) (400, 300) (120, 40)
移到 (560, 360) (560, 360) (440, 320) (120, 40)
移到 (700, 480) (700, 480) (580, 440) (120, 40)
移到 (900, 620) (900, 620) (780, 580) (120, 40)
**这个偏移量必须在「按下」时算一次,之后不能重算。**
**每次移动都重算的话,窗口会粘在鼠标上,鼠标永远停在左上角。**
**还有两件必须自己处理的事**:
**命中测试**:窗口边缘那几像素要留给「拉伸」,中间才是「拖动」。
高 DPI 缩放:150% 缩放下,一个逻辑单位是 1.5 个物理像素,
**坐标要跟屏幕坐标换算,否则拖起来会「飘」。**
**这两件事正是「无边框」比「有边框」贵的地方**——系统标题栏免费帮你做了二十年的活,
现在要自己写一遍。
偏移 (120, 40) 一直没变,窗口左上角跟着鼠标走,这就是「拖动手感自然」的定义: 鼠标按住窗口的哪个点,那个点就一直跟着鼠标。
如果偏移每次重算会怎样:off = 鼠标 - 窗口,而窗口刚刚被设成 鼠标 - off,
代入就是 off = 0——窗口左上角永远等于鼠标位置,看起来像「窗口被吸到鼠标上」。
这个 bug 在无边框界面的项目里非常常见,因为「重算」写起来更顺手(不用存状态)。 但它错在「把一个常量当成了变量」。
30 秒实验
- 想一想:为什么「双击标题栏最大化」在无边框窗口里要自己实现?
- 如果窗口有圆角,命中测试该怎么处理四个角?
- 在 150% 缩放下,鼠标移动 100 像素,窗口该移动多少?
常见误区
- 每次移动都重算拖动偏移。 结果偏移恒为 0,窗口会「粘」在鼠标上。
- 让 VLC 直接画在皮肤窗口上。 它每帧重绘整个客户区,按钮与文字会被盖掉。
- 混用逻辑单位与物理像素。 高 DPI 下拖动会发涩或跑得比鼠标快。
自测三问(点开对答案)
1. 无边框窗口的拖动偏移为什么只在按下时算一次? —— 因为它是「鼠标相对窗口左上角」的固定关系。每次重算会让偏移变成 0,窗口粘在鼠标上。 2. 为什么 VLC 的画面必须放在独立子窗口里? —— 因为它每帧重绘整个客户区,会把皮肤上的控件盖掉。 3. 没有声明 DPI 感知的程序会怎样? —— 系统按 96 DPI 渲染再放大到实际缩放,界面发虚。MFC 与 Duilib 都是 Windows 原生路线。下一节换 Qt——跨平台,但要把句柄交出去这件事一模一样。
- ☐ (本节完,下一节 02.03.08 使用Qt开发基于LibVLC的播放器)