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):
structSpring{doublevalue=0,target=0,velocity=0;doubledamping=1.0;// 阻尼比doubleresponse=0.4;// 响应(秒)qint64 last=0;// 上次步进时钟(ns)voidstep(qint64 nowNs);boolsettled()const;voidresetClock(){last=0;}};积分(Spring.cpp)——临界/欠/过阻尼用统一解析解(直接算exp/cos/sin,避免逐帧数值漂移):
voidSpring::step(qint64 nowNs){if(last==0)last=nowNs;doubledt=(nowNs-last)/1e9;last=nowNs;dt=qMin(dt,1.0/30.0);// 夹住大跳变:切回前台/久置后不爆冲constdoubleomega=2.0/response;// 自然频率 ω = 2/responseconstdoubled=damping;if(qAbs(d-1.0)<1e-4){// 临界阻尼:无回弹constdoublee=std::exp(-omega*dt);constdoublec1=value-target;constdoublec2=velocity+omega*c1;value=(c1+c2*dt)*e+target;velocity=(c2-omega*(c1+c2*dt))*e;}elseif(d<1.0){// 欠阻尼:过冲并振荡constdoublewd=omega*std::sqrt(1-d*d);// 阻尼自然频率constdoublee=std::exp(-d*omega*dt);constdoublec1=value-target;constdoublec2=(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{// 过阻尼:两根实数指数叠加constdoubler=std::sqrt(d*d-1);constdoubles1=-omega*(d-r),s2=-omega*(d+r);constdoublee1=std::exp(s1*dt),e2=std::exp(s2*dt);constdoublec2=(velocity-s1*(value-target))/(s2-s1);constdoublec1=(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 按下:即时反馈
voidTile::mousePressEvent(QMouseEvent*e){m_dragging=true;m_grab=e->globalPosition()-mapToGlobal(QPoint(0,0));// 保留抓取点偏移m_scale.target=0.92;// 按下即“咔哒”一下,不等 releasegrabMouse();update();}mapToGlobal(QPoint(0,0))是磁贴左上角的屏幕坐标;m_grab记下“指针相对左上角的偏移”,松手/拖动时还原,元素才不会跳到指针中心。
4.2 移动:1:1 跟随 + 橡皮筋
voidTile::mouseMoveEvent(QMouseEvent*e){if(!m_dragging)return;constQPointF 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);// 速度历史:最近一帧的 (位置, 时刻),供松手时算手指速度constqint64 t=m_clock.nsecsElapsed();constdoubledt=(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;}}越界时用橡皮筋软化(越超越远,跟得越少):
autorubber=[](doubleover,doubledim){constdoublek=0.55;return(over*dim*k)/(dim+k*std::fabs(over));};4.3 释放:把速度交出去
voidTile::mouseReleaseEvent(QMouseEvent*e){m_dragging=false;m_scale.target=1.0;// 松开弹回 1.0releaseMouse();constdoublecap=6000.0;// 夹掉甩太快/卡顿造成的脏值emitreleased(this,std::clamp(m_vx,-cap,cap),std::clamp(m_vy,-cap,cap));}5. 主窗口:FluidDemo的节拍器与归位
5.1 中央节拍器
voidFluidDemo::tickAll(){constqint64 now=m_clock.nsecsElapsed();boolmoving=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();// 全部静止 → 停表}boolTile::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 释放后:动量投射 → 最近空闲吸附点
voidFluidDemo::onTileReleased(Tile*self,doublevx,doublevy){constboolreduced=m_reducedChk->isChecked();// 动量投射:用释放速度把“静止位置”投出去(指数衰减,非物理 v²/2a)autoproject=[](doublev){return(v/1000.0)*0.998/(1.0-0.998);};constQPoint center=self->geometry().topLeft()+QPoint(Tile::Size/2,Tile::Size/2);constQPoint proj=center+QPoint(int(project(vx)),int(project(vy)));constintidx=pickSlot(self,proj);// 投射点最近的、且没被别的磁贴占住的吸附点self->setSlotIndex(idx);self->setTarget(m_slots.value(idx),reduced?0:vx,reduced?0:vy);// 速度交接ensureTimer();}pickSlot:对全部吸附点按到proj的距离排序,返回第一个“未被其它磁贴占住”的,避免两块叠在一起。
Tile::setTarget:
voidTile::setTarget(QPoint topLeft,doublevx,doublevy){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。效果是“快速落定、不甩、不回弹”的等价反馈,而非无反馈。生产环境应读系统开关:
constboolsysReduced=QGuiApplication::styleHints()->animationDuration()==0;