文件列表的惯性滚动包含两件事:拖动时内容跟随指针,松开后按速度继续移动并减速。Qt Widgets 已提供 QScroller;需要研究滚轮策略、速度采样或自定义衰减时,再使用自行维护状态的管理器。两种实现不要同时控制同一个 viewport。
本文 C++ 示例的 --self-test 使用 assert,应在 Debug 构建或未定义 NDEBUG 的情况下运行;Release 构建若禁用了断言,退出成功不代表这些检查已经执行。
先使用 QScroller
跳转到“先使用 QScroller”下面保留原例的 100 项文件列表,明确设置按像素滚动。保存为 main.cpp,使用 Qt 6 Widgets、C++17 构建。默认以鼠标左键拖动;如果界面主要使用触摸输入,可把手势类型替换为 TouchGesture,不是在同一目标上连续调用两次来叠加两种手势。
#include <QApplication>#include <QListWidget>#include <QScroller>#include <QScrollerProperties>#include <cassert>
int main(int argc, char *argv[]) { QApplication app(argc, argv); QListWidget list; for (int i = 0; i < 100; ++i) list.addItem(QString("File %1").arg(i)); list.setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); list.resize(400, 300); const auto gesture = QScroller::grabGesture( list.viewport(), QScroller::LeftMouseButtonGesture); auto *scroller = QScroller::scroller(list.viewport()); auto properties = scroller->scrollerProperties(); properties.setScrollMetric(QScrollerProperties::DragStartDistance, 0.001); properties.setScrollMetric(QScrollerProperties::DecelerationFactor, 0.3); scroller->setScrollerProperties(properties); if (app.arguments().contains("--self-test")) { assert(list.count() == 100); assert(list.verticalScrollMode() == QAbstractItemView::ScrollPerPixel); assert(scroller->target() == list.viewport()); assert(QScroller::grabbedGesture(list.viewport()) == gesture); assert(scroller->scrollerProperties().scrollMetric( QScrollerProperties::DragStartDistance).toReal() == 0.001); QScroller::ungrabGesture(list.viewport()); return 0; } list.show(); return app.exec();}本机 Qt 6.10.2 实测中,ungrabGesture() 后 grabbedGesture() 仍返回先前的类型值。该版本实现注销识别器并清空识别器指针,但未重置记录的类型字段;因此例子不把“解除后一定返回 0”作为断言,也不把残留类型值当成手势仍有效的证据。这是版本实现与文档描述之间的差异。Qt 6.10.2 QScroller 源码
DragStartDistance 的单位是米,0.001 表示 1 mm,由 Qt 结合 DPI 换算。QScroller 的 DecelerationFactor 与所选曲线共同影响减速,文档对多数曲线给出的常用范围为 0.1–2.0,不能概括成固定的 0–1 每帧保留系数。原值 0.05 是特定调参值;本例采用 0.3 示范,实际先试平台默认值。QScrollerProperties
左键手势与项目选择、拖放有交互关系。QScroller 的手势识别会处理拖动阈值和鼠标事件取消;若文件列表还要支持框选或拖放,可选择中键手势或仅触摸手势,并实际检查点击、拖动和松开。只检验参数值不等于验证不同设备的手感。QScroller
原自定义管理器的参数与需要修正的边界
跳转到“原自定义管理器的参数与需要修正的边界”原实现的设计是过滤 viewport 事件、保留最近五次坐标和时间、松开后估算速度,再以 16 ms 定时器更新横纵滚动条。这个思路仍然保留,但每项参数必须有清楚单位。
| 原参数或机制 | 保留的含义 | 修正点 |
|---|---|---|
| 定时器 16 ms | 约 60 次/秒请求更新 | 实际回调可能延迟,积分使用真实经过时间 |
| 五个采样点 | 估计松开前近期速度 | 以总位移/总时间加权估计;丢弃超过 100 ms 的旧点,停住后不再误甩动 |
| 系数 0.95 | 每 16 ms 保留原速度的 95% | 这是自定义速度保留率;数值越小减速越快,与 QScroller 同名参数不同 |
| 最大速度 2000 | 每轴初始速度上限,px/s | 拖动和滚轮统一限制,不把坐标差直接当速度 |
| 停止阈值 10 | 速度向量长度阈值,px/s | 到边界时也停止该轴的速度 |
| 滚轮系数 15 | 每度滚轮角度产生 15 px/s 初始速度 | angleDelta/8 是角度,不是像素;一个 120 单位刻度对应 15 度、225 px/s |
| 滚轮惯性开关 | 可把滚轮交还 Qt 原生处理 | 带 pixelDelta 或滚动 phase 的设备默认交还原生,避免双重惯性 |
| 鼠标拖动 | 追踪按下、移动和释放 | 自定义示例占用中键,保留左键选择;增加距离阈值和丢失按键取消 |
原代码用 QTime::start() 和墙上时间时间戳,既涉及 Qt 6 兼容性,也可能遇到系统时间调整。下面用 QElapsedTimer 的单调经过时间。对 QAbstractItemView 必须设为 ScrollPerPixel,否则一个滚动条单位可能是一行,而不是速度计算中的一个像素。QElapsedTimer、QAbstractItemView 滚动模式
完整自定义例子:二维拖动、滚轮与时间积分
跳转到“完整自定义例子:二维拖动、滚轮与时间积分”下面是独立于上一个例子的完整 main.cpp。管理器作为 view 的 QObject 子对象销毁,析构不再通过可能已失效的原始指针访问 viewport。例子面向一个生命周期内不替换 viewport 的 Widgets 视图;更换 viewport 后应重建管理器。
衰减采用 k = -ln(0.95)/0.016。经过 dt 秒后,速度为 v·exp(-k·dt),这段位移为 v·(1-exp(-k·dt))/k。保留浮点位置,最后写滚动条时才取整,避免低速时每帧的小位移全部丢失。
#include <QApplication>#include <QAbstractItemView>#include <QElapsedTimer>#include <QEvent>#include <QMouseEvent>#include <QObject>#include <QPointF>#include <QPointer>#include <QScrollBar>#include <QTableWidget>#include <QTimer>#include <QVector>#include <QWheelEvent>#include <algorithm>#include <cassert>#include <cmath>#include <stdexcept>
struct DecayStep { QPointF displacement; QPointF velocity;};
static DecayStep exponentialStep(QPointF velocity, double retention, double dt) { if (!(retention > 0 && retention < 1) || !std::isfinite(dt) || dt < 0) throw std::invalid_argument("invalid decay parameters"); const double k = -std::log(retention) / 0.016; const double factor = std::exp(-k * dt); return {velocity * (-std::expm1(-k * dt) / k), velocity * factor};}
class InertialScroller final : public QObject {public: explicit InertialScroller(QAbstractItemView *view) : QObject(view), view_(view) { if (!view) throw std::invalid_argument("view is required"); view->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel); view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); view->viewport()->installEventFilter(this); clock_.start(); timer_.setInterval(16); timer_.setTimerType(Qt::PreciseTimer); connect(&timer_, &QTimer::timeout, this, [this] { const qint64 now = clock_.nsecsElapsed(); const double dt = (now - lastTick_) / 1e9; lastTick_ = now; advance(dt); }); }
void setDecelerationFactor(double factor) { requireFinite(factor); retention_ = std::clamp(factor, 0.1, 0.99); } void setMaxSpeed(double speed) { requireFinite(speed); maxSpeed_ = std::max(1.0, speed); } void setStopThreshold(double speed) { requireFinite(speed); threshold_ = std::max(1.0, speed); } void setWheelSpeedFactor(double factor) { requireFinite(factor); wheelFactor_ = std::max(1.0, factor); } void setWheelInertiaEnabled(bool enabled) { wheelEnabled_ = enabled; if (!enabled) stopScrolling(); } bool isScrolling() const { return timer_.isActive(); } void stopScrolling() { timer_.stop(); velocity_ = {}; } void startScrolling(QPointF velocity) { requireFinite(velocity.x()); requireFinite(velocity.y()); stopScrolling(); if (!view_) return; velocity_ = {std::clamp(velocity.x(), -maxSpeed_, maxSpeed_), std::clamp(velocity.y(), -maxSpeed_, maxSpeed_)}; position_ = actualPosition(); lastWritten_ = actualPosition(); if (speed(velocity_) < threshold_) return; lastTick_ = clock_.nsecsElapsed(); timer_.start(); }
protected: bool eventFilter(QObject *watched, QEvent *event) override { if (!view_ || watched != view_->viewport()) return false; if (event->type() == QEvent::Hide || event->type() == QEvent::UngrabMouse) { pressed_ = dragging_ = false; stopScrolling(); return false; } if (event->type() == QEvent::MouseButtonPress) { auto *mouse = static_cast<QMouseEvent *>(event); stopScrolling(); pressed_ = dragging_ = false; if (mouse->button() != Qt::MiddleButton) return false; pressed_ = true; press_ = last_ = mouse->position(); position_ = actualPosition(); samples_.clear(); record(press_); return true; } if (event->type() == QEvent::MouseMove && pressed_) { auto *mouse = static_cast<QMouseEvent *>(event); if (!mouse->buttons().testFlag(Qt::MiddleButton)) { pressed_ = dragging_ = false; return false; } const QPointF point = mouse->position(); record(point); if (!dragging_ && (point - press_).manhattanLength() < QApplication::startDragDistance()) return true; dragging_ = true; writePosition(position_ + last_ - point); last_ = point; return true; } if (event->type() == QEvent::MouseButtonRelease && pressed_) { auto *mouse = static_cast<QMouseEvent *>(event); if (mouse->button() != Qt::MiddleButton) return false; pressed_ = false; const QPointF point = mouse->position(); record(point); if (dragging_) { writePosition(position_ + last_ - point); if (samples_.size() >= 2) { const auto &first = samples_.first(); const auto &last = samples_.last(); const double dt = (last.time - first.time) / 1e9; if (dt > 0) startScrolling((first.point - last.point) / dt); } } dragging_ = false; return true; } if (event->type() == QEvent::Wheel) { auto *wheel = static_cast<QWheelEvent *>(event); stopScrolling(); pressed_ = dragging_ = false; if (!wheelEnabled_ || !wheel->pixelDelta().isNull() || wheel->phase() != Qt::NoScrollPhase || wheel->modifiers() != Qt::NoModifier || wheel->angleDelta().isNull()) return false; const QPointF degrees = -QPointF(wheel->angleDelta()) / 8.0; const QPointF notches = -QPointF(wheel->angleDelta()) / 120.0; position_ = actualPosition(); writePosition(position_ + QPointF( notches.x() * view_->horizontalScrollBar()->singleStep(), notches.y() * view_->verticalScrollBar()->singleStep())); startScrolling(degrees * wheelFactor_); return true; } return false; }
private: struct Sample { QPointF point; qint64 time; }; QPointer<QAbstractItemView> view_; QTimer timer_; QElapsedTimer clock_; QVector<Sample> samples_; QPointF press_, last_, position_, velocity_, lastWritten_; qint64 lastTick_ = 0; double retention_ = 0.95, maxSpeed_ = 2000, threshold_ = 10; double wheelFactor_ = 15; bool wheelEnabled_ = true, pressed_ = false, dragging_ = false;
static void requireFinite(double value) { if (!std::isfinite(value)) throw std::invalid_argument("finite value required"); } static double speed(QPointF value) { return std::hypot(value.x(), value.y()); } QPointF actualPosition() const { return {double(view_->horizontalScrollBar()->value()), double(view_->verticalScrollBar()->value())}; } void record(QPointF point) { const qint64 now = clock_.nsecsElapsed(); samples_.push_back({point, now}); while (samples_.size() > 5 || (samples_.size() > 1 && now - samples_.first().time > 100000000)) samples_.removeFirst(); } void writePosition(QPointF desired) { auto *horizontal = view_->horizontalScrollBar(); auto *vertical = view_->verticalScrollBar(); position_ = {std::clamp(desired.x(), double(horizontal->minimum()), double(horizontal->maximum())), std::clamp(desired.y(), double(vertical->minimum()), double(vertical->maximum()))}; horizontal->setValue(qRound(position_.x())); vertical->setValue(qRound(position_.y())); lastWritten_ = actualPosition(); } void advance(double dt) { if (!view_ || pressed_ || !view_->isEnabled() || view_->horizontalScrollMode() != QAbstractItemView::ScrollPerPixel || view_->verticalScrollMode() != QAbstractItemView::ScrollPerPixel || actualPosition() != lastWritten_) { stopScrolling(); return; } const auto step = exponentialStep(velocity_, retention_, dt); velocity_ = step.velocity; writePosition(position_ + step.displacement); auto stopAtEdge = [](QScrollBar *bar, double position, double velocity) { if ((position <= bar->minimum() && velocity < 0) || (position >= bar->maximum() && velocity > 0)) return 0.0; return velocity; }; velocity_.setX(stopAtEdge(view_->horizontalScrollBar(), position_.x(), velocity_.x())); velocity_.setY(stopAtEdge(view_->verticalScrollBar(), position_.y(), velocity_.y())); if (speed(velocity_) < threshold_) stopScrolling(); }};
int main(int argc, char *argv[]) { QApplication app(argc, argv); QTableWidget table(120, 40); table.setWindowTitle("Middle drag; left click still selects"); auto *scroller = new InertialScroller(&table); table.resize(520, 320); if (app.arguments().contains("--self-test")) { assert(scroller->parent() == &table); assert(table.verticalScrollMode() == QAbstractItemView::ScrollPerPixel); const auto full = exponentialStep({1000, -500}, 0.95, 0.032); const auto first = exponentialStep({1000, -500}, 0.95, 0.016); const auto second = exponentialStep(first.velocity, 0.95, 0.016); assert(std::hypot((full.displacement - first.displacement - second.displacement).x(), (full.displacement - first.displacement - second.displacement).y()) < 1e-9); assert(std::abs(full.velocity.x() - 1000 * 0.95 * 0.95) < 1e-9); scroller->startScrolling({0, 5}); assert(!scroller->isScrolling()); scroller->startScrolling({0, 200}); assert(scroller->isScrolling()); scroller->setWheelInertiaEnabled(false); assert(!scroller->isScrolling()); return 0; } table.show(); return app.exec();}输入与验证范围
跳转到“输入与验证范围”自定义例子的中键按下、移动和释放全部由过滤器接管,超过系统拖动距离才移动。左键按下只停止惯性,之后交给视图继续选中项目;这避免原代码一边滚动、一边把同一套左键序列完整交给项目选择。若产品要求左键拖动与短按选择共存,需要完整的延迟、取消和转发策略,可使用上面的 QScroller 路径,不能只把 MiddleButton 换成 LeftButton 就认为交互等价。
普通离散滚轮的初次移动与后续速度都使用相同符号,修正原代码“先向一个方向走一步,随后反向惯性”的问题。初次移动是一刻度对应一个 singleStep(),不是操作系统的所有滚轮行数策略。带像素增量、滚动阶段或修饰键的事件返回 false,先停止自定义动画后交给 Qt 原生处理;由此保留触摸板的原生惯性和 Ctrl 等组合键行为。事件的 inverted() 是系统自然滚动状态说明,delta 已反映方向,不应无条件再反转一次。QWheelEvent
程序的 self-test 检查停止阈值、组装关系和时间积分一致性。本次离屏事件测试另外覆盖了:小幅中键抖动不滚动、正常拖动后继续移动、停顿后松开不甩动、滚轮即时和惯性方向一致、外部滚动条修改中止动画、两个方向到边界停止,以及 view 销毁时管理器一同消失。真实触摸设备和不同平台的自然滚动体验仍需实机验证,不能由离屏事件测试外推。
原文来源:CSDN:Qt 浏览文件支持惯性,首次发布于 2025-05-21。