本文是《用 Qt 实现电子白板》的其中一节,建议全章阅读。
在电子白板中,针对控件的选择、二维空间变换编辑操作是最基本的交互逻辑。所谓空间变换编辑操作,是对控件进行平移、缩放、旋转,所依赖的设备主要有鼠标、触摸,以及部分键盘按键。
QGraphicsItem 提供了一些的选择、平移的功能,比如 ItemIsSelectable、ItemIsMovable,但是不能完全满足我们的需求,所以需要我们自己实现所有功能。
控件选择
给定一个点,这个点命中哪个控件?问题的解答并不是那么简单。
现实情况是,这个点可能会命中多个控件(因为控件层叠),当然可以规定命中最上面一个。然而,有可能这个点命中了控件的透明部分(比如不规则几何图形的外围),那就需要跳过这一层,也有可能需要根据控件的状态来判断(比如画笔控件,在书写打开、关闭模式下的不同的行为)。
作为一个完整的方案,还需要考虑更多因素、细节。我们的目标是让控件选择逻辑与控件自己的编辑交互(如点击绘图框是为了绘制笔迹),能够有机的融合在一起。
所以,我们让控件自己执行命中测试,测试返回有三种可能:
- 【阻止】,则跳出处理,未选中如何控件
- 【命中】,则选中该控件
- 【透过】,则继续测试下一个控件
大概的测试流程是这样的:
一开始,上层的控件都返回【透过】,继续测试,直到有一个控件返回不是【透过】。如果返回的是【命中】,那么就选择该控件,如果返回【阻止】,意味着控件自己想处理该操作,此时也是停止命中测试,但是不选择任何控件。
这里需要用到 QGraphicsScene::items(QPointF) 方法,它返回一个点下面的所有 QGraphicsItem,这些 QGraphicsItem 的形状(shape)包含该点。一个 QGraphicsItem 的形状可以是不规则图形(可以看到它是用 QPainterPath 表示的)。
另外,在调用控件的命中测试时,需要将点坐标转换到该控件的相对坐标系中,使用 QGraphicsItem::mapToItem 完成。
this->mapToItem(control->item(), pos)
mapToItem 还有个简写方法 mapToParent,以及反向操作 mapFromItem。
空间变换
一般针对控件的空间变换编辑是由控件自己处理的(就是 QGraphicsItem 自己实现的那样),但是我们提出了另一个思路,就是由一个全局的 QGraphicsItem 代理所有控件的空间变换编辑。这种方案有下列优势:
- 统一了编辑框(如下图)的操作管理,编辑框有 8 个可能的拖拽句柄
- 对一些简单的控件来说,完全不需要操心处理鼠标、触摸事件
- 输入的坐标点是相对于一个固定的坐标系,不会因为控件移动而导致坐标系跟着移动
其中第三点比较关键。可能有些人在处理视图平移时,会发现视图会不听话的来回抖动,那可能就是使用了相对控件自身的坐标值。
有时候,还需要在更高层的坐标系中处理位置编辑,这时我们会使用场景的坐标(scenePos)作为输入。
位置编辑本质上是在操作 的二维变换矩阵,我们在下一节会做详细介绍。
鼠标事件
在 QGraphicsItem 中,需要明确声明接收鼠标事件,QGraphicsScene 才能给它分发鼠标事件(如果收不到鼠标事件,一般就是这个原因):
setAcceptedMouseButtons(Qt::LeftButton);
处理鼠标事件通常需要实现下面三个方法:
void ItemSelector::mousePressEvent(QGraphicsSceneMouseEvent *event)
void ItemSelector::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
void ItemSelector::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
鼠标事件中,有三个坐标值:pos,scenePos,screenPos,分别是相对于当前 QGraphicsItem,场景 QGraphicsScene 和 屏幕的位置。可以根据需要选择使用。
这里需要注意的是,event 默认已经 accept 了,不做任何处理,事件不会再分发给其他(层级靠下面的)控件了,所以当没有选中任何控件时,需要重置 accepted 标记,可以用下面两种方法:直接调用事件的 ignore() 方法,或者转发给父类处理。最终的父类 QGraphicsItem 默认处理还是调用 ignore() 方法。
event->ignore()
或者
SuperClass::mousePressEvent(event);
在触摸输入的情况下,也会收到鼠标事件,这是因为 Qt 会用没有处理的触摸事件,合成(转化为)鼠标事件。另外,在 Windows 系统中,应用会先后从系统收到触摸、鼠标事件,Qt 也原样转发,但是不再自己合成鼠标事件。
总的来说,我们需要针对合成的鼠标事件做处理,大部分情况下,我们判断是否还在处理触摸事件的过程中,如果是,就忽略鼠标事件。
void ItemSelector::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
#if ENBALE_TOUCH
if (!touchPositions_.empty()) {
return;
}
#endif
......
}
当然,也可以显式判断鼠标事件是否是合成的,比如:
if (event->source() != Qt::MouseEventNotSynthesized) {
QGraphicsItem::mousePressEvent(event);
return;
}
触摸事件
在 QGraphicsItem 中,需要明确声明接收触摸事件,QGraphicsScene 才能给它分发触摸事件:
setAcceptTouchEvents(true);
处理触摸事件没有各种子方法,统一在 sceneEvent 方法中处理:
bool ItemSelector::sceneEvent(QEvent *event)
{
switch (event->type()) {
case QEvent::TouchBegin:
touchBegin(static_cast<QTouchEvent*>(event));
break;
case QEvent::TouchUpdate:
touchUpdate(static_cast<QTouchEvent*>(event));
break;
case QEvent::TouchEnd:
touchEnd(static_cast<QTouchEvent*>(event));
break;
}
}
触摸事件支持多指触摸,所有事件中包含 TouchPoint 数组。每个 TouchPoint 被分配了一个唯一的 id,还有四个坐标值:pos,scenePos,screenPos,normalizedPos() ,每种坐标还有 startXXXPos,lastXXXPos 表示触摸开始的位置和是一个事件的位置。
一般情况下,我们需要跟踪每个手指的滑动轨迹,已经手指个数变化的情况,所以使用一个 map(以 TouchPoint 的 id 作为 key)来保存手指的位置(并没有使用 lastXXXPos)。
在做控件位置编辑时,两个手指作为手势操作处理,其他情况只作为平移操作处理(只考虑第一个手指的位置)。两种情况都需要确保在上一个事件也有同样的 TouchPoint id,只有对边前后坐标值才能计算位置变化。
除了触摸屏,笔记本的触摸板(TouchPad)也是触摸事件的来源。在 Windows 中,触摸板一般发出的是鼠标事件,苹果笔记本(MacBook)则是触摸事件。
但是在 MacBook 中处理触摸事件,是比较麻烦的。它的 TouchPad 可以改变鼠标位置,手指按下去的触摸事件中的位置就是鼠标位置,但是接下来的触摸事件中的位置就与鼠标位置不同步了。这部分需要继续研究,有结果后再来更新。
滚轮事件
滚轮也可以用来平移、缩放控件。实现 wheelEvent 处理滚轮事件。
void ItemSelector::wheelEvent(QGraphicsSceneWheelEvent *event)
与鼠标事件一样,滚轮事件也有坐标位置(也是鼠标位置),但还有另外一个数值 delta,表示滚动的距离(一般是固定值,可正负,系统设置里面可以修改)。
使用坐标位置,可以测试命中的控件。
可以结合按键(Ctrl、Shift)来切换滚轮的操作效果。正常作为平移操作处理,按住 Ctrl 时,作为缩放操作处理,按住 Shift 可以切换平移的方向(从上下切换为左右)。为了方便处理,Qt 已经在滚轮事件的 modifiers() 方法中返回这些按键状态了。
贴一段处理滚轮的相对完整的代码:
void ItemSelector::wheelEvent(QGraphicsSceneWheelEvent *event)
{
selectAt(mapFromItem(currentEventSource_, event->pos()), event->scenePos(), Wheel);
if (tempControl_) {
if (event->modifiers().testFlag(Qt::KeyboardModifier::ControlModifier)) {
qreal delta = event->delta() > 0 ? 1.2 : 1.0 / 1.2;
tempControl_->scale(tempControl_->item()->mapFromScene(event->scenePos()), delta);
} else {
QPointF d;
if (event->modifiers().testFlag(Qt::KeyboardModifier::ShiftModifier)) {
d.setX(event->delta());
} else {
d.setY(event->delta());
}
tempControl_->move(d);
}
selectRelease(Wheel);
} else {
event->ignore();
}
}