Qt Widgets 的投影可用 QGraphicsDropShadowEffect 实现。主题系统负责颜色、半径和偏移等参数,控件负责效果对象的生命周期。先把这两件事分清,再决定是否需要 Designer 属性、预设文件或调试面板。
本文适用于 Qt 6 Widgets。末尾完整程序已在 Qt 6.10.2、MinGW 13.1 上编译并通过离屏逻辑验证;没有据此推断各操作系统上的阴影外观或性能。
1. 阴影由哪些参数决定
跳转到“1. 阴影由哪些参数决定”投影是根据源图像的不透明区域生成、偏移并模糊的效果,不会改变布局中的控件尺寸。
| 参数 | 含义 | 本文初始值 |
|---|---|---|
color | 阴影颜色,alpha 控制透明程度 | QColor(0, 0, 0, 160) |
blurRadius | 模糊半径;半径增大通常使边缘更柔和 | 15 |
offset | 阴影相对于源图像的偏移;Qt 将其按设备坐标处理 | (4, 4) |
enabled | 是否启用效果 | true |
布局需要给效果留出空间,否则祖先控件边界可能裁剪阴影。边框圆角只改变控件自身的绘制,并不自动增加阴影的布局空间。具体 API 见 Qt 阴影效果文档。
QSS 可以设置背景、边框和圆角,但 Qt 样式表没有网页 CSS 那样的通用 box-shadow。原方案中的粗边框和负 margin 只能改变盒模型,不能替代模糊投影;$shadow-color、$bg-color 也不是 Qt 自带变量语法。若需要主题变量,应由应用先完成明确的占位符替换,再调用 setStyleSheet(),或者直接用已校验的颜色构造样式。参见 QSS 属性参考。
2. 效果归谁所有
跳转到“2. 效果归谁所有”widget->setGraphicsEffect(effect) 会把效果交给控件管理;为同一个控件设置另一个效果会删除原有效果。因此:
- 重复切换主题时修改现有效果的参数。
- 已有透明度或其他效果时,先决定合成策略,再替换。
- 不把被控件接管的效果同时留在一个可随意复用的“对象池”里。
- 长期注册表若要保存控件引用,使用
QPointer并处理对象销毁。
Qt 6 没有原草案使用的 QGraphicsEffectGroup。确需同时淡入和投影,可以采用两个普通 QWidget 包装层分别持有效果,或自行绘制一个合成效果,并检查视觉结果和代价。此方式不保证适用于所有子控件;例如 QQuickWidget、QOpenGLWidget 不支持普通 QWidget 图形效果。见 setGraphicsEffect 的所有权与限制。
3. Designer 只保存配置,程序解释配置
跳转到“3. Designer 只保存配置,程序解释配置”在 Designer 属性编辑器中添加动态属性,可以把参数随 .ui 保存。例如:
| 动态属性 | Designer 中的类型 | 应用约定 |
|---|---|---|
shadowEnabled | bool | 存在此属性的控件参与阴影配置 |
shadowColor | QColor | 可选;未设置时取主题默认色 |
shadowRadius | double 或 int | 可选;程序检查数值范围 |
shadowOffset | QPointF 或 QPoint | 可选;二维偏移 |
在 setupUi() 之后,从根控件递归查找有这些属性的控件。动态属性本身不会让 Designer 或运行程序自动出现阴影,必须有相应读取和应用逻辑。明确设置在控件上的参数优先于主题默认值;需要随主题变化的参数不要同时固定在 .ui 中。
如果只需让生成代码使用自定义控件,可以使用 Designer 的 Promote to…。如果要在 Designer 中显示专用属性和实时预览,需要实现 Designer 自定义控件插件。qRegisterMetaType() 用于元类型注册,Q_INIT_RESOURCE() 用于资源初始化,都不能代替插件注册。见 Designer 自定义控件与提升及插件创建文档。
4. 主题预设和调试面板怎么扩展
跳转到“4. 主题预设和调试面板怎么扩展”少量控件可以直接保存预设结构体;较多主题再引入版本化 JSON。例如保留原草案中的两组设计参数:
{ "version": 1, "presets": { "default": {"color": "#A0000000", "radius": 12, "offset": [4, 4]}, "materialLike": {"color": "#1E275DAD", "radius": 24, "offset": [0, 6]} }}这里颜色采用 Qt QColor 支持的 #AARRGGBB 形式:A0 为 alpha 160,1E 为 alpha 30;名称 materialLike 只是本例预设名,不表示符合某个设计规范。原草案的 rgba(...) 或 $primary 需要额外解析器,不能直接假定 QColor 会按 CSS 或主题变量语义解析。
加载配置时先验证版本、必需键、颜色、有限数值和偏移长度,全部成功后再更新界面;失败时保留上一份有效主题。调试用 QDockWidget 可以容纳颜色选择器、半径输入、X/Y 偏移输入、启用开关和恢复按钮,改变同一份配置即可实时预览。它应调用 setWidget() 安装实际面板,并明确“临时预览”和“保存预设”的区别。
原文中的 beginUpdate/endUpdate 若仅改变鼠标光标,不会批量提交图形更新。若临时调用 setUpdatesEnabled(false),必须保证所有退出路径恢复原状态;通常先校验、再一次性写入参数更简单。不要在耗时工作中通过关闭界面刷新掩盖主线程阻塞。
5. 模糊半径动画与性能边界
跳转到“5. 模糊半径动画与性能边界”blurRadius 是可写 Qt 属性,可以用 QPropertyAnimation 做 1 秒内从 5 到 20 的变化。动画要有所有者,重复点击时先停止现有动画,主题切换时也要取消旧动画,防止动画随后覆盖新参数。下面程序只创建一个动画并反复使用。
影响性能的因素包括效果覆盖面积、半径、同时活动的数量、刷新频率、设备像素比及图形后端。应在目标机器记录帧时间、CPU 和内存,再决定是否减少半径、减少动画数量或改用预渲染资源。原笔记的星级性能表没有测量条件,不能作为速度结论。
QOpenGLContext::currentContext() 返回空只说明当前线程此时没有当前 OpenGL 上下文,不能证明机器没有 GPU。设置多重采样数、启用 OpenGL 标志也不会自动加速所有 QWidget 投影;Qt 6 的高 DPI 处理不依靠原草案中的 Qt 5 开关来完成。
Windows 的 DwmExtendFrameIntoClientArea() 用于扩展窗口框架到客户区,不是给任意子控件添加投影的通用接口;使用原生窗口效果应另写平台适配层。见 微软 DWM 接口说明。
6. 完整可运行示例
跳转到“6. 完整可运行示例”程序演示动态属性读取、递归配置、主题切换、效果复用和半径动画。为便于阅读,测试分支也保留在同一文件;实际工程可以把它移到独立测试目标。
CMakeLists.txt
跳转到“CMakeLists.txt”cmake_minimum_required(VERSION 3.21)project(ShadowNotes LANGUAGES CXX)set(CMAKE_CXX_STANDARD 17)find_package(Qt6 REQUIRED COMPONENTS Widgets)qt_add_executable(shadow_notes main.cpp)target_link_libraries(shadow_notes PRIVATE Qt6::Widgets)main.cpp
跳转到“main.cpp”#include <QApplication>#include <QColor>#include <QFrame>#include <QGraphicsDropShadowEffect>#include <QGraphicsOpacityEffect>#include <QLabel>#include <QPointer>#include <QPropertyAnimation>#include <QPushButton>#include <QVBoxLayout>#include <QVariant>#include <cmath>#include <cstdlib>
struct ShadowPreset { QColor color{0, 0, 0, 160}; qreal radius = 15.0; QPointF offset{4.0, 4.0}; bool enabled = true;};
bool readShadow(QWidget* widget, const ShadowPreset& defaults, ShadowPreset& result) { result = defaults; const QVariant enabled = widget->property("shadowEnabled"); if (!enabled.isValid() || enabled.metaType().id() != QMetaType::Bool) return false; result.enabled = enabled.toBool(); const QVariant color = widget->property("shadowColor"); if (color.isValid()) { result.color = color.value<QColor>(); if (!result.color.isValid()) return false; } const QVariant radius = widget->property("shadowRadius"); if (radius.isValid()) { bool ok = false; result.radius = radius.toDouble(&ok); // 64 是此演示的配置上限,不是 Qt API 的上限。 if (!ok || !std::isfinite(result.radius) || result.radius < 0.0 || result.radius > 64.0) return false; } const QVariant offset = widget->property("shadowOffset"); if (offset.isValid()) { if (!offset.canConvert<QPointF>()) return false; result.offset = offset.toPointF(); if (!std::isfinite(result.offset.x()) || !std::isfinite(result.offset.y())) return false; } return true;}
bool applyShadow(QWidget* widget, const ShadowPreset& defaults) { ShadowPreset preset; if (!readShadow(widget, defaults, preset)) return false; auto* shadow = qobject_cast<QGraphicsDropShadowEffect*>(widget->graphicsEffect()); if (widget->graphicsEffect() && !shadow) return false; if (!shadow) { shadow = new QGraphicsDropShadowEffect(widget); widget->setGraphicsEffect(shadow); } if (auto* animation = shadow->findChild<QPropertyAnimation*>("shadowPulse")) animation->stop(); shadow->setColor(preset.color); shadow->setBlurRadius(preset.radius); shadow->setOffset(preset.offset); shadow->setEnabled(preset.enabled); return true;}
int applyTree(QWidget* root, const ShadowPreset& preset) { auto widgets = root->findChildren<QWidget*>(); // 默认递归包含后代 widgets.prepend(root); int failed = 0; for (auto* widget : widgets) { if (widget->property("shadowEnabled").isValid() && !applyShadow(widget, preset)) ++failed; } return failed;}
void require(bool condition, const char* message) { if (!condition) qFatal("%s", message);}
int main(int argc, char** argv) { QApplication app(argc, argv); QWidget window; auto* layout = new QVBoxLayout(&window); layout->setContentsMargins(36, 36, 36, 36); layout->setSpacing(28); auto* card = new QFrame; card->setObjectName("card"); card->setProperty("shadowEnabled", true); card->setProperty("shadowRadius", 20.0); auto* cardLayout = new QVBoxLayout(card); cardLayout->addWidget(new QLabel(QStringLiteral("阴影由控件持有,主题只提供参数。"))); layout->addWidget(card); auto* themeButton = new QPushButton(QStringLiteral("切换主题")); auto* pulseButton = new QPushButton(QStringLiteral("演示模糊半径变化")); layout->addWidget(themeButton); layout->addWidget(pulseButton);
bool dark = false; auto updateTheme = [&] { ShadowPreset preset; preset.color = dark ? QColor(0, 0, 0, 210) : QColor(0, 0, 0, 110); window.setStyleSheet(dark ? "QWidget { background:#20242a; color:#f4f6f8; } QFrame#card { background:#343b44; border-radius:8px; }" : "QWidget { background:#eef1f5; color:#17202b; } QFrame#card { background:#ffffff; border-radius:8px; }"); require(applyTree(&window, preset) == 0, "invalid shadow configuration"); }; updateTheme(); auto* shadow = qobject_cast<QGraphicsDropShadowEffect*>(card->graphicsEffect()); auto* pulse = new QPropertyAnimation(shadow, "blurRadius", shadow); pulse->setObjectName("shadowPulse"); pulse->setDuration(1000); pulse->setStartValue(5.0); pulse->setEndValue(20.0); QObject::connect(pulseButton, &QPushButton::clicked, pulse, [pulse] { pulse->stop(); pulse->start(); }); QObject::connect(themeButton, &QPushButton::clicked, &window, [&] { dark = !dark; updateTheme(); });
if (app.arguments().contains("--test")) { require(shadow && shadow->blurRadius() == 20.0, "radius property"); auto* before = card->graphicsEffect(); dark = true; updateTheme(); require(card->graphicsEffect() == before, "reuse existing effect"); require(shadow->color() == QColor(0, 0, 0, 210), "theme propagation"); pulse->start(); pulse->setCurrentTime(1000); require(shadow->blurRadius() == 20.0, "animation target property"); QWidget conflict; conflict.setProperty("shadowEnabled", true); auto* opacity = new QGraphicsOpacityEffect(&conflict); conflict.setGraphicsEffect(opacity); require(!applyShadow(&conflict, {}), "must not replace another effect"); require(conflict.graphicsEffect() == opacity, "effect retained"); card->setProperty("shadowRadius", -1.0); require(!applyShadow(card, {}), "invalid radius rejected"); card->setProperty("shadowRadius", 20.0); card->setProperty("shadowEnabled", false); require(applyShadow(card, {}) && !shadow->isEnabled(), "disabled property"); auto* temporary = new QWidget; temporary->setProperty("shadowEnabled", true); require(applyShadow(temporary, {}), "temporary effect"); QPointer<QGraphicsEffect> observed = temporary->graphicsEffect(); delete temporary; require(observed.isNull(), "widget owns effect"); qInfo("shadow example checks passed"); return 0; } window.resize(420, 260); window.show(); return app.exec();}在配置好 Qt 的开发终端中构建并运行:
cmake -S . -B buildcmake --build build运行生成的 shadow_notes;附加 --test 可检查参数拒绝、效果复用、主题更新、禁用效果和控件销毁。界面需要手工检查不同缩放比例下的阴影裁剪与文字对比度。
7. 与 .ui 同步时的检查顺序
跳转到“7. 与 .ui 同步时的检查顺序”先在 Designer 保存动态属性,再构建生成代码,运行后核对实际属性值和显示效果。若编写批处理脚本修改 .ui,应把 XML 的读取、属性存在性检查、备份、写回和重新打开验证全部实现;只有 pass 的脚本骨架不能视为同步工具。优先保存声明式配置,避免脚本生成的效果代码与运行时主题逻辑各维护一份。