桌面图标的动画应由组件维护交互状态,由动画对象改变可写属性;拖放则传递稳定标识,并由接收方更新页面模型。把这三个职责分清,比一开始建立全局动画管理器更有帮助。
本文将原草案的点击缩放、文字图标、页号、网格位置、透明拖动预览和拖动阈值整理成 Qt 6 示例。程序已在 Qt 6.10.2、MinGW 13.1 上编译,并通过离屏逻辑测试;原生系统拖放和不同屏幕缩放比例仍需交互验收。
1. 先确定动画在改变什么
跳转到“1. 先确定动画在改变什么”QPropertyAnimation 改变的是 QObject 的一个可写 Qt 属性。属性必须存在,且属性写入应真正改变画面。普通 QWidget 没有内置 scale 或通用 opacity 属性。
| 需求 | Widgets 实现方式 | 注意点 |
|---|---|---|
| 图标缩放 | 自定义 iconScale 属性,在 paintEvent() 中缩放绘制 | 不改布局几何;给弹性超调留空间 |
| 整个普通控件淡入 | 动画目标为 QGraphicsOpacityEffect::opacity | 控件已有其他效果时先处理合成与所有权 |
| 控件移动 | 动画可写位置或几何属性 | 布局管理器可能重新接管几何 |
| QML Item 缩放或淡入 | 对应 QML scale、opacity 与动画 | 不等同于 QWidget 的属性集合 |
原草案把任意 QWidget 交给通用 scale 动画会失败;工具函数的声明返回 QSequentialAnimationGroup*,后续实现却返回 QPropertyAnimation*,也需要统一。参见 QPropertyAnimation。
2. 组织方式由复用需求决定
跳转到“2. 组织方式由复用需求决定”| 方式 | 合适的情况 | 需要明确的边界 |
|---|---|---|
| 组件内部方法 | 一个或几个控件,效果与交互紧密相连 | 动画由组件持有,业务只接收组件信号 |
| 小型动画工具函数 | 多个组件反复设置相同曲线和时长 | 传入属性名、目标和所有者,校验目标属性 |
| 装饰器或事件过滤器 | 希望为现有组件附加行为 | 不吞掉正常点击、键盘与拖放事件;处理目标销毁 |
| 集中管理器 | 需要统一暂停、减少动态效果或协调多组件时序 | 注册项使用有效期管理,避免全局裸指针表 |
这些都是可选方案,没有证据支持“多数企业都采用某一种”。先使用清晰的组件方法,出现真实重复后再提炼工具函数。本例不建立单例管理器。
3. 点击、动画完成与业务激活
跳转到“3. 点击、动画完成与业务激活”原设计先缩小、再弹回:
- 当前比例到 0.8,持续 100 ms,使用
OutQuad。 - 0.8 到 1.0,持续 200 ms,使用
OutElastic。 - 动画完成后发出业务激活通知。
下面把控件改为继承 QAbstractButton,保留点击和键盘操作的基础行为。其原生 clicked 仍表示按钮已激活;另设 activatedAfterAnimation(id) 表示延后的业务通知,避免把同名信号混在一起。参见 QAbstractButton 的输入和信号。
重复点击采用“停止旧动画,从当前比例重新开始”的策略,最终只完成最后一次动画。开启减少动态效果时直接发出业务通知。若业务要求每次点击都执行,应该在原生 clicked 处理业务,动画只做反馈,不应把业务次数绑定到动画完成次数。
动画组和子动画由组件持有并复用,不在每次点击时创建新组。采用 DeleteWhenStopped 的一次性动画则不能继续保留裸指针;停止也不等同于自然完成,不能假设 stop() 会发送 finished()。见 QAbstractAnimation与动画组所有权。
4. 拖放传数据标识,不传内存地址
跳转到“4. 拖放传数据标识,不传内存地址”原实现把 reinterpret_cast<quintptr>(this) 写入 MIME 数据,再由另一处转回指针。这种值依赖进程地址和对象生存期,不是可靠的拖放协议。
本例为每个图标生成 UUID,在 MIME 中只写 UTF-8 标识;接收方同时核对事件的 source()、源对象类型、所属窗口和标识。它只支持同一窗口中的图标搬移,外部程序传来的相同 MIME 类型也不会被当作内部对象。
若未来需要跨进程拖动,应传公开可解析的数据,并由目标模型重新查找或创建对象;不能继续依赖 event->source() 为本地控件。参见 QDrag与QDropEvent。
拖动只有超过 QApplication::startDragDistance() 后才开始,避免把普通点击当作拖动。开始拖动时停止点击动画、清除按下状态。源图标保留显示,目标接受后才搬移,因此取消拖动不会把源图标永久藏起来。
预览图用 QPainter::setOpacity(0.7) 绘制到透明图像,保留设备像素比。原草案叠加黑色半透明矩形只是把图标变暗,不等于降低透明度。拖动期间可能继续处理事件,本例使用 QPointer 检查源对象是否仍然存在。
5. 完整示例
跳转到“5. 完整示例”示例包含一个 120×120 的图标按钮、80×80 图标绘制区和两个页面。文本使用当前字体和调色板,不固定要求某个系统字体或白色文字;两行文本区域与图标缩放分开。自定义绘制还显示键盘焦点,按钮提供可访问名称。
CMakeLists.txt
跳转到“CMakeLists.txt”cmake_minimum_required(VERSION 3.21)project(DesktopAnimationNotes LANGUAGES CXX)set(CMAKE_CXX_STANDARD 17)set(CMAKE_AUTOMOC ON)find_package(Qt6 REQUIRED COMPONENTS Widgets Test)qt_add_executable(desktop_animation main.cpp DesktopIcon.hpp)target_link_libraries(desktop_animation PRIVATE Qt6::Widgets Qt6::Test)DesktopIcon.hpp
跳转到“DesktopIcon.hpp”#pragma once#include <QAbstractButton>#include <QApplication>#include <QDrag>#include <QMimeData>#include <QMouseEvent>#include <QPainter>#include <QPointer>#include <QPropertyAnimation>#include <QSequentialAnimationGroup>#include <QUuid>#include <cmath>
inline constexpr char IconMime[] = "application/x-vitalogos-desktop-icon-id";
class DesktopIcon final : public QAbstractButton { Q_OBJECT Q_PROPERTY(qreal iconScale READ iconScale WRITE setIconScale)public: explicit DesktopIcon(QWidget* parent = nullptr) : QAbstractButton(parent) { setFixedSize(120, 120); setFocusPolicy(Qt::StrongFocus); setCursor(Qt::PointingHandCursor); setIconSize(QSize(80, 80)); group_ = new QSequentialAnimationGroup(this); shrink_ = new QPropertyAnimation(this, "iconScale"); shrink_->setDuration(100); shrink_->setEndValue(0.8); shrink_->setEasingCurve(QEasingCurve::OutQuad); auto* grow = new QPropertyAnimation(this, "iconScale"); grow->setDuration(200); grow->setStartValue(0.8); grow->setEndValue(1.0); grow->setEasingCurve(QEasingCurve::OutElastic); group_->addAnimation(shrink_); group_->addAnimation(grow); connect(this, &QAbstractButton::clicked, this, &DesktopIcon::playClickAnimation); connect(group_, &QSequentialAnimationGroup::finished, this, [this] { setIconScale(1.0); emit activatedAfterAnimation(id_); }); } QString iconId() const { return id_; } int pageIndex() const { return page_; } QPoint gridPosition() const { return position_; } void setLocation(int page, QPoint position) { page_ = page; position_ = position; } qreal iconScale() const { return scale_; } void setIconScale(qreal value) { if (!std::isfinite(value)) return; scale_ = qBound(0.2, value, 1.1); update(); } void setMotionEnabled(bool enabled) { motion_ = enabled; if (!enabled) { group_->stop(); setIconScale(1.0); } } QSequentialAnimationGroup* animation() const { return group_; } void playClickAnimation() { group_->stop(); if (!motion_) { setIconScale(1.0); emit activatedAfterAnimation(id_); return; } shrink_->setStartValue(iconScale()); group_->start(); // 由控件持有并重复使用,不使用 DeleteWhenStopped。 }signals: void activatedAfterAnimation(const QString& id);protected: void paintEvent(QPaintEvent*) override { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); painter.setRenderHint(QPainter::SmoothPixmapTransform); if (hasFocus()) { painter.setPen(palette().highlight().color()); painter.setBrush(Qt::NoBrush); painter.drawRoundedRect(rect().adjusted(2, 2, -3, -3), 8, 8); } painter.save(); painter.translate(width() / 2.0, 46.0); painter.scale(scale_, scale_); icon().paint(&painter, QRect(-40, -40, 80, 80), Qt::AlignCenter, isEnabled() ? QIcon::Normal : QIcon::Disabled); painter.restore(); painter.setPen(palette().color(isEnabled() ? QPalette::Active : QPalette::Disabled, QPalette::ButtonText)); // 文字独立于图标缩放;Qt 根据当前字体和设备像素比绘制。 painter.drawText(QRect(6, 90, width() - 12, 28), Qt::AlignCenter | Qt::TextWordWrap, text()); } void mousePressEvent(QMouseEvent* event) override { if (event->button() == Qt::LeftButton) pressPosition_ = event->position().toPoint(); QAbstractButton::mousePressEvent(event); } void mouseMoveEvent(QMouseEvent* event) override { if (isEnabled() && isDown() && (event->buttons() & Qt::LeftButton) && (event->position().toPoint() - pressPosition_).manhattanLength() >= QApplication::startDragDistance()) { setDown(false); group_->stop(); setIconScale(1.0); auto* drag = new QDrag(this); auto* mime = new QMimeData; mime->setData(IconMime, id_.toUtf8()); // 不序列化内存地址。 drag->setMimeData(mime); // QDrag 接管 QMimeData。 const QPixmap original = grab(); QPixmap preview(original.size()); preview.setDevicePixelRatio(original.devicePixelRatio()); preview.fill(Qt::transparent); { QPainter painter(&preview); painter.setOpacity(0.7); painter.drawPixmap(0, 0, original); } drag->setPixmap(preview); drag->setHotSpot(pressPosition_); QPointer<DesktopIcon> guard(this); // 保留源图标显示;目标只有在接受后才搬移,取消不必恢复隐藏状态。 drag->exec(Qt::MoveAction); if (!guard) return; // exec 期间事件处理可能销毁源对象。 drag->deleteLater(); setDown(false); return; } QAbstractButton::mouseMoveEvent(event); }private: QString id_ = QUuid::createUuid().toString(QUuid::WithoutBraces); int page_ = 0; QPoint position_; QPoint pressPosition_; qreal scale_ = 1.0; bool motion_ = true; QSequentialAnimationGroup* group_ = nullptr; QPropertyAnimation* shrink_ = nullptr;};
inline DesktopIcon* checkedDragSource(const QMimeData* mime, QObject* object, QWidget* sameWindow) { auto* icon = qobject_cast<DesktopIcon*>(object); if (!mime || !icon || icon->window() != sameWindow || !mime->hasFormat(IconMime) || mime->data(IconMime) != icon->iconId().toUtf8()) return nullptr; return icon;}main.cpp
跳转到“main.cpp”#include "DesktopIcon.hpp"#include <QDragEnterEvent>#include <QDropEvent>#include <QGridLayout>#include <QHBoxLayout>#include <QLabel>#include <QSignalSpy>#include <QStyle>#include <QTest>#include <QVBoxLayout>
class DesktopPage final : public QWidget {public: DesktopPage(int page, QWidget* parent = nullptr) : QWidget(parent), page_(page) { setAcceptDrops(true); setMinimumSize(260, 210); auto* layout = new QVBoxLayout(this); layout->addWidget(new QLabel(QString("Page %1").arg(page), this)); grid_ = new QGridLayout; layout->addLayout(grid_); } void append(DesktopIcon* icon) { int index = 0; while (grid_->itemAtPosition(index / 3, index % 3)) ++index; grid_->addWidget(icon, index / 3, index % 3); icon->setLocation(page_, QPoint(index % 3, index / 3)); icon->show(); }protected: void dragEnterEvent(QDragEnterEvent* event) override { auto* icon = checkedDragSource(event->mimeData(), event->source(), window()); if (icon && icon->parentWidget() != this && (event->possibleActions() & Qt::MoveAction)) { event->setDropAction(Qt::MoveAction); event->accept(); } } void dropEvent(QDropEvent* event) override { auto* icon = checkedDragSource(event->mimeData(), event->source(), window()); if (icon && icon->parentWidget() != this && (event->possibleActions() & Qt::MoveAction)) { append(icon); event->setDropAction(Qt::MoveAction); event->accept(); } }private: int page_; QGridLayout* grid_ = nullptr;};
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 QHBoxLayout(&window); auto* first = new DesktopPage(0); auto* second = new DesktopPage(1); layout->addWidget(first); layout->addWidget(second); auto* icon = new DesktopIcon; icon->setText(QStringLiteral("测量记录")); icon->setAccessibleName(icon->text()); icon->setIcon(app.style()->standardIcon(QStyle::SP_FileDialogContentsView)); first->append(icon); if (app.arguments().contains("--test")) { QSignalSpy activated(icon, &DesktopIcon::activatedAfterAnimation); icon->click(); icon->animation()->setCurrentTime(50); require(icon->iconScale() < 1.0, "shrink animation"); icon->click(); icon->animation()->setCurrentTime(300); require(activated.count() == 1 && icon->iconScale() == 1.0, "restart animation"); icon->setMotionEnabled(false); QTest::keyClick(icon, Qt::Key_Space); require(activated.count() == 2, "keyboard activation"); QMimeData mime; mime.setData(IconMime, icon->iconId().toUtf8()); require(checkedDragSource(&mime, icon, &window) == icon, "valid same-window ID"); mime.setData(IconMime, "unknown-id"); require(!checkedDragSource(&mime, icon, &window), "wrong ID rejected"); require(!checkedDragSource(&mime, nullptr, &window), "external drag rejected"); second->append(icon); require(icon->pageIndex() == 1 && icon->parentWidget() == second, "page move"); auto* temporary = new DesktopIcon; QPointer<QSequentialAnimationGroup> observed = temporary->animation(); temporary->click(); delete temporary; require(observed.isNull(), "animation owner destruction"); qInfo("desktop animation example checks passed"); return 0; } window.setWindowTitle(QStringLiteral("点击、空格激活;拖到另一页")); window.show(); return app.exec();}在 Qt 开发终端执行 cmake -S . -B build 与 cmake --build build,运行生成的程序。点击或按空格触发动画,把图标拖到另一页进行搬移;同一页内排序没有包含在这个最小例子中。
6. 验证了哪些行为
跳转到“6. 验证了哪些行为”附加 --test 会检查动画途中重启、最后一次完成通知、恢复比例 1、空格激活、内部 ID 校验、拒绝错误 ID 和外部来源、跨页状态更新,以及正在播放时销毁控件的清理。
这些测试没有替代真实桌面上的原生拖放验收。还应在目标系统检查鼠标按下后移出、拖动取消、窗口关闭、键盘焦点、高 DPI、长标题、禁用状态和减少动态效果偏好。
本例把页号和网格位置保存在控件中以便独立运行。正式桌面应用应把持久化位置放入模型,接收方校验并提交模型变更,再更新视图;图标 UUID 也应从模型加载,而不是每次启动生成。淡入、阴影等附加效果应按阴影与主题管理的所有权规则组合。