Qt 在无边框窗口上做一套换肤系统
flyfish
版本
Qt Creator 17.0.1
Desktop Qt 6.9.3 MSVC2022 64bit
Windows 平台
很多桌面软件都支持换肤,但真正做得舒服的,往往不是点一下变个色那么简单,而是要做到三点:切换即时生效、配色协调统一、关掉再开还是上次选的那套。本文以一个最小可运行的 Qt 6 Widgets 工程为例,把换肤拆成主题数据建模 → 应用函数 → 切换入口 → 持久化四件套。
一、为什么要专门写这篇
换肤是把颜色从代码里抽出来,变成数据,再用一个函数统一刷上去。
本文的工程 frameless-theme-demo,是在无边框拖拽窗口(frameless-demo)之上,只新增了换肤这一件事。它最终长这样:
标题栏点换肤弹出一个带色块预览的菜单;
内容区还有一排色块按钮,点一下直接换;
默认是深邃青,换到活力橙后关掉程序,下次打开还是活力橙。
二、第一步:把颜色变成数据
换肤能不能做得干净,第一步就看有没有把颜色从 UI 代码里剥离出来。定义一个 Theme 结构体:
cpp
struct Theme {
QString name; // 主题名称(如深邃青)
QString primaryColor; // 主色调 ------ 窗口主背景
QString lightColor; // 浅色变体 ------ 预留给 hover、选中背景
QString darkColor; // 深色变体 ------ 标题条背景、按下状态
QString accentColor; // 强调色 ------ 预留给分类选中文字、链接
};
设计取舍 :结构体里留了
lightColor/accentColor两个字段,本演示只用到主色(primaryColor)和深色调(darkColor)。它们不是多余的------真实项目里子控件的 hover 态、链接色、进度条都要用它们,提前建模好,后面扩展不用改结构。
主题集合用 QList<Theme> 统一管理,初始化一次即可:
cpp
void MainWindow::initThemes()
{
m_themes.append({ "清新绿", "#22CB64", "#E8F5E9", "#1BA34D", "#5eb161" });
m_themes.append({ "天空蓝", "#2196F3", "#E3F2FD", "#1976D2", "#42a5f5" });
m_themes.append({ "优雅紫", "#9C27B0", "#F3E5F5", "#7B1FA2", "#ab47bc" });
m_themes.append({ "活力橙", "#FF9800", "#FFF3E0", "#F57C00", "#ffa726" });
m_themes.append({ "热情红", "#F44336", "#FFEBEE", "#D32F2F", "#ef5350" });
m_themes.append({ "深邃青", "#009688", "#E0F2F1", "#00796B", "#26a69a" });
}
一眼看过去,{ 名称, 主色, 浅色, 深色, 强调色 },加一套主题就是一行 append。这就是数据驱动的甜头:UI 代码完全不用动。
三、第二步:一个函数统一刷颜色
把所有上色逻辑收口到一个 applyTheme(int index) 里,是换肤模块能不能复用的关键。任何入口(菜单、色块、启动时恢复)都只调用它,绝不在别处散写 setStyleSheet("background:xxx")。
cpp
void MainWindow::applyTheme(int index)
{
if (index < 0 || index >= m_themes.size()) return;
const Theme &t = m_themes[index];
m_currentTheme = index;
// 主背景用主色;标题条用深色变体,保证白字清晰
m_central->setStyleSheet(QString("background-color: %1;").arg(t.primaryColor));
m_title->setStyleSheet(QString("background-color: %1;").arg(t.darkColor));
// 标题文字实时显示当前主题名
m_titleText->setText(QStringLiteral("frameless-theme-demo · 当前主题:%1").arg(t.name));
// 首次调用时创建色块按钮;之后只更新选中描边
if (m_swatches.isEmpty()) {
auto *body = qobject_cast<QWidget *>(m_hint->parent());
auto *row = new QWidget(body);
auto *sl = new QHBoxLayout(row);
sl->setContentsMargins(0, 0, 0, 0);
sl->setSpacing(10);
for (int i = 0; i < m_themes.size(); ++i) {
QPushButton *b = new QPushButton(row);
b->setFixedSize(36, 36);
b->setStyleSheet(QString(
"QPushButton{background-color:%1;border:2px solid rgba(255,255,255,0.6);"
"border-radius:6px;}"
"QPushButton:hover{border:2px solid #ffffff;}")
.arg(m_themes[i].primaryColor));
b->setToolTip(m_themes[i].name);
connect(b, &QPushButton::clicked, this, [this, i]() { applyTheme(i); });
m_swatches.append(b);
sl->addWidget(b);
}
qobject_cast<QVBoxLayout *>(body->layout())->insertWidget(1, row);
}
// 更新色块选中态:当前主题描 3px 白边,其余 2px 半透明白边
for (int i = 0; i < m_swatches.size(); ++i) {
const bool sel = (i == index);
m_swatches[i]->setStyleSheet(QString(
"QPushButton{background-color:%1;border:%2px solid %3;border-radius:6px;}")
.arg(m_themes[i].primaryColor)
.arg(sel ? 3 : 2)
.arg(sel ? "#ffffff" : "rgba(255,255,255,0.6)"));
}
// 持久化
QSettings s(QSettings::IniFormat, QSettings::UserScope,
QStringLiteral("frameless-theme-demo"), QStringLiteral("config"));
s.setValue(QStringLiteral("themeIndex"), index);
}
这段代码有三个值得记住的点:
- 所有颜色都来自
t,没有一处硬编码十六进制,所以换肤必然整套协调。 - 色块按钮懒创建 :只在第一次调用时
new,后续调用只改样式,避免重复建控件造成内存泄漏。 - 结尾顺手持久化:换肤动作和记住选择天然绑在一起,不会漏存。
四、第三步:两个切换入口 + 持久化
4.1 入口 A:标题栏换肤菜单
cpp
void MainWindow::buildThemeMenu()
{
QMenu menu(this);
for (int i = 0; i < m_themes.size(); ++i) {
const Theme &t = m_themes[i];
QAction *action = menu.addAction(t.name);
action->setCheckable(true);
action->setChecked(i == m_currentTheme);
// 菜单项前画一个 16×16 色块预览,体验立刻专业
QPixmap pixmap(16, 16);
pixmap.fill(QColor(t.primaryColor));
action->setIcon(QIcon(pixmap));
}
connect(&menu, &QMenu::triggered, this, &MainWindow::onThemeSelected);
menu.exec(m_themeBtn->mapToGlobal(QPoint(0, m_themeBtn->height())));
}
void MainWindow::onThemeSelected(QAction *action)
{
if (!action) return;
for (int i = 0; i < m_themes.size(); ++i) {
if (m_themes[i].name == action->text()) {
applyTheme(i);
break;
}
}
}
注意 menu 是栈上对象,用 menu.exec() 模态 弹出,所以 connect 在 exec 返回前就触发,不会悬空。
4.2 入口 B:内容区色块按钮
就是第三节里创建的那排 m_swatches,每个 connect 到 applyTheme(i),点一下即换。两个入口最终都回到同一个 applyTheme,单一收口。
4.3 持久化:关掉再开还是的选择
用 QSettings(Ini 格式)把当前索引写进 config.ini:
cpp
// 保存(在 applyTheme 末尾)
QSettings s(... "frameless-theme-demo", "config");
s.setValue("themeIndex", index);
// 恢复(构造函数里,applyTheme 之前)
QSettings s(... "frameless-theme-demo", "config");
int saved = s.value("themeIndex", m_currentTheme).toInt(); // 默认值=5 深邃青
if (saved >= 0 && saved < m_themes.size()) m_currentTheme = saved;
applyTheme(m_currentTheme);
配置文件实际落在:
frameless-theme-demo\config.ini
内容就一行:
ini
themeIndex=5
五、它和无边框拖拽怎么不打架
因为本工程建立在无边框窗口上,拖拽是靠 nativeEvent 拦截 WM_NCHITTEST、把顶部标题条返回 HTCAPTION 实现的。问题来了:换肤按钮和关闭按钮都在标题条上,如果点它们却触发了拖拽,就废了。
解决办法在命中测试里------拖拽区要排除这两个按钮:
cpp
// 标题条区域,但换肤/关闭按钮所在的那一段不算拖拽区
const int btnLeft = qMin(m_themeBtn->mapTo(this, QPoint()).x(),
m_closeBtn->mapTo(this, QPoint()).x());
if (cy < TITLE_H && cx < btnLeft) {
*result = HTCAPTION; // 只有按钮左侧的空白标题区才拖拽
return true;
}
取两个按钮左缘的较小值 作为分界,按钮区域一律交给 Qt 正常处理(点击、菜单照常工作)。这套命中测试排除控件的写法,是任何无边框窗口叠加交互控件时的通用套路。
