跳转到内容
新建笔记

Qt 惯性滚动:QScroller、输入仲裁与时间积分

文件列表的惯性滚动包含两件事:拖动时内容跟随指针,松开后按速度继续移动并减速。Qt Widgets 已提供 QScroller;需要研究滚轮策略、速度采样或自定义衰减时,再使用自行维护状态的管理器。两种实现不要同时控制同一个 viewport。

本文 C++ 示例的 --self-test 使用 assert,应在 Debug 构建或未定义 NDEBUG 的情况下运行;Release 构建若禁用了断言,退出成功不代表这些检查已经执行。

下面保留原例的 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。