前阵子折腾了一个 Qt 纯代码模仿 WPS 登录界面的小项目,没有切图、没有背景素材,窗口上的 logo、圆角卡片、输入框、按钮和阴影全部用 QSS 加 QPainter 手写出来。这篇文章就从布局拆解、窗口底层、QSS 细节、交互实现到踩坑记录,完整复盘一下这个登录窗口是怎么从空白工程一步步长出来的。如果你最近也在写自定义窗体、想摆脱对设计师切图的依赖,或者单纯想看看一个“看起来还算精致”的登录界面需要哪些基本功,这篇应该能给你不少可以直接抄作业的细节。
1. 纯代码方案的出发点与界面拆解
1.1 为什么要用纯代码做,而不是直接找素材
很多开发者一听“模仿某某界面”,第一反应是去网上扒图,把 logo、背景、图标全部切成图片,然后往 QLabel 里一贴。这个思路最省事,但后患不小。图片资源一旦需要换肤或者适配高分屏,要么模糊,要么拉伸变形;想在 hover 时让图标换个颜色,还得准备两套甚至三套图;到了深色模式,整套素材又得推翻重做。
纯代码路线的核心优势是“参数化”。所有颜色、圆角、间距、阴影强度都是代码里的常量,改一个变量,整个界面跟着变。我这次刻意把 logo、协议勾选框、视觉装饰全部用 QPainter 和 QSS 子控件来实现,全程零图片依赖,目的就是让这套代码可以任意移植到别的项目里。实测下来,纯代码界面的帧率表现也很稳,因为不需要解码位图,内存占用少,启动速度快。
不过要泼一盆冷水:纯代码不代表“不用设计”。恰恰相反,它要求你把设计语言拆得更细,色值、字重、间距、层级都要心里有数。简单说,图片方案是把设计打包成黑盒,纯代码方案是让设计变成了可编辑的配置文件。我建议新手练这种项目时,先观察再动手,不要急着敲代码。
1.2 WPS 登录界面到底长什么样,拆开看
我参照的是日常使用中常见的 WPS 登录弹窗形态,没有官方设计稿,全靠肉眼观察和坐标测量。整个窗口可以拆成三层结构:
- 最外层是一个透明背景的无边框窗口,悬浮着一张白色圆角卡片。
- 卡片内容是垂直布局:顶部 logo 区,中间账号与密码输入区,底部主按钮,再往下是第三方登录方式和协议勾选。
- 视觉上最有辨识度的不是某个具体控件,而是整体留白节奏和主按钮的颜色,主按钮在灰色输入框中间尤其抢眼。
从代码角度拆,大概分成这几个区:
- Logo 区:卡片顶部居中或左对齐,高度约 80px 左右,下方留 20px 间隙。
- 表单区:账号输入框、密码输入框,每个输入框高度约 42px,间距 16px。
- 主操作区:登录按钮高度约 44px,宽度撑满卡片内容区,圆角略大于输入框。
- 辅助信息区:协议勾选框、忘记密码链接、第三方登录图标,通常弱化颜色。
这个拆解特别重要,因为模仿不是“像素级复制”,而是抓住结构和层次。我最终做出来的窗口尺寸是 420x560,这个比例是在参考常见弹窗基础上调整出来的,卡片左右留了 24px 的透明边距,正好用来显示阴影。
2. 窗口骨架与无边框阴影实现
2.1 工程目录与类划分
动工之前先定工程结构。项目虽然不大,但为了后面好维护,我把“窗口逻辑”和“自绘图标”拆成了两个部分:
LoginDemo/ ├── main.cpp ├── LoginWindow.h ├── LoginWindow.cpp ├── LoginWindow.qss ├── IconPainter.h └── IconPainter.cppmain.cpp 只负责创建 QApplication 和显示窗口;LoginWindow 负责整体布局、事件处理、登录状态切换;IconPainter 是一个静态绘图工具类,专门提供画 logo、画协议勾、画 loading 弧线的函数。之所以拆出 IconPainter,是因为 QSS 能覆盖大部分静态样式,但 loading 转圈、动态 logo 这类需要旋转角度的东西,QSS 做不了,必须用 QPainter 绘制,而绘制代码混进窗口类里会让文件越变越长。
qss 文件单独拎出来,是为了方便整体替换主题。我习惯在 main 函数里用 QFile 读取 qss 然后 setStyleSheet,而不是把样式字符串硬编码在窗口构造函数里。这样以后想换一套夜间主题,只加一个 qss 文件就行。
2.2 无边框窗口与圆角卡片的底层设置
登录窗口第一步是去掉系统标题栏。Qt 里最常用的做法是设置窗口标志:
setWindowFlags(Qt::FramelessWindowHint | Qt::WindowMinimizeButtonHint); setAttribute(Qt::WA_TranslucentBackground); setFixedSize(420, 560);这里有两个关键点。Qt::FramelessWindowHint 会去掉标题栏和边框,但窗口本身依然是矩形;真正让窗口变成“圆角卡片”的,是 WA_TranslucentBackground 这个属性——它让整个窗口背景透明,于是你只能在窗口上画出一个圆角矩形的“卡片”,其余区域全部透明。
对应到代码上,我在中央容器上设置了 objectName:
auto *card = new QWidget(this); card->setObjectName("loginCard");然后在 QSS 里:
#loginCard { background-color: #FFFFFF; border-radius: 12px; }这样卡片就有了白色圆角背景,透明区域透出桌面。这里我踩过一个小坑:如果直接在顶层窗口上设置 background-color 和 border-radius,很多平台不会生效,因为顶层窗口有系统边框逻辑介入。常规做法是“透明窗口 + 内部圆角容器”,这个套路做登录弹窗基本是标准答案。
2.3 给卡片增加阴影,而不是画一条灰边
登录窗口最外层的悬浮感主要靠阴影。Qt 里最省事的方案是 QGraphicsDropShadowEffect:
auto *shadow = new QGraphicsDropShadowEffect(card); shadow->setBlurRadius(24); shadow->setOffset(0, 4); shadow->setColor(QColor(0, 0, 0, 35)); card->setGraphicsEffect(shadow);参数选择上,blurRadius 24 是为了让阴影边缘过渡自然,offset 的 Y 方向给 4px 是为了制造“卡片略微浮在桌面上方”的感觉,颜色用半透明黑而不是纯黑,否则会显得很脏。
这个方案唯一的坑是阴影会被窗口边界裁剪。因为窗口本身只有 420x560,而阴影要画在卡片外,所以必须在布局时给卡片四周留出余量。我的做法是:窗口最外层放一个 QVBoxLayout,layout 的 margins 设为 24px,然后把卡片放进去。这样阴影有地方画,不会被切掉。
还有一点值得注意:QGraphicsDropShadowEffect 本质是让控件在离屏缓冲区里重绘一次,再做高斯模糊。如果 shadow 作用的控件特别大或者模糊半径特别大,移动窗口时会感觉到掉帧。登录窗口尺寸不大,所以没问题。如果你的项目里有复杂图表,建议只对静态容器加阴影,不要大面积滥用。
3. 核心界面与 QSS 实现
3.1 先定色板和字体,再写样式
写 QSS 之前我先做了一张“调色盘”,把使用到的颜色全部列出来。这一步很多人会忽略,但实际效果非常明显——没有色板,调着调着颜色就飘了。
| 用途 | 色值 | 说明 |
|---|---|---|
| 卡片背景 | #FFFFFF | 登录卡片底色 |
| 页面底色 | #F5F6FA | 透明窗口透出的底层,登录页常用浅灰 |
| 主按钮 | #2878FF | 偏蓝的办公风主色 |
| 主按钮 hover | #1D66E5 | 鼠标悬停时加深 |
| 主按钮 pressed | #1754C7 | 按下时进一步加深 |
| 输入框背景 | #F7F8FA | 未聚焦时的浅灰底 |
| 输入框边框 | #E5E6EB | 未聚焦时的细边框 |
| 主文本 | #1D2129 | 账号、标题等主要文字 |
| 次要文本 | #86909C | 协议、辅助说明 |
| 错误提示 | #F53F3F | 密码错误时的提示色 |
字体方面,我设置了“Microsoft YaHei, PingFang SC, Segoe UI, sans-serif”这个顺序,中文优先用微软雅黑,苹果系统回退到苹方,英文数字回退到 Segoe UI。字体栈要写在窗口整体样式上,而不是每个控件单独写,这样所有子控件自动继承。
3.2 输入框和按钮的 QSS 写法
输入框是登录界面的门面,我实现了三种状态:默认、聚焦、禁用。默认状态是浅灰底加浅边框,聚焦状态切换成白底加蓝色边框。用 QSS 写起来很清晰:
#accountEdit, #passwordEdit { background-color: #F7F8FA; border: 1px solid #E5E6EB; border-radius: 6px; height: 42px; padding: 0px 12px; font-size: 14px; color: #1D2129; } #accountEdit:focus, #passwordEdit:focus { background-color: #FFFFFF; border: 1px solid #2878FF; }这里有个细节:给 QLineEdit 设置 height 时,实际上设置的是控件整体高度,而不是内部文本区域。所以文字会垂直居中,不需要额外调 padding。当然了,如果你在某些平台上发现文字偏上,可以适当调大 padding 的上下值。
主按钮的 QSS 也比较常规:
#loginButton { background-color: #2878FF; border: none; border-radius: 8px; height: 44px; color: #FFFFFF; font-size: 15px; font-weight: 500; } #loginButton:hover { background-color: #1D66E5; } #loginButton:pressed { background-color: #1754C7; } #loginButton:disabled { background-color: #A0C4FF; color: #FFFFFF; }按钮禁用状态要单独考虑,因为登录请求发出后我会暂时禁用按钮,防止用户重复点击。如果不写 :disabled 伪类,禁用时就会保留 hover 的高亮样式,视觉上很怪。
3.3 纯代码画 Logo、协议勾选框和动态圆弧
WPS 登录框的 logo 我不打算复刻它的商标图形,而是用 QPainter 画一个表达“文档叠加”概念的抽象图标,既贴合办公场景,又避免原样照搬的麻烦。画法是在 IconPainter 里定义一个函数:
void IconPainter::drawLogo(QPainter &p, const QRectF &r, const QColor &c) { p.save(); p.setRenderHint(QPainter::Antialiasing); // 底部圆角矩形代表基础文档 QRectF bottomRect(r.left() + r.width() * 0.2, r.top() + r.height() * 0.3, r.width() * 0.6, r.height() * 0.62); p.setBrush(c); p.setPen(Qt::NoPen); p.drawRoundedRect(bottomRect, 8, 8); // 顶部倾斜矩形表示叠加的文档页 p.save(); p.translate(r.center().x(), r.top() + r.height() * 0.36); p.rotate(15); QRectF topRect(-r.width() * 0.22, -r.height() * 0.25, r.width() * 0.44, r.height() * 0.5); p.drawRoundedRect(topRect, 6, 6); p.restore(); p.restore(); }这个函数只用了两个圆角矩形,通过旋转和叠加模拟出“多文档堆叠”的视觉效果。因为是纯矢量绘制,logo 可以任意缩放,也可以随时通过参数换颜色,比位图灵活得多。
协议勾选框我用 QCheckBox + QSS 子控件实现。其实就是把 indicator 的默认勾选样式替换成自绘的圆角方块:
#agreeCheckBox { color: #86909C; font-size: 12px; spacing: 6px; } #agreeCheckBox::indicator { width: 16px; height: 16px; border-radius: 4px; border: 1px solid #C9CDD4; background-color: #FFFFFF; } #agreeCheckBox::indicator:checked { border: 1px solid #2878FF; background-color: #2878FF; image: none; }checked 状态下的勾号如果不用图片,最简单的办法是在 QCheckBox 里放一个子类,重写 paintEvent 画一个白色对勾。否则就只能在 QSS 的 image 属性里嵌入一张 base64 图形,这又变成“纯代码”之外的资源依赖了。我选择了重写 QCheckBox,几行代码就能画出平滑的对勾线条。
loading 转圈同样用 QPainter 实现。我在窗口里放了一个自定义的 LoadingCircle 控件,paintEvent 里根据当前角度画一段圆弧,然后用 QVariantAnimation 持续改变角度:
void LoadingCircle::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); QPen pen(QColor("#FFFFFF"), 3); pen.setCapStyle(Qt::RoundCap); p.setPen(pen); // 只画 270 度的弧,留出缺口产生旋转感 p.drawArc(rect().adjusted(4, 4, -4, -4), m_angle * 16, 270 * 16); }动画循环里每次把 m_angle 减 1,等于 360 度转一圈。圆弧的缺口让视觉上有“加载中”的节奏,比单纯转一整圈更像真实 Loading。
4. 交互细节:拖动、回车、焦点与状态切换
4.1 无边框窗口拖动
去掉系统标题栏后,窗口默认无法拖动,必须自己处理鼠标事件。我是在卡片顶部留了一个 50px 高的标题区域,作为“可拖拽热区”:
void LoginWindow::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { m_dragOffset = event->globalPosition().toPoint() - frameGeometry().topLeft(); event->accept(); } } void LoginWindow::mouseMoveEvent(QMouseEvent *event) { if (event->buttons() & Qt::LeftButton) { move(event->globalPosition().toPoint() - m_dragOffset); } }这段代码核心逻辑是:按下鼠标时记录“鼠标相对窗口左上角”的偏移量;移动时用当前鼠标全局坐标减去这个偏移量,得到窗口应该移动到的位置。这里有两个容易踩的坑。
第一个坑是坐标系。Qt5 里用 event->globalPos(),Qt6 里改成了 event->globalPosition().toPoint()。如果你在升级版本时空了指针,编译能过但运行时位置会偏一个像素。
第二个坑是 frameGeometry 和 geometry 的区别。对无边框窗口来说两者基本一样,但如果你以后加了阴影窗口或者保留系统边框,计算偏移量时就要改成 frameGeometry,否则拖动时会突然“跳一下”。
4.2 回车登录与按钮状态模拟
登录交互不能只靠鼠标点击。用户习惯在密码框里敲完回车就登录,所以我在两个输入框上都监听了 returnPressed 信号,统一指向 doLogin 槽函数:
connect(m_passwordEdit, &QLineEdit::returnPressed, this, &LoginWindow::doLogin); connect(m_loginButton, &QPushButton::clicked, this, &LoginWindow::doLogin);doLogin 里做的第一件事不是发起网络请求,而是做输入校验:账号为空时提示“请输入账号”,密码长度不足时提示“请输入密码”。这些提示都显示在一个红色 QLabel 上,避免用 QMessageBox 弹窗打断流程。
状态切换用 QTimer 模拟了常见登录场景下的耗时操作:
void LoginWindow::doLogin() { if (m_accountEdit->text().trimmed().isEmpty()) { m_tipLabel->setText("请输入账号"); return; } m_loginButton->setEnabled(false); m_loginButton->setText("登录中..."); showLoading(true); QTimer::singleShot(1200, this, [this]() { // 模拟登录成功后的处理 m_loginButton->setEnabled(true); m_loginButton->setText("登录"); showLoading(false); m_tipLabel->setText("登录成功(纯模拟)"); }); }这里有个小细节:按钮禁用的时候如果直接改文字,QSS 的 :disabled 样式会接管按钮颜色,但文字还是可以用 setText 控制。我把按钮文本改成“登录中...”后配合 LoadingCircle 控件显示,用户在等待期间看到的是一条转圈动画加变化文字,比单纯禁用按钮有反馈感。
4.3 Tab 焦点顺序和键盘可达性
很多人自定义界面后会忽略键盘操作。默认情况下,Qt 会按照控件创建顺序决定 Tab 顺序,但在复杂布局里经常不是我们想要的。所以我在构造函数的末尾显式设置了焦点顺序:
setTabOrder(m_accountEdit, m_passwordEdit); setTabOrder(m_passwordEdit, m_loginButton); setTabOrder(m_loginButton, m_agreeCheckBox);同时,QSS 里给输入框的 :focus 样式增加一个淡蓝色外圈提示。由于我前面已经写了 focus 边框变色,键盘用户按下 Tab 切到输入框时能清楚看到焦点位置,而不是全靠猜。这一点听起来简单,但实际很多客户端界面都没做好。
Esc 键的处理也要考虑。无边框窗口默认 Esc 不会关闭,我在 keyPressEvent 里做了监听:如果当前不在登录请求中,Esc 就关闭窗口;如果正在登录中,则忽略 Esc,避免用户误关窗口导致提示状态还没结束。
5. 常见问题与排查技巧实录
5.1 阴影发黑、边缘发虚怎么办
使用 QGraphicsDropShadowEffect 后最常出现的问题是阴影颜色太深或者边缘生硬。我调过几轮,总结出两个直接原因。
第一个原因是 color 的 alpha 值过高。很多人从网上下载代码直接抄,看到 setColor(QColor(0, 0, 0, 255)) 就拿过来用,阴影自然黑得像描边。我的建议是 alpha 先给 30~50,然后整体看效果,不够再逐步加,不要一上来就 100 以上。
第二个原因是窗口边缘裁切。如果卡片布局没有给阴影预留空间,阴影被窗口边界切掉一刀,视觉上就会出现“一半阴影一半没有”的断层。检查方法很简单:把窗口透明背景色临时改成纯红色,看看卡片周围是否还有一圈红边,如果有,说明 layout margin 留得不够,加大到 24px 以上。
5.2 QSS 不生效的高频原因
我整理了一个速查表,基本覆盖了写 QSS 时最容易翻车的几类问题:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 样式一点不生效 | 没加载 qss 文件,或选择器写错 | 在 main 里 QFile 读取后 setStyleSheet;用 #objectName 时确认 setObjectName 已执行 |
| 子控件样式不生效 | 父级 QSS 把子控件覆盖了 | 给子控件单独设 objectName,用更具体的选择器 |
| hover 样式不生效 | 控件没有使能鼠标跟踪 | QPushButton 等默认支持,QLabel 需要重写 enterEvent 或安装事件过滤器 |
| 背景图不显示 | 资源路径写错 | 优先用 qrc 资源路径,避免相对路径 |
| border-radius 部分失效 | 同时设置了 background-image | 背景图片会把圆角遮住,改用 QPainter 或去掉图片 |
还有一个小坑值得单独说:QSS 里的 height 对 QLineEdit 起作用,但对某些自定义控件不一定起作用,比如 QComboBox 的 height 设置后下拉按钮的位置可能还会偏。遇到这种问题,别死磕 QSS,直接继承控件重写 sizeHint 才是正路。
5.3 高 DPI 缩放和绘制模糊
现在高分屏已经很普及了,如果把 DPI 处理忽略,界面会出现两种问题:一是控件整体变小,字发虚;二是 QPainter 画的图形边缘有锯齿。
Qt5 环境里,main 函数开头要加:
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);Qt6 默认开启高分屏缩放,不需要手动设置,但要注意 setFont 的参数。如果你用 pointSize 设置字号,Qt 会按照屏幕 DPI 自动换算;如果你用 pixelSize,那在高分屏上字体可能比预期小。我全局采用 pointSize,这样在不同缩放下表现一致。
QPainter 绘制方面,DrawArc、drawRoundedRect 这类函数只要设置了 Antialiasing,在高分屏上一般没问题。真正容易出问题的是绘制到 QPixmap 上再贴到界面里的场景,必须用 devicePixelRatio 来设置 pixmap 的分辨率,否则图形会糊。我的 LoadingCircle 和 Logo 都是实时绘制到控件上的,绕开了 PxielMap 那套逻辑,反而最省心。
最后补一个实操心得
这个项目做完之后,我最大的体会是:模仿界面的难点从来不是某个控件怎么写,而是整个界面的“呼吸感”怎么调。输入框之间的间距、logo 和表单的距离、按钮和协议说明之间的距离,这些数值每改一次,视觉效果都会明显变化。建议所有间距都抽成常量,集中放在一个命名空间里,方便批量微调。
另外,如果你想让这个登录页看起来更生动,可以给 logo 加一个非常轻量的透明度循环动画,周期 3 秒,透明度在 0.75 到 1.0 之间缓慢变化。因为幅度很小,不会影响可读性,但会让人觉得界面不是“死”的。用 QVariantAnimation 实现,几十行代码就能搞定。登录页模仿到这里,剩下的就是根据自己的项目配色去微调了,希望你也能做出自己满意的登录界面。