I have a QSplitter that divides my main window in two parts, in the upper part I have a QTabWidget and in the lower I have a QVBoxLayout.
What I want to do is to have only a single movable QSplitter, but now I can hide the entire QVBoxLayout in one shot while I would like to hide it element by element.
Is it possible to do that with QT Creator?
An example: lowering the QSplitter i jump between the two images. I want to see at first Button1, TextLabel, Button2 then Button1, TextLabel, then only Button1
In the code I added my programmatic solution
main.py
from PyQt6 import QtWidgets, uic
import sys
class mw(QtWidgets.QMainWindow):
def __init__(self, *args):
super(mw, self).__init__(*args)
uic.loadUi("mainwindow.ui", self)
self.splitter.splitterMoved.connect(self.splitterMoved)
def splitterMoved(self, pos: int, index: int):
delta = self.splitter.height() - pos - self.splitter.handleWidth()
items = [self.pb1, self.label, self.pb2] #first is last disappearing
used = items[0].height()
for f in items[1:]:
used += f.height() + self.innerLay.spacing()
f.setVisible(used <= delta) # use < instead of <= to hide before
app = QtWidgets.QApplication(sys.argv)
mw = mw()
mw.show()
app.exec()
mainwindow.ui
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>600</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="handleWidth">
<number>5</number>
</property>
<widget class="QTableView" name="tableView"/>
<widget class="QWidget" name="verticalLayoutWidget">
<layout class="QVBoxLayout" name="innerLay">
<property name="spacing">
<number>6</number>
</property>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="pb1">
<property name="text">
<string>PushButton1</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb2">
<property name="text">
<string>PushButton2</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>21</height>
</rect>
</property>
</widget>
<widget class="QStatusBar" name="statusbar"/>
</widget>
<resources/>
<connections/>
</ui>
The main problem when trying to achieve such feat is that the splitterMoved
signal is only emitted when the splitter handle is actually moved.
This means that as soon as the minimum "legal" size is reached, the signal is not emitted anymore until the mouse moves beyond the limit that would actually make the whole widget hidden, assuming that the childrenCollapsible
property is True
.
The proposed implementation will not work if that property is false, because in that case the signal is never emitted when the mouse moves beyond the minimum size limit.
But that's not the only problem: in fact, even when children are collapsible, the behavior would be unreliable, and could cause some flickering. To clarify that, we need to understand what actually happens when the mouse is moved to the point where the child would be collapsed:
splitterMoved
signal as expected;Now, the tricky part. If the user stops the mouse exactly at that point, no widget will be shown, which is certainly not what we want. Assuming that the mouse button is still pressed, the user moves the mouse a few pixel, and now the splitterMoved
signal is emitted again, because it's in a "legal position" (before the current handle position), so the function connected to the signal will show the possible widgets.
All the above will still happen until the last possibly visible widget is finally hidden.
As long as the mouse is moved quickly, and stopped at specific position, the user will not see any problem, but that's clearly unacceptable.
The only way to achieve all this is by receiving the possible handle position, no matter if it's "legal" or not.
This can only be achieved using subclasses, mouse event handler overrides, and custom signals:
subclass QSplitterHandle;
override its mousePressEvent
, mouseMoveEvent
and mouseReleaseEvent
handlers;
add a custom signal that will be always emitted when the user attempts to move the handle, and emit it in mouseMoveEvent()
;
subclass QSplitter;
override createHandle()
where we:
finally implement the move function by computing the possible sizes of all internal widgets and eventually show/hide them;
In order to do this with a Designer UI we need to use widget promotion (see this post for a detailed workflow of a custom QWidget).
In this specific case, we right click the QSplitter in the Designer Object Inspector and select "Promote to...", then type the name of the custom subclass in the "Promoted class name" field and the file name (without the .py
extension!!!) of the module/file script that contains our custom class in the "Header file" field, and finally click Add and Promote.
Remember that if the given "header file" is also our main script, using the if __name__ == '__main__':
check is mandatory, since PyQt will try to import again the same script.
Here is a possible implementation of those two subclasses:
class CustomSplitterHandle(QSplitterHandle):
offset = 0
pressed = False
moveRequested = pyqtSignal(int, QWidget)
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self.offset = event.pos().y()
self.pressed = True
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self.pressed:
pos = self.parentWidget().mapFromGlobal(event.globalPos()).y()
self.moveRequested.emit(pos - self.offset, self)
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton:
self.pressed = False
super().mouseReleaseEvent(event)
class CustomSplitter(QSplitter):
moveRequested = pyqtSignal(int, int)
def createHandle(self):
handle = CustomSplitterHandle(self.orientation(), self)
handle.moveRequested.connect(self.emitMoveRequested)
return handle
def emitMoveRequested(self, pos, handle):
for i in range(self.count()):
if self.handle(i) == handle:
self.moveRequested.emit(pos, i)
break
And here is how they can be used, based on your original code:
class MainWindow(QMainWindow):
def __init__(self, *args):
super().__init__(*args)
loadUi("splitter.ui", self)
self.splitter.moveRequested.connect(self.splitterMoved)
def splitterMoved(self, pos: int, index: int):
spacing = self.innerLay.spacing()
required = 0
widgetData = []
lastItem = None
for i in range(self.innerLay.count()):
item = self.innerLay.itemAt(i)
if isinstance(item, QWidgetItem):
widget = item.widget()
if (
not widget.isVisible()
and widget.testAttribute(
Qt.WidgetAttribute.WA_WState_ExplicitShowHide)
):
continue
itemHeight = widget.minimumSizeHint().height()
widgetData.append((widget, itemHeight))
required += itemHeight
if lastItem is not None and not isinstance(lastItem, QSpacerItem):
required += spacing
lastItem = item
avail = self.splitter.height() - pos - self.splitter.handleWidth()
if avail >= required:
for widget, _ in widgetData:
widget.show()
widget.setAttribute(
Qt.WidgetAttribute.WA_WState_ExplicitShowHide, False)
return
realHeight = 0
# hide widgets starting from the bottom; in order to hide from the
# top, use reversed(widgetData) instead
for widget, height in widgetData:
realHeight += height
# we show/hide the widget when the mouse is before or after its
# vertical center; alternatively, we could consider an arbitrary
# "threshold" of a few pixels, but in that case we would need to
# differentiate between "upward" or "downward" movements
if realHeight - height // 2 < avail:
widget.show()
realHeight += spacing
else:
widget.hide()
widget.setAttribute(
Qt.WidgetAttribute.WA_WState_ExplicitShowHide, False)
if __name__ == '__main__': # IMPORTANT!
app = QApplication(sys.argv)
mw = MainWindow()
mw.show()
app.exec()
Some important notes.
The above is a very crude and elementary concept, strictly based on the fact that:
verticalLayoutWidget
QWidget, since QSplitter only allows managing widgets, not layouts);Any attempt in creating a more general approach would be much more complex, especially when dealing with splitters showing more than two "main" widgets. Ideally, we would try to override moveSplitter()
of QSplitter or QSplitterHandle so that we would also manage the show/hide aspects when moving sibling widgets; unfortunately, those functions are not "virtual", so trying to override them would be uneffective and pointless.
The splitterMoved()
function checks for the WA_WState_ExplicitShowHide
attribute, which is always set when calling setVisible()
(even indirectly for show()
and hide()
). In this way we can check which widgets were hidden by our function, or programmatically hidden explicitly calling setVisible()
from elsewhere.
Finally, unrelated to the problem but still quite important, you should always use capitalized names for classes (see the official Style Guide for Python Code), and, more importantly, you should never create instances with the same name. Creating a class named mw
is not appropriate, but creating a global instance of it also named mw
is just wrong.
I'm aware that yours was just an example, but we should always follow those basic rules to begin with. It's not about writing "professional code", but correct code.
"They're not professional actors, they're taken from the streets" is not a valid excuse ;-)