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: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_())