QScrollArea with FlowLayout widgets not resizing properly



  • I want to create a widget similar to the KDE (or Gnome or MacOS) system settings (e.g., like this picture).

    I already implemented a FlowLayout from the Qt docs exampe.

    If I put some FlowLayout widgets (wrapped in a container widget with a QVBoxLayout) into a QScrollArea and resize the QSrollArea, everything flows and re-layouts as it shoulds.

    However, if I increase the scroll area’s width so that it needs less height, the scroll area’s still thinks that its widgets require
    the orginal height for their minimumWidth:

    0_1507630989642_flow_layout_in_scrollarea.gif

    How can I can I update the scroll area with the actual height of its child so that the vertical scroll bar disappears when it’s no longer needed?

    Below, you’ll find the (Python) implementation of the FlowLayout and in the __main__ block the actual example.

    Cheers,
    Stefan

    """
    PyQt5 port of the `layouts/flowlayout
    <https://doc.qt.io/qt-5/qtwidgets-layouts-flowlayout-example.html>`_ example
    from Qt5.
    
    Usage:
    
        python3 -m pip install pyqt5
        python3 flow_layout.py
    
    """
    from PyQt5.QtCore import pyqtSignal, QPoint, QRect, QSize, Qt
    from PyQt5.QtWidgets import QLayout, QSizePolicy, QSpacerItem
    
    
    class FlowLayout(QLayout):
        """A ``QLayout`` that aranges its child widgets horizontally and
        vertically.
    
        If enough horizontal space is available, it looks like an ``HBoxLayout``,
        but if enough space is lacking, it automatically wraps its children into
        multiple rows.
    
        """
        heightChanged = pyqtSignal(int)
    
        def __init__(self, parent=None, margin=0, spacing=-1):
            super().__init__(parent)
            if parent is not None:
                self.setContentsMargins(margin, margin, margin, margin)
            self.setSpacing(spacing)
    
            self._item_list = []
    
        def __del__(self):
            while self.count():
                self.takeAt(0)
    
        def addItem(self, item):  # pylint: disable=invalid-name
            self._item_list.append(item)
    
        def addSpacing(self, size):  # pylint: disable=invalid-name
            self.addItem(QSpacerItem(size, 0, QSizePolicy.Fixed, QSizePolicy.Minimum))
    
        def count(self):
            return len(self._item_list)
    
        def itemAt(self, index):  # pylint: disable=invalid-name
            if 0 <= index < len(self._item_list):
                return self._item_list[index]
            return None
    
        def takeAt(self, index):  # pylint: disable=invalid-name
            if 0 <= index < len(self._item_list):
                return self._item_list.pop(index)
            return None
    
        def expandingDirections(self):  # pylint: disable=invalid-name,no-self-use
            return Qt.Orientations(Qt.Orientation(0))
    
        def hasHeightForWidth(self):  # pylint: disable=invalid-name,no-self-use
            return True
    
        def heightForWidth(self, width):  # pylint: disable=invalid-name
            height = self._do_layout(QRect(0, 0, width, 0), True)
            return height
    
        def setGeometry(self, rect):  # pylint: disable=invalid-name
            super().setGeometry(rect)
            self._do_layout(rect, False)
    
        def sizeHint(self):  # pylint: disable=invalid-name
            return self.minimumSize()
    
        def minimumSize(self):  # pylint: disable=invalid-name
            size = QSize()
    
            for item in self._item_list:
                minsize = item.minimumSize()
                extent = item.geometry().bottomRight()
                size = size.expandedTo(QSize(minsize.width(), extent.y()))
    
            margin = self.contentsMargins().left()
            size += QSize(2 * margin, 2 * margin)
            return size
    
        def _do_layout(self, rect, test_only=False):
            m = self.contentsMargins()
            effective_rect = rect.adjusted(+m.left(), +m.top(), -m.right(), -m.bottom())
            x = effective_rect.x()
            y = effective_rect.y()
            line_height = 0
    
            for item in self._item_list:
                wid = item.widget()
    
                space_x = self.spacing()
                space_y = self.spacing()
                if wid is not None:
                    space_x += wid.style().layoutSpacing(
                        QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal)
                    space_y += wid.style().layoutSpacing(
                        QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical)
    
                next_x = x + item.sizeHint().width() + space_x
                if next_x - space_x > effective_rect.right() and line_height > 0:
                    x = effective_rect.x()
                    y = y + line_height + space_y
                    next_x = x + item.sizeHint().width() + space_x
                    line_height = 0
    
                if not test_only:
                    item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
    
                x = next_x
                line_height = max(line_height, item.sizeHint().height())
    
            new_height = y + line_height - rect.y()
            self.heightChanged.emit(new_height)
            return new_height
    
    
    if __name__ == '__main__':
        import sys
        from PyQt5.QtWidgets import QApplication, QPushButton, QScrollArea, QVBoxLayout, QWidget
    
    
        class Container(QWidget):
            def __init__(self):
                super().__init__()
                self.setLayout(QVBoxLayout())
                self._widgets = []
    
            def sizeHint(self):
                w = self.size().width()
                h = 0
                for widget in self._widgets:
                    h += widget.layout().heightForWidth(w)
    
                sh = super().sizeHint()
                print(sh)
                print(w, h)
                return sh
    
            def add_widget(self, widget):
                self._widgets.append(widget)
                self.layout().addWidget(widget)
    
            def add_stretch(self):
                self.layout().addStretch()
    
    
        app = QApplication(sys.argv)  # pylint: disable=invalid-name
        container = Container()
        for i in range(2):
            w = QWidget()
            w.setWindowTitle('Flow Layout')
            l = FlowLayout(w, 10)
            w.setLayout(l)
            l.addWidget(QPushButton('Short'))
            l.addWidget(QPushButton('Longer'))
            l.addWidget(QPushButton('Different text'))
            l.addWidget(QPushButton('More text'))
            l.addWidget(QPushButton('Even longer button text'))
            container.add_widget(w)
        container.add_stretch()
    
        sa = QScrollArea()
        sa.setWidgetResizable(True)
        sa.setWidget(container)
        sa.show()
    
        sys.exit(app.exec_())
    


  • The solution was (surprisingly) simple: Use the FlowLayout’s heightChanged signal to update the minimum height of the container (the ScrollArea’s widget).

    Here is a working example:

    """
    PyQt5 port of the `layouts/flowlayout
    <https://doc.qt.io/qt-5/qtwidgets-layouts-flowlayout-example.html>`_ example
    from Qt5.
    
    """
    from PyQt5.QtCore import pyqtSignal, QPoint, QRect, QSize, Qt
    from PyQt5.QtWidgets import QLayout, QSizePolicy, QSpacerItem
    
    
    class FlowLayout(QLayout):
        """A ``QLayout`` that aranges its child widgets horizontally and
        vertically.
    
        If enough horizontal space is available, it looks like an ``HBoxLayout``,
        but if enough space is lacking, it automatically wraps its children into
        multiple rows.
    
        """
        heightChanged = pyqtSignal(int)
    
        def __init__(self, parent=None, margin=0, spacing=-1):
            super().__init__(parent)
            if parent is not None:
                self.setContentsMargins(margin, margin, margin, margin)
            self.setSpacing(spacing)
    
            self._item_list = []
    
        def __del__(self):
            while self.count():
                self.takeAt(0)
    
        def addItem(self, item):  # pylint: disable=invalid-name
            self._item_list.append(item)
    
        def addSpacing(self, size):  # pylint: disable=invalid-name
            self.addItem(QSpacerItem(size, 0, QSizePolicy.Fixed, QSizePolicy.Minimum))
    
        def count(self):
            return len(self._item_list)
    
        def itemAt(self, index):  # pylint: disable=invalid-name
            if 0 <= index < len(self._item_list):
                return self._item_list[index]
            return None
    
        def takeAt(self, index):  # pylint: disable=invalid-name
            if 0 <= index < len(self._item_list):
                return self._item_list.pop(index)
            return None
    
        def expandingDirections(self):  # pylint: disable=invalid-name,no-self-use
            return Qt.Orientations(Qt.Orientation(0))
    
        def hasHeightForWidth(self):  # pylint: disable=invalid-name,no-self-use
            return True
    
        def heightForWidth(self, width):  # pylint: disable=invalid-name
            height = self._do_layout(QRect(0, 0, width, 0), True)
            return height
    
        def setGeometry(self, rect):  # pylint: disable=invalid-name
            super().setGeometry(rect)
            self._do_layout(rect, False)
    
        def sizeHint(self):  # pylint: disable=invalid-name
            return self.minimumSize()
    
        def minimumSize(self):  # pylint: disable=invalid-name
            size = QSize()
    
            for item in self._item_list:
                minsize = item.minimumSize()
                extent = item.geometry().bottomRight()
                size = size.expandedTo(QSize(minsize.width(), extent.y()))
    
            margin = self.contentsMargins().left()
            size += QSize(2 * margin, 2 * margin)
            return size
    
        def _do_layout(self, rect, test_only=False):
            m = self.contentsMargins()
            effective_rect = rect.adjusted(+m.left(), +m.top(), -m.right(), -m.bottom())
            x = effective_rect.x()
            y = effective_rect.y()
            line_height = 0
    
            for item in self._item_list:
                wid = item.widget()
    
                space_x = self.spacing()
                space_y = self.spacing()
                if wid is not None:
                    space_x += wid.style().layoutSpacing(
                        QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal)
                    space_y += wid.style().layoutSpacing(
                        QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical)
    
                next_x = x + item.sizeHint().width() + space_x
                if next_x - space_x > effective_rect.right() and line_height > 0:
                    x = effective_rect.x()
                    y = y + line_height + space_y
                    next_x = x + item.sizeHint().width() + space_x
                    line_height = 0
    
                if not test_only:
                    item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
    
                x = next_x
                line_height = max(line_height, item.sizeHint().height())
    
            new_height = y + line_height - rect.y()
            self.heightChanged.emit(new_height)
            return new_height
    
    
    if __name__ == '__main__':
        import sys
        from PyQt5.QtWidgets import QApplication, QPushButton, QScrollArea, QVBoxLayout, QWidget, QGroupBox
    
        app = QApplication(sys.argv)
    
        container = QWidget()
        container_layout = QVBoxLayout()
        for i in range(2):
            g = QGroupBox(f'Group {i}')
            l = FlowLayout(margin=10)
            l.heightChanged.connect(container.setMinimumHeight)
            g.setLayout(l)
            l.addWidget(QPushButton('Short'))
            l.addWidget(QPushButton('Longer'))
            l.addWidget(QPushButton('Different text'))
            l.addWidget(QPushButton('More text'))
            l.addWidget(QPushButton('Even longer button text'))
            container_layout.addWidget(g)
        container_layout.addStretch()
        container.setLayout(container_layout)
    
        w = QScrollArea()
        w.setWindowTitle('Flow Layout')
        w.setWidgetResizable(True)
        w.setWidget(container)
        w.show()
    
        sys.exit(app.exec_())
    
    

Log in to reply
 

Looks like your connection to Qt Forum was lost, please wait while we try to reconnect.