python-3.xpyqtpyqt5flowlayout

PyQt - Oriented Flow Layout


I'm trying to adapt this PyQt implementation of FlowLayout to allow vertical flow as well as horizontal. This is my current implementation:

from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *


class FlowLayout(QLayout):
    def __init__(self, orientation=Qt.Horizontal, parent=None, margin=0, spacing=-1):
        super().__init__(parent)
        self.orientation = orientation

        if parent is not None:
            self.setContentsMargins(margin, margin, margin, margin)

        self.setSpacing(spacing)

        self.itemList = []

    def __del__(self):
        item = self.takeAt(0)
        while item:
            item = self.takeAt(0)

    def addItem(self, item):
        self.itemList.append(item)

    def count(self):
        return len(self.itemList)

    def itemAt(self, index):
        if index >= 0 and index < len(self.itemList):
            return self.itemList[index]

        return None

    def takeAt(self, index):
        if index >= 0 and index < len(self.itemList):
            return self.itemList.pop(index)

        return None

    def expandingDirections(self):
        return Qt.Orientations(Qt.Orientation(0))

    def hasHeightForWidth(self):
        return self.orientation == Qt.Horizontal

    def heightForWidth(self, width):
        return self.doLayout(QRect(0, 0, width, 0), True)

    def hasWidthForHeight(self):
        return self.orientation == Qt.Vertical

    def widthForHeight(self, height):
        return self.doLayout(QRect(0, 0, 0, height), True)

    def setGeometry(self, rect):
        super().setGeometry(rect)
        self.doLayout(rect, False)

    def sizeHint(self):
        return self.minimumSize()

    def minimumSize(self):
        size = QSize()

        for item in self.itemList:
            size = size.expandedTo(item.minimumSize())

        margin, _, _, _ = self.getContentsMargins()

        size += QSize(2 * margin, 2 * margin)
        return size

    def doLayout(self, rect, testOnly):
        x = rect.x()
        y = rect.y()
        offset = 0
        horizontal = self.orientation == Qt.Horizontal

        for item in self.itemList:
            wid = item.widget()
            spaceX = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal)
            spaceY = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical)

            if horizontal:
                next = x + item.sizeHint().width() + spaceX
                if next - spaceX > rect.right() and offset > 0:
                    x = rect.x()
                    y += offset + spaceY
                    next = x + item.sizeHint().width() + spaceX
                    offset = 0
            else:
                next = y + item.sizeHint().height() + spaceY
                if next - spaceY > rect.bottom() and offset > 0:
                    x += offset + spaceX
                    y = rect.y()
                    next = y + item.sizeHint().height() + spaceY
                    offset = 0

            if not testOnly:
                item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))

            if horizontal:
                x = next
                offset = max(offset, item.sizeHint().height())
            else:
                y = next
                offset = max(offset, item.sizeHint().width())

        return y + offset - rect.y() if horizontal else x + offset - rect.x()


if __name__ == '__main__':
    class Window(QWidget):
        def __init__(self):
            super().__init__()

            #flowLayout = FlowLayout(orientation=Qt.Horizontal)
            flowLayout = FlowLayout(orientation=Qt.Vertical)
            flowLayout.addWidget(QPushButton("Short"))
            flowLayout.addWidget(QPushButton("Longer"))
            flowLayout.addWidget(QPushButton("Different text"))
            flowLayout.addWidget(QPushButton("More text"))
            flowLayout.addWidget(QPushButton("Even longer button text"))
            self.setLayout(flowLayout)

            self.setWindowTitle("Flow Layout")

    import sys

    app = QApplication(sys.argv)
    mainWin = Window()
    mainWin.show()
    sys.exit(app.exec_())

This implementation has 2 (likely related) problems when handling vertical layouts:

  1. QLayout has the hasHeightForWidth and heightForWidth methods, but not their inverses hasWidthForHeight and widthForHeight. I implemented the latter two methods regardless, but I doubt they're ever actually getting called.
  2. When using the horizontal variant of the layout, the window is automatically appropriately sized to contain all the items. When using the vertical variant, this is not the case. However, the vertical layout does work properly if you manually resize the window.

How do I properly implement a vertical flow layout?


Solution

  • As you already found out, Qt layouts don't support widthForHeight, and, in general, these kinds of layouts are discouraged, mostly because they tend to behave erratically in complex situation with nested layouts and mixed widget size policies. Even when being very careful about their implementation, you might end up in recursive calls to size hints, policies etc.

    That said, a partial solution is to still return a height for width, but position the widgets vertically instead of horizontally.

        def doLayout(self, rect, testOnly):
            x = rect.x()
            y = rect.y()
            lineHeight = columnWidth = heightForWidth = 0
    
            for item in self.itemList:
                wid = item.widget()
                spaceX = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal)
                spaceY = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical)
                if self.orientation == Qt.Horizontal:
                    nextX = x + item.sizeHint().width() + spaceX
                    if nextX - spaceX > rect.right() and lineHeight > 0:
                        x = rect.x()
                        y = y + lineHeight + spaceY
                        nextX = x + item.sizeHint().width() + spaceX
                        lineHeight = 0
    
                    if not testOnly:
                        item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
    
                    x = nextX
                    lineHeight = max(lineHeight, item.sizeHint().height())
                else:
                    nextY = y + item.sizeHint().height() + spaceY
                    if nextY - spaceY > rect.bottom() and columnWidth > 0:
                        x = x + columnWidth + spaceX
                        y = rect.y()
                        nextY = y + item.sizeHint().height() + spaceY
                        columnWidth = 0
    
                    heightForWidth += item.sizeHint().height() + spaceY
                    if not testOnly:
                        item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
    
                    y = nextY
                    columnWidth = max(columnWidth, item.sizeHint().width())
    
            if self.orientation == Qt.Horizontal:
                return y + lineHeight - rect.y()
            else:
                return heightForWidth - rect.y()
    

    This is how the widget appears as soon as it's shown (which is almost the same as the horizontal flow):

    first shown

    Now, resizing to allow less vertical space:

    small vertical space

    And even smaller height:

    not that tall, huh?