跳转到内容
新建笔记

Qt Widgets 阴影:Designer 属性、主题参数与效果生命周期

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 属性参考。

widget->setGraphicsEffect(effect) 会把效果交给控件管理;为同一个控件设置另一个效果会删除原有效果。因此:

  1. 重复切换主题时修改现有效果的参数。
  2. 已有透明度或其他效果时,先决定合成策略,再替换。
  3. 不把被控件接管的效果同时留在一个可随意复用的“对象池”里。
  4. 长期注册表若要保存控件引用,使用 QPointer 并处理对象销毁。

Qt 6 没有原草案使用的 QGraphicsEffectGroup。确需同时淡入和投影,可以采用两个普通 QWidget 包装层分别持有效果,或自行绘制一个合成效果,并检查视觉结果和代价。此方式不保证适用于所有子控件;例如 QQuickWidget、QOpenGLWidget 不支持普通 QWidget 图形效果。见 setGraphicsEffect 的所有权与限制。

3. Designer 只保存配置,程序解释配置

跳转到“3. Designer 只保存配置,程序解释配置”

在 Designer 属性编辑器中添加动态属性,可以把参数随 .ui 保存。例如:

动态属性Designer 中的类型应用约定
shadowEnabledbool存在此属性的控件参与阴影配置
shadowColorQColor可选;未设置时取主题默认色
shadowRadiusdouble 或 int可选;程序检查数值范围
shadowOffsetQPointF 或 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 接口说明。

程序演示动态属性读取、递归配置、主题切换、效果复用和半径动画。为便于阅读,测试分支也保留在同一文件;实际工程可以把它移到独立测试目标。

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)
#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 build
cmake --build build

运行生成的 shadow_notes;附加 --test 可检查参数拒绝、效果复用、主题更新、禁用效果和控件销毁。界面需要手工检查不同缩放比例下的阴影裁剪与文字对比度。

7. 与 .ui 同步时的检查顺序

跳转到“7. 与 .ui 同步时的检查顺序”

先在 Designer 保存动态属性,再构建生成代码,运行后核对实际属性值和显示效果。若编写批处理脚本修改 .ui,应把 XML 的读取、属性存在性检查、备份、写回和重新打开验证全部实现;只有 pass 的脚本骨架不能视为同步工具。优先保存声明式配置,避免脚本生成的效果代码与运行时主题逻辑各维护一份。