前言

  使用 QtDesigner 开发界面可以极大提高开发效率,我之前有些过关于 Qt Designer 使用攻略文章 链接
  然而使用传统的 Qt Layout 系统会有个小限制,无法让组件堆叠在一起。
  比如说我有个 QListWidget、QGroupBox 之类的内置组件,我想要在它的右上角加一个按钮。
  由于内置组件是自成一体的,如果使用 Qt 的 Layout 进行组件排列管理,就无法实现将组件叠在上层的效果。
  然而不适用 layout 的话,缩放窗口组件就无法动态调整大小了,这也是我不想要的。

  为了解决这个问题,我开发了一个 OverLay 组件,只要定义方向就可以将组件叠在另一个组件上。
  通过 Qt Designer 的扩展自定义组件功能来配合使用,可以让 UI 开发更上一层楼。

解决的问题

alt

  上面的图就是传统 designer 开发由于无法堆叠,所以只能腾出侧边的组件空位放置按钮。

alt

  经过我开发的组件,就可以让侧边的按钮叠在 TableView 上。

alt

  Overlay 依然可以通过 QtDesigner 来进行组件的管理,比起 QTableView 扩展出按钮的方案更方便修改和维护。

  上面的实战案例可能还感觉不出 Overlay 的好处,我可以再举一个 QGroupBox 按钮扩展的删除按钮。

alt

  通过 Designer 连接删除信号,然后点击删除就可以将整个 QGroupBox 删除。
  由于是 Overlay 状态,所以组件看起来是自成一体的,而且修改起来也很方便,要加多个按钮直接在 Designer 里面添加即可。

Overlay 的使用

alt

  直接 Designer 上扩展 QWidget 组件即可。
  提示组件的方法可以参考我之前写的文章 Qt Designer 使用全攻略

  之后有两个属性 stretch 和 direction 需要配置。
  stretch 拉伸默认配置为 Auto , direction 默认配置为 E。
  通常情况下 stretch 无需配置, direction 需要配置,否则无法找到依附的组件。

alt

  可以使用 Designer 的动态属性进行配置,添加字符串类型的属性即可。

1
self.Overlay_Widget.set_stretch(self.Overlay_Widget.STRETCH.NoStretch)

  也可以使用代码的 set_stretchset_direction 方法进行配置,代码只要不配置默认值就就会一定会覆盖 Designer 的配置。

  direction 有 N S E W 四个字母属性,分别是英文东南西北的首字母,定义组件当前的位置。
  stretch 有 NoStretch Vertical Horizontal Center Auto 五个属性,分别代表需要动态拉伸的长宽位置。
  Overlay 类中有 STRETCHDIRECTION 的常亮来配置属性。
  默认 Auto 可以根据组件的 direction 自动匹配 Vertical 还是 Horizontal。

alt

  如果使用 NoStretch 会如上图,不会动态更新组件的长宽。

Overlay 实现原理

  首先要找出当前 Overlay 组件所在 layout 以及 layout 下的 index
  通过 layout takeAt 方法可以将 组件 从 layout 里面提取出来。
  这个时候 Overlay 组件已经悬浮在目标组件上了,只是调整大小无法响应了。
  因此要根据 Designer 配置的动态属性 direction 找到要依附的父组件,响应父组件的 resize 和 paint 事件进行相应的更新。

初始化注册

1
2
3
4
5
6
7
8
9
10
from collections import namedtuple
class QOverlay(QtWidgets.QWidget):

resized = QtCore.Signal(QtCore.QEvent)
painted = QtCore.Signal(QtCore.QEvent)

DIRECTION = namedtuple("Direction", "E S W N")(0, 1, 2, 3)
STRETCH = namedtuple("Stretch", "NoStretch Vertical Horizontal Center Auto")(
0, 1, 2, 3, 4
)

  定义类的 信号槽 和 属性常量。
  利用内置 namedtuple 可以更简洁生成相关的属性值

1
2
3
4
5
def __init__(self, parent=None):
super(QOverlay, self).__init__(parent=parent)
QtCore.QTimer.singleShot(0, self._initialize)
self.stretch = self.STRETCH.Auto
self.direction = self.DIRECTION.E

  通过 QTimer.singleShot 延迟到下一个 eventloop 触发。
  这样就不用纠结 stretch 和 direction 相关的属性是否已经设置了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def _initialize(self):
# NOTE 将组件放到最上面 https://stackoverflow.com/a/31197643
self.raise_()

# NOTE 获取 stretch 动态属性
stretch = self.property("stretch")
stretch = self.STRETCH._asdict().get(stretch)
if not stretch is None and self.stretch == self.STRETCH.Auto:
self.set_stretch(stretch)

# NOTE 获取 direction 动态属性 根据方向获取依附组件
direction = self.property("direction")
direction = direction.upper() if isinstance(direction, str) else ""
direction = self.DIRECTION._asdict().get(direction)
if not direction is None and self.direction == self.DIRECTION.E:
self.set_direction(direction)

# NOTE 递归查找 当前组件的 layout 和 index
layout = self.parentWidget().layout()
info = self._traverse_layout(layout)
assert info, "%s cannot find layout" % (self)

parent_layout, index = info

# NOTE 获取依附的组件
value = 1 if self.direction <= 1 else -1
item = parent_layout.itemAt(index - value)
assert item, "%s wrong overlay direction" % (self)

# NOTE 父组件添加事件拦截
parent_widget = parent_layout.parentWidget()
parent_widget.installEventFilter(self)
data = {
"index": index,
"item": item,
"layout": parent_layout,
}

