Qt6 实现 会动的界面像物理世界一样

Qt6 实现 会动的界面像物理世界一样

flyfish

下载地址在文末

1. 总体架构

复制代码
FluidDemo  (主窗口)
 ├─ 控制条 bar        : 阻尼/响应/打乱/复位/底部表单/减弱动效
 ├─ 画布 m_canvas     : 容纳若干 Tile(子控件,绝对定位)
 ├─ 底部表单 m_sheet  : 一个 QWidget,纵向位置由弹簧驱动
 └─ 中央节拍器 m_timer: ~60fps 的 QTimer,每帧调用 tickAll()

核心数据模型:每个会被动画的属性,都是一条独立的 Spring 。一条弹簧 = 一个一维状态 (value, velocity) 朝 target 积分。

磁贴位置:m_x / m_y 两条弹簧(2D 运动拆成独立轴)。

磁贴按压缩放:m_scale 一条弹簧。

底部表单纵向:m_sheetY 一条弹簧。

为什么用中央节拍器而不是每条弹簧自带定时器 :所有物体共用同一个单调时钟 QElapsedTimer,逐帧步进,静止即停表。这样位置、缩放、表单的动画天然同步,也避免 N 个定时器互相抢。

2. 名词解释

名词 含义
Spring(弹簧) 一个一维物理状态机:value(当前值) + velocity(速度) → 朝 target 积分。新输入只是改 target,运动连续不中断。
damping(阻尼比) 1.0 = 临界阻尼,无回弹、平滑落定;<1 = 欠阻尼,过冲并振荡(更"弹");>1 = 过阻尼,慢吞吞收敛。
response(响应) 到达目标的快慢,单位秒。注意这不是"时长"------弹簧没有固定时长,落定时间由物理涌现。
settled(静止) `
可中断性 动画任意时刻可改 target;因为 step() 从当前 value 起跳,不跳变、速度续接。
直接操控 拖拽时元素 1:1 粘住指针,并保留"从哪抓的"偏移量。
速度交接 松手瞬间把手指速度(px/s)交给弹簧 velocity,动画无缝衔接拖拽。
动量投射 用释放速度把"静止位置"投出去,再吸附到投射点最近的锚点(而不是从释放点就近吸附)。
橡皮筋 越界时渐进抵抗(越超越远跟得越少),不硬停。
减弱动效 无障碍模式:落定更短、不续接速度、无过冲
。

3. 弹簧积分器:Spring::step

状态定义(Spring.h):

cpp 复制代码
struct Spring {
    double value = 0, target = 0, velocity = 0;
    double damping = 1.0;     // 阻尼比
    double response = 0.4;    // 响应(秒)
    qint64 last = 0;          // 上次步进时钟(ns)
    void step(qint64 nowNs);
    bool settled() const;
    void resetClock() { last = 0; }
};

积分(Spring.cpp)------临界/欠/过阻尼用统一解析解 (直接算 exp/cos/sin,避免逐帧数值漂移):

cpp 复制代码
void Spring::step(qint64 nowNs)
{
    if (last == 0) last = nowNs;
    double dt = (nowNs - last) / 1e9;
    last = nowNs;
    dt = qMin(dt, 1.0 / 30.0);     // 夹住大跳变:切回前台/久置后不爆冲

    const double omega = 2.0 / response;   // 自然频率 ω = 2/response
    const double d = damping;

    if (qAbs(d - 1.0) < 1e-4) {            // 临界阻尼:无回弹
        const double e  = std::exp(-omega * dt);
        const double c1 = value - target;
        const double c2 = velocity + omega * c1;
        value = (c1 + c2 * dt) * e + target;
        velocity = (c2 - omega * (c1 + c2 * dt)) * e;
    } else if (d < 1.0) {                   // 欠阻尼:过冲并振荡
        const double wd = omega * std::sqrt(1 - d * d);   // 阻尼自然频率
        const double e  = std::exp(-d * omega * dt);
        const double c1 = value - target;
        const double c2 = (velocity + d * omega * c1) / wd;
        value = e * (c1 * std::cos(wd * dt) + c2 * std::sin(wd * dt)) + target;
        velocity = e * (-c1 * wd * std::sin(wd * dt) + c2 * wd * std::cos(wd * dt))
                    - d * omega * (value - target);
    } else {                                // 过阻尼:两根实数指数叠加
        const double r  = std::sqrt(d * d - 1);
        const double s1 = -omega * (d - r), s2 = -omega * (d + r);
        const double e1 = std::exp(s1 * dt), e2 = std::exp(s2 * dt);
        const double c2 = (velocity - s1 * (value - target)) / (s2 - s1);
        const double c1 = (value - target) - c2;
        value = c1 * e1 + c2 * e2 + target;
        velocity = c1 * s1 * e1 + c2 * s2 * e2;
    }

    if (qAbs(value - target) < 1e-3 && qAbs(velocity) < 1e-3) {
        value = target; velocity = 0;       // 收尾精确吸附
    }
}

要点:

dt 夹到 1/30s:防止一帧跨度过大(比如窗口切回前台)导致数值爆炸。

last==0 表示"下一帧重新对表",让恢复/重定目标后的第一帧 dt=0,不跳变。

收敛判定里把 value/velocity 钉死到 target/0,消除亚像素抖动。

4. 磁贴:Tile 的拖拽与释放

磁贴是 QWidget 子类,位置由 m_x/m_y 弹簧驱动,按下缩放由 m_scale 驱动。

4.1 按下:即时反馈

cpp 复制代码
void Tile::mousePressEvent(QMouseEvent *e)
{
    m_dragging = true;
    m_grab = e->globalPosition() - mapToGlobal(QPoint(0, 0)); // 保留抓取点偏移
    m_scale.target = 0.92;           // 按下即"咔哒"一下,不等 release
    grabMouse();
    update();
}

mapToGlobal(QPoint(0,0)) 是磁贴左上角的屏幕坐标;m_grab 记下"指针相对左上角的偏移",松手/拖动时还原,元素才不会跳到指针中心。

4.2 移动:1:1 跟随 + 橡皮筋

cpp 复制代码
void Tile::mouseMoveEvent(QMouseEvent *e)
{
    if (!m_dragging) return;
    const QPointF g = e->globalPosition() - m_grab;
    QPoint topLeft = parentWidget()->mapFromGlobal(g.toPoint()); // 回到画布坐标
    topLeft = clampToCanvas(topLeft);

    // 拖拽中直接钉死 value==target,弹簧不参与,避免和手指"打架"
    m_x.value = m_x.target = topLeft.x();
    m_y.value = m_y.target = topLeft.y();
    setGeometry(topLeft.x(), topLeft.y(), Size, Size);

    // 速度历史:最近一帧的 (位置, 时刻),供松手时算手指速度
    const qint64 t = m_clock.nsecsElapsed();
    const double dt = (t - m_lastT) / 1e9;
    if (dt > 1e-4) {
        m_vx = (topLeft.x() - m_lastPos.x()) / dt;
        m_vy = (topLeft.y() - m_lastPos.y()) / dt;
        m_lastPos = QPointF(topLeft); m_lastT = t;
    }
}

越界时用橡皮筋软化(越超越远,跟得越少):

cpp 复制代码
auto rubber = [](double over, double dim) {
    const double k = 0.55;
    return (over * dim * k) / (dim + k * std::fabs(over));
};

4.3 释放:把速度交出去

cpp 复制代码
void Tile::mouseReleaseEvent(QMouseEvent *e)
{
    m_dragging = false;
    m_scale.target = 1.0;          // 松开弹回 1.0
    releaseMouse();
    const double cap = 6000.0;     // 夹掉甩太快/卡顿造成的脏值
    emit released(this,
                  std::clamp(m_vx, -cap, cap),
                  std::clamp(m_vy, -cap, cap));
}

5. 主窗口:FluidDemo 的节拍器与归位

5.1 中央节拍器

cpp 复制代码
void FluidDemo::tickAll()
{
    const qint64 now = m_clock.nsecsElapsed();
    bool moving = false;
    for (auto *t : m_tiles)
        if (t->tick(now)) moving = true;     // x/y/scale 各一条弹簧
    m_sheetY.step(now);
    m_sheet->setGeometry(0, qRound(m_sheetY.value), width(), kSheetH);
    if (!m_sheetY.settled()) moving = true;
    if (!moving) m_timer->stop();            // 全部静止 → 停表
}
cpp 复制代码
bool Tile::tick(qint64 nowNs)
{
    m_x.step(nowNs); m_y.step(nowNs); m_scale.step(nowNs);
    setGeometry(qRound(m_x.value), qRound(m_y.value), Size, Size);
    update();
    return !(m_x.settled() && m_y.settled() && m_scale.settled());
}

5.2 释放后:动量投射 → 最近空闲吸附点

cpp 复制代码
void FluidDemo::onTileReleased(Tile *self, double vx, double vy)
{
    const bool reduced = m_reducedChk->isChecked();

    // 动量投射:用释放速度把"静止位置"投出去(指数衰减,非物理 v²/2a)
    auto project = [](double v) { return (v / 1000.0) * 0.998 / (1.0 - 0.998); };
    const QPoint center = self->geometry().topLeft() + QPoint(Tile::Size / 2, Tile::Size / 2);
    const QPoint proj = center + QPoint(int(project(vx)), int(project(vy)));

    const int idx = pickSlot(self, proj);   // 投射点最近的、且没被别的磁贴占住的吸附点
    self->setSlotIndex(idx);
    self->setTarget(m_slots.value(idx), reduced ? 0 : vx, reduced ? 0 : vy);  // 速度交接
    ensureTimer();
}

pickSlot:对全部吸附点按到 proj 的距离排序,返回第一个"未被其它磁贴占住"的,避免两块叠在一起。

Tile::setTarget:

cpp 复制代码
void Tile::setTarget(QPoint topLeft, double vx, double vy)
{
    if (m_reduced) { m_x.response = m_y.response = 0.18; m_x.velocity = m_y.velocity = 0; }
    m_x.target = topLeft.x(); m_y.target = topLeft.y();
    m_x.velocity = vx; m_y.velocity = vy;     // 手里速度直接续接
    m_x.resetClock(); m_y.resetClock();
}

5.3 为什么可中断

连点 打乱、或拖动途中再点别的------这些只是改写 m_x.target/m_y.target。下一帧 step() 从当前 value (屏幕上真实位置)继续积分,速度自动续接,不重建动画、不跳变。这正是弹簧替掉 QPropertyAnimation 的根本原因。

6. 底部表单(Sheet)

一个 QWidget,纵向位置由单条弹簧驱动;用 抽屉默认值(欠阻尼、带轻回弹):

cpp 复制代码
m_sheetY.damping = 0.8;     // 抽屉/表单:欠阻尼,轻微回弹
m_sheetY.response = 0.3;
// 开:target = height()-kSheetH ;关:target = height()(藏到窗口下沿外)

每帧 tickAll 里 m_sheetY.step(now) 后写 setGeometry。

下载地址 源码

下载地址 可执行

7. 减弱动效

勾选后 Tile::setReduced(true):把磁贴 response 降到 0.18、清零 velocity。效果是"快速落定、不甩、不回弹"的等价反馈,而非无反馈。生产环境应读系统开关:

cpp 复制代码
const bool sysReduced = QGuiApplication::styleHints()->animationDuration() == 0;
相关推荐
UIU1141 小时前
头文件可以防什么错?运用头文件做声明
c++·学习·c#
by209992 小时前
list:迭代器与资源管理实现的综合叙述(下)
数据结构·c++·笔记·list
MayZork2 小时前
Qt 开发集成CMake + vcpkg
开发语言·c++·qt
书生执笔画浮沉2 小时前
Linux桌面端应用向鸿蒙PC迁移适配纪实
c++·qt·c·信创·海思·libc·ohos
(Charon)2 小时前
【C++面试】单例模式:懒汉式与饿汉式的实现、区别与线程安全
开发语言·c++·面试
某不知名網友2 小时前
ROS2入门:命令行启动节点与Launch程序
c++·机器人
小米里的大麦3 小时前
C++ 移动语义
c++·后端
jimy13 小时前
虚函数和 RTTI实现运行时多态
开发语言·c++
今夜有雨.3 小时前
图像运算、掩膜/ROI 与绘制交互
c语言·数据结构·c++·qt·算法·计算机视觉