跳转到内容
新建笔记

Qt 图标动画与拖放:属性、交互状态和对象生命周期

桌面图标的动画应由组件维护交互状态,由动画对象改变可写属性;拖放则传递稳定标识,并由接收方更新页面模型。把这三个职责分清,比一开始建立全局动画管理器更有帮助。

本文将原草案的点击缩放、文字图标、页号、网格位置、透明拖动预览和拖动阈值整理成 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. 点击、动画完成与业务激活”

原设计先缩小、再弹回:

  1. 当前比例到 0.8,持续 100 ms,使用 OutQuad。
  2. 0.8 到 1.0,持续 200 ms,使用 OutElastic。
  3. 动画完成后发出业务激活通知。

下面把控件改为继承 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 检查源对象是否仍然存在。

示例包含一个 120×120 的图标按钮、80×80 图标绘制区和两个页面。文本使用当前字体和调色板,不固定要求某个系统字体或白色文字;两行文本区域与图标缩放分开。自定义绘制还显示键盘焦点,按钮提供可访问名称。

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)
#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;
}
#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,运行生成的程序。点击或按空格触发动画,把图标拖到另一页进行搬移;同一页内排序没有包含在这个最小例子中。

附加 --test 会检查动画途中重启、最后一次完成通知、恢复比例 1、空格激活、内部 ID 校验、拒绝错误 ID 和外部来源、跨页状态更新,以及正在播放时销毁控件的清理。

这些测试没有替代真实桌面上的原生拖放验收。还应在目标系统检查鼠标按下后移出、拖动取消、窗口关闭、键盘焦点、高 DPI、长标题、禁用状态和减少动态效果偏好。

本例把页号和网格位置保存在控件中以便独立运行。正式桌面应用应把持久化位置放入模型,接收方校验并提交模型变更,再更新视图;图标 UUID 也应从模型加载,而不是每次启动生成。淡入、阴影等附加效果应按阴影与主题管理的所有权规则组合。