Qt 在无边框窗口上做一套换肤系统

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);
}

这段代码有三个值得记住的点:

  1. 所有颜色都来自 t,没有一处硬编码十六进制,所以换肤必然整套协调。
  2. 色块按钮懒创建 :只在第一次调用时 new,后续调用只改样式,避免重复建控件造成内存泄漏。
  3. 结尾顺手持久化:换肤动作和记住选择天然绑在一起,不会漏存。

四、第三步:两个切换入口 + 持久化

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() 模态 弹出,所以 connectexec 返回前就触发,不会悬空。

4.2 入口 B:内容区色块按钮

就是第三节里创建的那排 m_swatches,每个 connectapplyTheme(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 正常处理(点击、菜单照常工作)。这套命中测试排除控件的写法,是任何无边框窗口叠加交互控件时的通用套路。

下载地址

相关推荐
西西弗Sisyphus1 小时前
Qt 启动 UsageStatistic 插件报错
开发语言·qt
NeoGressAI外贸数字化1 小时前
外贸独立站零询盘排查:从 Google Search Console 到 PageSpeed 的技术实操
开发语言·c++
神仙别闹1 小时前
基于 QT(C++)实现操作系统
数据库·c++·qt
无小道1 小时前
C/C++——异步编程小记
开发语言·c++·c++11
奇树谦1 小时前
Pimpl 模式(d-pointer)详解:如何解决 C++ 头文件过大、编译依赖和 ABI 兼容问题
开发语言·c++
Broccoli523026651 小时前
FastAPI + SQLAlchemy 异步会话(AsyncSession)核心方法
数据库·oracle·fastapi
DevOpenClub1 小时前
Markdown、HTML 和 PPT 如何稳定交付:文档转换任务的幂等发布流程
开发语言·前端·c#·html·powerpoint
程序员夏洛1 小时前
谈谈你了解的最常见的几种设计模式,说说他们的应用场景
开发语言·python·模板方法模式
小灰灰搞电子2 小时前
Rust+Slint 实现侧滑菜单栏源码分享
开发语言·rust·抽屉式菜单栏