Search code examples
qtpyqtqt-creator

QSplitter, hide sub elements one by one


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 minimum size All gone

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>

Solution

  • 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:

    • the mouse is moved, reaching the minimum size limit; every movement would emit the splitterMoved signal as expected;
    • the mouse is moved beyond that limit, no signal is emitted;
    • the mouse is moved beyond the point that makes the child collapsible;
    • QSplitter will immediately hide the widget;

    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:

    1. subclass QSplitterHandle;

    2. override its mousePressEvent, mouseMoveEvent and mouseReleaseEvent handlers;

    3. add a custom signal that will be always emitted when the user attempts to move the handle, and emit it in mouseMoveEvent();

    4. subclass QSplitter;

    5. override createHandle() where we:

      • create an instance of the custom subclass above;
      • connect the custom signal to a function that eventually emits a further signal (or manage it internally);
      • return the instance of the custom subclass;
    6. 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:

    • the splitter is vertical;
    • only two "splitted" elements exist;
    • the bottom part of the splitter actually consists of a widget with a vertical layout (Designer doesn't show it, but the UI actually contains a 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 ;-)