
🫧 励志不掉头发的内向程序员 :个人主页
✨️ 个人专栏: 《C++语言》《Linux学习》
🌅偶尔悲伤,偶尔被幸福所完善
👓️博主简介:

文章目录
- 前言
- 一、平移改的不是图纸,是"你在看哪儿"
- 二、秘诀:抓住一个点,而不是累加位移
-
- 2.1、先说一个看起来更直观、但会出问题的写法
- 2.2、正确的思路:记住"抓住的那个点"
- [2.3、alignTo 里的那个加号](#2.3、alignTo 里的那个加号)
- 三、三个状态,三个事件函数
- [四、收回第一个尾巴:坐标轴改用 toScreen](#四、收回第一个尾巴:坐标轴改用 toScreen)
- [五、收回第三个尾巴:resizeEvent 保持视图中心](#五、收回第三个尾巴:resizeEvent 保持视图中心)
-
- 5.1、注意这里用的是"同一个函数"
- [5.2、那个 oldSize 判断不是多余的](#5.2、那个 oldSize 判断不是多余的)
- 5.3、为什么必须先算、再交给基类
- 六、第三个尾巴,有个意外的结局
- 七、这一版的成果
- 总结
前言
上一篇把 originX / originY / scale 打包成了 View,结尾我列了三个尾巴:
- 画坐标轴的时候绕过了
toScreen,直接读offsetX / offsetY - 两个 getter 没有
const resizeEvent会把图纸拽回窗口中心
这一篇做中键拖动平移 ,顺手把第一和第三个尾巴收掉。第二个尾巴有个意外的结局,最后一节说。不过在写代码之前,有一件事得先想明白------平移的时候,到底什么东西变了? 答案是:图纸上没有任何一个数字发生变化。

一、平移改的不是图纸,是"你在看哪儿"
这是 CAD 和很多 2D 绘图程序最容易被想歪的一点。
如果拿一个记事本当类比:把文字往右移,你会去改每个字符的位置。但在 CAD 里拖动图纸,两千条线的坐标一个都不会变 ------变的只是 View 里的那两个偏移量。
| 平移前 | 平移后 | |
|---|---|---|
图元坐标(Line::startPoint 等) |
(0,0) → (100,0) |
(0,0) → (100,0) 一模一样 |
offsetX / offsetY |
400 / 300 |
120 / 520 |
| 屏幕上看到的位置 | 靠中间 | 挪到左上 |
"图纸"和"看图纸的窗口"是两样东西。 你在纸上滑动一张透明胶片,胶片上的图形没动,动的是它相对窗口的位置------CAD 的平移就是这个。

想清楚这一点,第 3 篇为什么要把 View 单独抽出来就一目了然了:因为平移只改 View,一个字符都不用碰 Document。 如果当初那三个数还散在 Canvas 里、和"鼠标点在哪"混在一块儿,现在做平移就得在一堆交互状态里翻找。
二、秘诀:抓住一个点,而不是累加位移
2.1、先说一个看起来更直观、但会出问题的写法
很多人第一反应是这样:鼠标每移动一次,就算出它相对上一次移动了多少像素,然后把这个差值加到偏移量上。
cpp
// 不推荐的写法
offsetX += screenPos.x() - lastMousePos.x();
offsetY += screenPos.y() - lastMousePos.y();
lastMousePos = screenPos;
它能跑,但有两个毛病:
一是误差会累积。 每次移动都要算一次减法、做一次加法,整数像素的取整误差一点点攒起来。拖久了,你会发现"我明明拖回原地,图纸却没完全回到原来的位置"。
二是松手再按就要重新对准。 因为这套逻辑依赖"上一次鼠标在哪",而这个量一旦断了(松手、切窗口、切工具),就得重新建立基准。基准没建好的那一帧,图会跳一下。
2.2、正确的思路:记住"抓住的那个点"
换个角度想:拖动的时候,真正在发生的是------你用手指按住纸上的某一个点,然后让这个点始终跟着你的手指。
所以只需要记住一件事:按下鼠标那一刻,鼠标底下是图纸上的哪个点。
cpp
void Canvas::mousePressEvent(QMouseEvent *event)
{
if(event->button() == Qt::MiddleButton) {
isPanning = true;
anchorCAD = view->toCAD(event->pos()); // ← 就记这一个点
}
QWidget::mousePressEvent(event);
}
anchorCAD 就是那个"手指按住的点"。之后鼠标每一次移动,做的事都一样:
cpp
void Canvas::mouseMoveEvent(QMouseEvent *event)
{
QPoint screenPosition = event->pos();
if(isPanning) {
view->alignTo(anchorCAD, screenPosition); // 让 anchor 回到鼠标底下
}
mousePosition = view->toCAD(screenPosition);
update();
}
没有累加,没有"上一次",每次都是从 anchor 重新算一遍。 误差不可能累积,因为压根没有地方给它攒。

2.3、alignTo 里的那个加号
cpp
void View::alignTo(const Point& cadPoint, const QPoint& screenPos)
{
offsetX = screenPos.x() - cadPoint.x * scale;
offsetY = screenPos.y() + cadPoint.y * scale;
}
第一行减号、第二行加号,很多人第一次写会以为第二个也是减号,然后发现"横向拖是对的,纵向拖反了"。
但这两个符号其实不用背,它是从 toScreen 反解出来的。
要让"图纸上的 cadPoint"显示在"屏幕上的 screenPos",就是把 toScreen 的输入和输出对调:
已知: screenPos.x = offsetX + cadPoint.x * scale
求: offsetX = ?
两边减去 cadPoint.x * scale:
offsetX = screenPos.x - cadPoint.x * scale ← 第一个式子
已知: screenPos.y = offsetY - cadPoint.y * scale
两边加上 cadPoint.y * scale:
offsetY = screenPos.y + cadPoint.y * scale ← 第二个式子,加号自己冒出来了
第二行的加号,就是从"屏幕 Y 轴朝下、图纸 Y 轴朝上"那个负号里还回来的。 记住这条推导路径,比记住两个符号靠谱。
(顺便说:alignTo 这个函数名值得留意。它没有叫 translate(平移),而是叫"对齐到"------因为它的语义是把某个图纸点对齐到某个屏幕位置。名字说的是意图,不是实现。)
三、三个状态,三个事件函数
这一版给 Canvas 加了一次 mousePressEvent(之前只有 move 和 release),成员也多了一个:
cpp
protected:
void mousePressEvent(QMouseEvent *event) override; // ← 新增
private:
bool isPanning; // 中键是不是按着
Point anchorCAD; // 按下去那一刻,鼠标底下的那个图纸点
按下 → 记 anchor;移动 → 对齐;松开 → 关掉标记:
cpp
} else if(event->button() == Qt::MiddleButton) {
isPanning = false;
}
三个细节:
一、为什么 anchor 要在 mousePress 里记,不在 mouseMove 里记"第一次移动"。
因为按下和第一次移动之间隔着一段时间 。这段时间里用户可能已经在移动鼠标了------如果等到第一次 move 才记 anchor,那记的就是"已经移动过一点之后"的位置,图纸会先跳一下再跟手。"抓住"这个动作发生在按下那一刻,状态就应该在那一刻建立。
二、mousePressEvent 末尾那一句 QWidget::mousePressEvent(event); 别漏。
你自己只处理了中键,左键右键还得让基类有机会处理。这是 Qt 事件处理的一条通用规矩:处理了可以返回,没处理的一定要交给基类------否则某些默认行为(比如焦点转移)会莫名其妙失效。
三、中键在 CAD 里是个有传统的键。
AutoCAD 里中键按住是平移,滚轮是缩放,这就是为什么这一版选它。左键留给"确定",右键留给"取消/结束"------第 2 篇已经占上了。三个键各管一件事,不用教,用惯 CAD 的人自己就会。
四、收回第一个尾巴:坐标轴改用 toScreen
上一篇我在 paintEvent 里留了这样一段:
cpp
// 之前的写法:绘制代码直接读 offset
painter.drawLine(0, static_cast<int>(view->getOffsetY()), width(), static_cast<int>(view->getOffsetY()));
painter.drawLine(static_cast<int>(view->getOffsetX()), 0, static_cast<int>(view->getOffsetX()), height());
painter.drawEllipse(QPoint(static_cast<int>(view->getOffsetX()), static_cast<int>(view->getOffsetY())), 4, 4);
这一版改成了:
cpp
// CAD 原点 (0, 0) 落在屏幕上的哪个像素------它就是十字轴的交点
const QPoint originScreen = view->toScreen(Point(0.0, 0.0));
painter.setPen(QPen(Qt::black, 1));
painter.drawLine(0, originScreen.y(), width(), originScreen.y());
painter.drawLine(originScreen.x(), 0, originScreen.x(), height());
painter.setBrush(Qt::red);
painter.drawEllipse(originScreen, 4, 4);
为什么这两版效果一样,但只有新版是对的? 因为绘制代码的职责是"把图纸上的东西画到屏幕上",它该问的问题是**"图纸原点的屏幕位置在哪",而不是"你的偏移量是多少"**。
旧写法有个隐藏的假设:"图纸原点"和"偏移量"是同一件事 。这个假设在今天成立,因为 offsetX/offsetY 的定义恰好就是"原点画在屏幕的哪一列/哪一行"。
但假设就是用来被打破的。哪天坐标系要支持旋转,或者视图要支持"多视口各自偏移",toScreen 还是那个 toScreen,而"直接读 offset"那三行就得整体重写。
这也是"封装"最容易退化的地方:不是接口被改坏了,而是接口还在、但调用方绕过去了。 上一篇我把 toScreen 抽出来了,可画坐标轴的时候图省事直接读了 offset------抽了等于白抽。判断标准很简单:
在这个函数里,有没有出现
View内部成员的名字? 出现了,就是绕过。
五、收回第三个尾巴:resizeEvent 保持视图中心
上一篇那个版本是这样的:
cpp
void Canvas::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
recenterView(); // ← 把视图拽回窗口中心
}
在"没有平移"的时候看不出问题(反正原点本来就在窗口中心)。但平移做完之后,它的症状立刻显形:你把图纸拖到角落,随手拉一下窗口,图纸"啪"地跳回中间。
原因很直白:recenterView() 干的事是"把偏移量设成新窗口的中心",它完全不管你之前在看哪儿。
正确的做法是要保住一件事:窗口中心对应的那个图纸点,尺寸变完之后还得在新的窗口中心。
cpp
void Canvas::resizeEvent(QResizeEvent *event)
{
const QSize oldSize = event->oldSize();
// 首次显示时 oldSize 是无效尺寸(-1,-1),没有"旧中心"可言,直接居中
if (!oldSize.isValid()) {
QWidget::resizeEvent(event);
recenterView();
return;
}
// 1. 用【旧尺寸的中心】和【当前的 offset】,算出那里是图纸上的哪个点
const Point centerCAD = view->toCAD(QPoint(oldSize.width() / 2, oldSize.height() / 2));
// 2. 正常处理尺寸变化
QWidget::resizeEvent(event);
// 3. 让这个 CAD 点回到【新尺寸的中心】
view->alignTo(centerCAD, QPoint(width() / 2, height() / 2));
}

5.1、注意这里用的是"同一个函数"
第三步调的还是第二节那个 alignTo。
这一点值得单独说:alignTo(cadPoint, screenPos) 这个函数,在平移里是"让抓着的点跟住鼠标",在 resize 里是"让中心点跟住新中心"------两个完全不同的功能,用的是同一行代码。
这不是巧合,是因为它们本来就是同一件事:把某个图纸点摆到某个屏幕位置。
一个函数如果抽象对了,它会在你意想不到的地方被复用。 反过来,如果当初 alignTo 写成 panBy(dx, dy)(按位移量平移),那 resize 里就用不上它------因为 resize 要的不是"移动多少",而是"让谁到哪儿"。
5.2、那个 oldSize 判断不是多余的
cpp
if (!oldSize.isValid()) {
...
return;
}
控件第一次显示时也会触发 resizeEvent,而此时 oldSize 是 (-1, -1)。 拿它去算"旧窗口的中心"会得到一个负数坐标,然后再让这个不存在的点回到新中心------视图就歪了。
这个边界很容易漏,因为它的症状只在程序刚启动的那一帧出现,之后你随便拉一下窗口就好了,很容易被当成"启动时闪一下"忽略过去。
判据 :QSize::isValid() 在宽高都非负时为真,而 (-1,-1) 恰好是 Qt 用来表示"没有旧尺寸"的哨兵值。凡是拿到 event->oldSize() 的地方,都该先问一句它是不是有效的。
5.3、为什么必须先算、再交给基类
三步的顺序不能换:
cpp
const Point centerCAD = view->toCAD(...); // 1. 先算------此时 offset 还是旧的
QWidget::resizeEvent(event); // 2. 交给基类------尺寸真正改变
view->alignTo(centerCAD, ...); // 3. 再对齐------用新的 width()/height()
因为第 1 步依赖旧的 offset (还没被改),第 3 步依赖新的 width() / height()(基类处理完才更新)。顺序一反,算出来的就是"新尺寸配旧偏移"的四不像。
六、第三个尾巴,有个意外的结局
上一篇我还留了一个尾巴:getOffsetX() / getOffsetY() 没有 const,而且"这两个 getter 本来就不该存在"。
这一版的结局有点意思:坐标轴改用 toScreen 之后,这两个 getter 就彻底没人调用了。
我搜过整个工程------定义还在,调用点一个都没有。
cpp
double getOffsetX() { return offsetX; } // 死代码
double getOffsetY() { return offsetY; } // 死代码
这件事本身说明了一个规律:上一节那个"绕过封装"的写法,不只是风格问题------它还在给不该存在的接口续命。 要是没人绕过 toScreen,这两个 getter 在抽 View 的那天就应该直接不写。
(没有删是因为这一版的改动已经够多了,我想把"删接口"单独做一次 ,免得和功能改动混在同一个提交里------那样万一下次构建挂了,你分不清是平移写错了还是删漏了谁在用。这个理由听起来像拖延,但它确实是有意的:一次提交只干一件事。)
七、这一版的成果
现在能做的:
- 中键按住拖动:图纸跟着鼠标走,拖到哪儿停哪儿
- 拉窗口:视图中心对应的图纸点保持不变(不再弹回中间)
- 坐标轴 :跟着平移走,而且绘制代码不再偷看
View的内部
顺带说一个"另一种写法",你可能在别的 Qt 代码里见过:
cpp
// 另一种写法:不维护 isPanning,直接看"当前按着哪些键"
void Canvas::mouseMoveEvent(QMouseEvent *event)
{
if (event->buttons() & Qt::MiddleButton) {
...
}
}
buttons() 返回的是当前按着的所有键 (复数),而 button() 是触发这个事件的那个键 (单数)------这个单复数之差是 Qt 里一个经典的坑。这种写法省掉了一个状态变量,但缺点是:它没法回答"这次拖动是不是从我这个控件上开始的"。我选了显式的 isPanning,是因为状态越显式,调试时越容易看懂------鼠标事件里出错的时候,你至少能一眼看出"现在到底以为自己在拖,还是没在拖"。
总结
下一篇做以鼠标为中心的滚轮缩放,它会用到两个这次刚立起来的东西:
一是 anchor 这个套路会原样复用。 缩放的时候要保住的是"鼠标底下那个点,缩放前后待在原地"------和这一篇的 anchor 是同一个思路:先记住一个点,再做变换,最后让那个点回到原处。
二是 setScale 该有上下限了。 滚轮是连续事件,用户能连着滚二十下。不夹紧范围的话,scale 会一路乘到 inf,屏幕上什么都算不出来------这不是"缩放到极限",是"程序坏了"。上一篇我在 View 里放 setter 的时候说过这个伏笔,下一篇就轮到它了。
🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど
