前言
使用 QtDesigner 开发界面可以极大提高开发效率,我之前有些过关于 Qt Designer 使用攻略文章 链接 然而使用传统的 Qt Layout 系统会有个小限制,无法让组件堆叠在一起。 比如说我有个 QListWidget、QGroupBox 之类的内置组件,我想要在它的右上角加一个按钮。 由于内置组件是自成一体的,如果使用 Qt 的 Layout 进行组件排列管理,就无法实现将组件叠在上层的效果。 然而不适用 layout 的话,缩放窗口组件就无法动态调整大小了,这也是我不想要的。
为了解决这个问题,我开发了一个 OverLay 组件,只要定义方向就可以将组件叠在另一个组件上。 通过 Qt Designer 的扩展自定义组件功能来配合使用,可以让 UI 开发更上一层楼。
解决的问题
上面的图就是传统 designer 开发由于无法堆叠,所以只能腾出侧边的组件空位放置按钮。
经过我开发的组件,就可以让侧边的按钮叠在 TableView 上。
Overlay 依然可以通过 QtDesigner 来进行组件的管理,比起 QTableView 扩展出按钮的方案更方便修改和维护。
上面的实战案例可能还感觉不出 Overlay 的好处,我可以再举一个 QGroupBox 按钮扩展的删除按钮。
通过 Designer 连接删除信号,然后点击删除就可以将整个 QGroupBox 删除。 由于是 Overlay 状态,所以组件看起来是自成一体的,而且修改起来也很方便,要加多个按钮直接在 Designer 里面添加即可。
Overlay 的使用
直接 Designer 上扩展 QWidget 组件即可。 提示组件的方法可以参考我之前写的文章 Qt Designer 使用全攻略
之后有两个属性 stretch 和 direction 需要配置。 stretch 拉伸默认配置为 Auto , direction 默认配置为 E。 通常情况下 stretch 无需配置, direction 需要配置,否则无法找到依附的组件。
可以使用 Designer 的动态属性进行配置,添加字符串类型的属性即可。
1 self.Overlay_Widget.set_stretch(self.Overlay_Widget.STRETCH.NoStretch)
也可以使用代码的 set_stretch
和 set_direction
方法进行配置,代码只要不配置默认值就就会一定会覆盖 Designer 的配置。
direction 有 N S E W
四个字母属性,分别是英文东南西北的首字母,定义组件当前的位置。 stretch 有 NoStretch Vertical Horizontal Center Auto
五个属性,分别代表需要动态拉伸的长宽位置。 Overlay 类中有 STRETCH
和 DIRECTION
的常亮来配置属性。 默认 Auto 可以根据组件的 direction 自动匹配 Vertical 还是 Horizontal。
如果使用 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 namedtupleclass 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 ): self.raise_() 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) 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) layout = self.parentWidget().layout() info = self._traverse_layout(layout) assert info, "%s cannot find layout" % (self) parent_layout, index = info value = 1 if self.direction <= 1 else -1 item = parent_layout.itemAt(index - value) assert item, "%s wrong overlay direction" % (self) parent_widget = parent_layout.parentWidget() parent_widget.installEventFilter(self) data = { "index" : index, "item" : item, "layout" : parent_layout, } 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 ): 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 ): self.painted.disconnect() layout = data.get("layout" ) index = data.get("index" ) item = data.get("item" ) layout.takeAt(index) data["geometry" ] = item.geometry() data["original_pos" ] = self.pos() self.painted.connect(self._update_mask) self.resized.connect(partial(self._resize_overlay, data)) 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() 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() 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)) 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 ): reg = QtGui.QRegion(self.frameGeometry()) reg -= QtGui.QRegion(self.geometry()) reg += self.childrenRegion() self.setMask(reg)
这个 mask 可以将 Overlay 组件自身挖空,保留子组件,避免导致下面的组件被遮挡。
左边是使用 mask 挖空的效果,右边是没有挖空的效果,没有挖空会遮挡住下面的功能。 研究这个也让我解开了之前困扰我很久的问题, mpdb 开发时悬而未决的问题 链接 当时也是想做一个 overlay 组件作为红色边框叠在 Maya 的窗口上,但是 overlay 组件会遮挡点击。 现在就知道可以通过 setMask
来解决这个问题。
总结
以上就是 overlay 组件开发记录,源码可以去 Github 查阅 链接
内置了测试用的 ui 文件,使用上目前没有发现问题了。 计划将这个组件功能添加到 dayu_widgets 里面,希望 pull request 可以通过。