在开发图片标注、视频编辑或CAD类软件时,经常会遇到这样一个需求:在可以缩放的场景中,绘制一个带有控制点的选择框。
听起来很简单?如果你直接继承 QGraphicsRectItem,很快就会遇到两个令人抓狂的"经典BUG":
- 点击穿透:明明把控制点画在边角了,鼠标点上去却没反应,事件全被底层的图元吃了。
- 视觉变形 :当视图放大好几倍后,原本精致的调整手柄变成了巨大的色块,遮挡了图片细节;或者缩得很小时,手柄完全消失不见。
今天,我们就来一步步填平这些坑,打造一个交互完美、视觉恒定的ResizableRectItem。
第一关:解决"点击穿透"陷阱
很多初学者会认为:只要我在 paint 里画出来了,并且重写了 boundingRect 把画出来的区域包进去,鼠标就能点到了。
残酷的现实是:boundingRect 只决定了"重绘区域",而决定"鼠标点击区域"的是 shape() 方法。
QGraphicsRectItem 默认的 shape() 只包含 rect() 内部。如果你把控制点画在矩形外面(通常为了选中边框),默认的 shape() 是管不到那里的。
错误的尝试
有人会尝试在 mousePressEvent 里写复杂的坐标判断逻辑,但这无法解决 hover 事件(鼠标悬停变手势)的问题。
正确的解法
我们需要重写 shape(),构建一个包含矩形本体和所有控制点的路径。
python
def shape(self):
# 1. 包含矩形本身
path = QPainterPath()
path.addRect(self.rect())
# 2. 包含所有控制点区域
# 这里的 handle_size 需要动态计算(下文会讲)
for center in self._get_handle_centers():
rect = QRectF(
center.x() - self.handle_size / 2,
center.y() - self.handle_size / 2,
self.handle_size,
self.handle_size,
)
path.addRect(rect)
return path
关键点 :QPainterPath 默认使用 OddEvenFill(奇偶填充),如果两个矩形重叠(比如角上的控制点),重叠区域会被视为"空洞",点击依然会穿透!记得使用 path.setFillRule(Qt.FillRule.WindingFill) 来确保所有区域都是实心的。
第二关:征服"缩放变形"难题
搞定了点击,接下来是视觉体验。我们希望的效果是:无论用户把视图放大还是缩小,边框的线宽和控制点的大小在屏幕上看起来永远是不变的(比如永远是 4px 和 8px)。
原理:反向缩放
假设我们在屏幕上想要一个 8px 的正方形控制点。
- 当视图放大 2 倍时,1 个 Item 坐标单位对应 2 个屏幕像素。所以我们要画的正方形边长应该是 8/2=48 / 2 = 48/2=4 个 Item 单位。
- 当视图缩小 0.5 倍时,1 个 Item 单位对应 0.5 个屏幕像素。我们要画的正方形边长应该是 8/0.5=168 / 0.5 = 168/0.5=16 个 Item 单位。
公式很简单:Item尺寸 = 屏幕期望尺寸 / 当前缩放比例。
坑点:如何获取当前缩放比例?
这是最容易写死代码的地方。很多人会写 scene().views()[0].transform().m11()。
但问题是:views()[0] 不一定是当前正在操作的视图。 如果你的软件有多个视图(比如主视图和预览小窗),这行代码可能会导致在预览窗里操作时,主视图的控制点大小乱套。
终极方案 是利用 paint 函数的 widget 参数。
python
def paint(self, painter, option, widget):
# widget 是当前正在绘制的 viewport
# 它的 parent 才是 QGraphicsView
view = widget.parent()
if view:
# m11() 获取水平缩放系数
self.__cur_scale = view.transform().m11()
# 接下来就可以根据 __cur_scale 计算实际绘制尺寸了
# ...
第三关:优雅的最终实现
结合以上思路,最终的实现代码非常简洁。我们将复杂的缩放逻辑封装在属性中,让绘制和交互逻辑保持清晰。
完整代码
python
from PyQt6.QtWidgets import QGraphicsRectItem, QGraphicsItem, QGraphicsView
from PyQt6.QtCore import Qt, QRectF, QPointF
from PyQt6.QtGui import QPen, QBrush, QColor, QPainter, QTransform, QPainterPath
class ResizableRectItem(QGraphicsRectItem):
def __init__(
self,
rect: QRectF,
parent=None,
handle_size=8.0, # 屏幕坐标宽度
border_width=4, # 屏幕坐标宽度
penColor="#0078D7",
brushAplha=0.2,
):
super().__init__(rect, parent)
self.__handle_size = handle_size
self.__border_width = border_width
self.penColor = penColor
self.brushAplha = brushAplha
self.__cur_scale: float = 1 # 缓存当前缩放比例
self.setAcceptHoverEvents(True)
# 交互状态变量
self._mode = None # 'Move', 'L', 'R', 'T', 'B', 'TL'...
self._start_pos = QPointF()
self._original_rect = QRectF()
# --- 核心:动态计算 Item 坐标系下的尺寸 ---
@property
def handle_size(self):
# 反向缩放:屏幕尺寸 / 当前缩放比
return self.__handle_size / self.__cur_scale
@property
def border_width(self):
return self.__border_width / self.__cur_scale
def boundingRect(self):
# 必须包含控制点区域,且要随着 handle_size 动态变化
return self.rect().adjusted(
-self.handle_size, -self.handle_size, self.handle_size, self.handle_size
)
def shape(self):
# 构建点击区域,包含整个边界框
# 这种写法最简洁,将控制点区域和主矩形融为一体
rect = self.boundingRect()
path = QPainterPath()
path.addRect(rect)
return path
def paint(self, painter, option, widget):
"""
重绘:核心在于获取缩放比例并应用反向变换
"""
# 1. 获取当前View的真实缩放比
view: QGraphicsView = widget.parent()
if view:
self.__cur_scale = view.transform().m11()
# 2. 设置画笔(利用反向计算后的宽度)
penColor = QColor(self.penColor)
painter.setPen(QPen(penColor, self.border_width))
brushColor = QColor(self.penColor)
brushColor.setAlphaF(self.brushAplha)
painter.setBrush(brushColor)
painter.drawRect(self.rect())
# 3. 绘制控制点
painter.setBrush(penColor)
for center in self._get_handle_centers():
rect = QRectF(
center.x() - self.handle_size / 2,
center.y() - self.handle_size / 2,
self.handle_size,
self.handle_size,
)
painter.drawRect(rect)
def _get_handle_centers(self):
"""获取8个控制点的中心坐标"""
r = self.rect()
cx, cy = r.center().x(), r.center().y()
return [
QPointF(r.left(), r.top()), QPointF(cx, r.top()), QPointF(r.right(), r.top()),
QPointF(r.right(), cy), QPointF(r.right(), r.bottom()), QPointF(cx, r.bottom()),
QPointF(r.left(), r.bottom()), QPointF(r.left(), cy),
]
def _get_hit_area(self, pos):
"""判断点击位置,返回操作模式"""
tolerance = self.handle_size * 1.1 # 稍微放宽点击容差
centers = self._get_handle_centers()
modes = ["TL", "T", "TR", "R", "BR", "B", "BL", "L"]
for mode, center in zip(modes, centers):
if (abs(pos.x() - center.x()) <= tolerance and
abs(pos.y() - center.y()) <= tolerance):
return mode
if self.rect().contains(pos):
return "Move"
return None
# --- 交互事件 (标准写法,无需关心缩放) ---
def hoverMoveEvent(self, event):
mode = self._get_hit_area(event.pos())
cursors = {
"TL": Qt.CursorShape.SizeFDiagCursor, "BR": Qt.CursorShape.SizeFDiagCursor,
"TR": Qt.CursorShape.SizeBDiagCursor, "BL": Qt.CursorShape.SizeBDiagCursor,
"T": Qt.CursorShape.SizeVerCursor, "B": Qt.CursorShape.SizeVerCursor,
"L": Qt.CursorShape.SizeHorCursor, "R": Qt.CursorShape.SizeHorCursor,
"Move": Qt.CursorShape.SizeAllCursor,
}
self.setCursor(cursors.get(mode, Qt.CursorShape.ArrowCursor))
super().hoverMoveEvent(event)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._mode = self._get_hit_area(event.pos())
if self._mode:
self._start_pos = event.pos()
self._original_rect = self.rect()
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if not self._mode:
super().mouseMoveEvent(event)
return
delta = event.pos() - self._start_pos
new_rect = QRectF(self._original_rect)
min_size = 10.0
if self._mode == "Move":
new_rect.translate(delta)
self.setRect(new_rect)
return
# 简洁的缩放逻辑
if "L" in self._mode:
new_left = self._original_rect.left() + delta.x()
if new_left < new_rect.right() - min_size: new_rect.setLeft(new_left)
if "R" in self._mode:
new_right = self._original_rect.right() + delta.x()
if new_right > new_rect.left() + min_size: new_rect.setRight(new_right)
if "T" in self._mode:
new_top = self._original_rect.top() + delta.y()
if new_top < new_rect.bottom() - min_size: new_rect.setTop(new_top)
if "B" in self._mode:
new_bottom = self._original_rect.bottom() + delta.y()
if new_bottom > new_rect.top() + min_size: new_rect.setBottom(new_bottom)
self.setRect(new_rect)
def mouseReleaseEvent(self, event):
self._mode = None
super().mouseReleaseEvent(event)