# NOTE 确保显示状态才去 生成 Overlay (否则 Tab 组件下看不见的 Tab 会出问题)
self.painted.connect(partial(self._init_resize, data))

  上面是初始化函数的过程,获取相关配置和属性。
  重点是 _traverse_layout 找出当前组件所在 layout 和 index

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def _traverse_layout(self, layout):
# NOTE 递归查询 组件所在的 layout 和 index
target = None
for i in range(layout.count()):
item = layout.itemAt(i)
if isinstance(item, QtWidgets.QLayout):
target = self._traverse_layout(item)
if target:
break
elif isinstance(item, QtWidgets.QWidgetItem) and item.widget() is self:
target = (layout, i)
break

return target

  通过上面的方法实现递归查找当前所处的 layout 和 index
  不能直接使用 parent 获取是因为组件有可能在 子layout 下,所以要获取 parent 的 layout 然后递归查找。
  从而根据方向找出 依附组件。

  随后是 installEventFilter 拦截事件
  通过拦截的事件触发信号槽执行 _init_resize

  本来 installEventFilter 这一步应该弄一个 QObject 类生成拦截实例 会更加好理解。
  但是如果用 Overlay 组件自身的 eventFilter 会让代码都在一个类里面可以更加紧凑。

1
2
3
4
5
6
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.Resize:
self.resized.emit(event)
if event.type() == QtCore.QEvent.Paint:
self.painted.emit(event)
return super(QOverlay, self).eventFilter(obj, event)

  eventFilter 里面劫持相关的 Qt 事件通过 信号槽 来触发。
  这里劫持了 paint 事件,确保组件在可见范围下才生成 Overlay 效果,避免 TabWidget 看不见的状况下 size 对不上的问题。

resize 实现

  上面确保在绘制的界面触发 _init_resize 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def _init_resize(self, data, event):
# NOTE 注销 init_resize
self.painted.disconnect()

layout = data.get("layout")
index = data.get("index")
item = data.get("item")

# NOTE 从 layout 中取出
layout.takeAt(index)

# NOTE 将当前的父组件大小进行记录
data["geometry"] = item.geometry()
data["original_pos"] = self.pos()

self.painted.connect(self._update_mask)
self.resized.connect(partial(self._resize_overlay, data))
# NOTE 更新界面
QtCore.QTimer.singleShot(0, lambda: self._resize_overlay(data, None))

  通过 layout.takeAt(index) 将 Overlay 组件取出来,如此就不受 layout 系统控制,成为一个悬浮组件。
  随后注册相关的信号槽更新 _update_mask _resize_overlay

  最后加上 QtCore.QTimer.singleShot 是强制更新当前组件的位置。
  要用 singleShot 到下一个 eventloop 进行更新是因为 layout.takeAt(index) 要等待它进入 eventloop 执行完成,更新 Overlay 组件的位置。
  更新了组件位置之后才可以用 _resize_overlay 方法来调整位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def _resize_overlay(self, data, event):
item = data.get("item")
geometry = data.get("geometry")
layout = data.get("layout")
original_pos = data.get("original_pos")

width = self.geometry().width()
height = self.geometry().height()
spacing = layout.spacing()

# NOTE 比较新的 geometry 大小和之前的差异
new_geometry = item.geometry()
delta_x = new_geometry.x() - geometry.x()
delta_y = new_geometry.y() - geometry.y()
delta_width = new_geometry.width() - geometry.width()
delta_height = new_geometry.height() - geometry.height()

# NOTE 判断方向进行 x y 的位置调整
x = 0
y = 0
if self.direction == self.DIRECTION.W:
x = delta_x + width + spacing
y = delta_y
elif self.direction == self.DIRECTION.E:
x = delta_width + delta_x - width - spacing
y = delta_y
elif self.direction == self.DIRECTION.N:
x = delta_x
y = delta_y + height + spacing
elif self.direction == self.DIRECTION.S:
x = delta_x
y = delta_height + delta_y - height - spacing

self.move(original_pos + QtCore.QPoint(x, y))

# NOTE stretch 控制 Overlay 的缩放
if self.stretch == self.STRETCH.Auto:
if self.direction in [1, 3]:
self.setFixedWidth(new_geometry.width())
else:
self.setFixedHeight(new_geometry.height())
elif self.stretch == self.STRETCH.Horizontal:
self.setFixedWidth(new_geometry.width())
elif self.stretch == self.STRETCH.Vertical:
self.setFixedHeight(new_geometry.height())
elif self.stretch == self.STRETCH.Center:
self.move(original_pos)
self.setFixedWidth(new_geometry.width())
self.setFixedHeight(new_geometry.height())

  _init_resize 方法记录了依附组件当是时的 geometry 信息
  在 _resize_overlay 里面与之前的 geometry 数值做差就可以得到变化的偏移值。
  随后根据方向调整偏移值即可。

1
2
3
4
5
6
def _update_mask(self):
# NOTE https://stackoverflow.com/q/27855137
reg = QtGui.QRegion(self.frameGeometry())
reg -= QtGui.QRegion(self.geometry())
reg += self.childrenRegion()
self.setMask(reg)

  这个 mask 可以将 Overlay 组件自身挖空,保留子组件,避免导致下面的组件被遮挡。

alt

  左边是使用 mask 挖空的效果,右边是没有挖空的效果,没有挖空会遮挡住下面的功能。
  研究这个也让我解开了之前困扰我很久的问题, mpdb 开发时悬而未决的问题 链接
  当时也是想做一个 overlay 组件作为红色边框叠在 Maya 的窗口上,但是 overlay 组件会遮挡点击。
  现在就知道可以通过 setMask 来解决这个问题。

总结

  以上就是 overlay 组件开发记录,源码可以去 Github 查阅 链接

alt

  内置了测试用的 ui 文件,使用上目前没有发现问题了。
  计划将这个组件功能添加到 dayu_widgets 里面,希望 pull request 可以通过。