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):
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,避免逐帧数值漂移):
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 按下:即时反馈
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 跟随 + 橡皮筋
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;
}
}
越界时用橡皮筋软化(越超越远,跟得越少):
auto rubber = [](double over, double dim) {
const double k = 0.55;
return (over * dim * k) / (dim + k * std::fabs(over));
};
4.3 释放:把速度交出去
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 中央节拍器
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(); // 全部静止 → 停表
}
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 释放后:动量投射 → 最近空闲吸附点
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:
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,纵向位置由单条弹簧驱动;用 抽屉默认值(欠阻尼、带轻回弹):
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。效果是“快速落定、不甩、不回弹”的等价反馈,而非无反馈。生产环境应读系统开关:
const bool sysReduced = QGuiApplication::styleHints()->animationDuration() == 0;


3037

被折叠的 条评论
为什么被折叠?



