'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 1:[1]

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?

Solution 2:[2]

While the answer provided by @musicamente works, it is incomplete:

What is missing is the widthForHeight mecanism: as items are added into the layout, the minimumWidth of the container widget is not updated.

For some reason, Qt decided that heightForWidth mecanism should exist but not widthForHeight.

It would seem that when using the heightForWidth mecanism, the minimumHeight of the parent widget is automatically updated via the Qt framework (I may be wrong but I think it is the case).

In the example provided by @musicamente, as the main window is resizable this limitation is not easilly seen.

However when using a QScrollArea, this limitation is cleary observable as the scrollbar doesn't show up and the view is truncated.

So we need to determine which row of the FlowLayout is the widest and set the minimumWidth of the parent widget accordingly.

I've implemented it like so:

As the items are placed, that they are assigned i and j indexes which represent their position in a 2D array.

Then once all of them are placed, we determine the width of the widest row (including spacing between items) and let the parent widget know using a dedicated signal which can be connected to the setMinimumWidth method.

My solution might not be perfect nor a great implementation, but it is the best alternative I found so far to achieve what I wanted.

The following code will provide a working version, while I don't find my solution very elegant, it works.

If you have ideas on how to optimize it feel free to improve my implementation by making a PR on my GitHub : https://github.com/azsde/BatchMkvToolbox/tree/main/ui/customLayout

class FlowLayout(QLayout):

    widthChanged = pyqtSignal(int)

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

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

        self.setSpacing(spacing)
        self.itemList = []
        self.orientation = orientation

    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 True

    def heightForWidth(self, width):
        if (self.orientation == Qt.Horizontal):
            return self.doLayoutHorizontal(QRect(0, 0, width, 0), True)
        elif (self.orientation == Qt.Vertical):
            return self.doLayoutVertical(QRect(0, 0, width, 0), True)

    def setGeometry(self, rect):
        super(FlowLayout, self).setGeometry(rect)
        if (self.orientation == Qt.Horizontal):
            self.doLayoutHorizontal(rect, False)
        elif (self.orientation == Qt.Vertical):
            self.doLayoutVertical(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 doLayoutHorizontal(self, rect, testOnly):
        # Get initial coordinates of the drawing region (should be 0, 0)
        x = rect.x()
        y = rect.y()
        lineHeight = 0
        i = 0
        for item in self.itemList:
            wid = item.widget()
            # Space X and Y is item spacing horizontally and vertically
            spaceX = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal)
            spaceY = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical)
            # Determine the coordinate we want to place the item at
            # It should be placed at : initial coordinate of the rect + width of the item + spacing
            nextX = x + item.sizeHint().width() + spaceX
            # If the calculated nextX is greater than the outer bound...
            if nextX - spaceX > rect.right() and lineHeight > 0:
                x = rect.x() # Reset X coordinate to origin of drawing region
                y = y + lineHeight + spaceY # Move Y coordinate to the next line
                nextX = x + item.sizeHint().width() + spaceX # Recalculate nextX based on the new X coordinate
                lineHeight = 0

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

            x = nextX # Store the next starting X coordinate for next item
            lineHeight = max(lineHeight, item.sizeHint().height())
            i = i + 1

        return y + lineHeight - rect.y()

    def doLayoutVertical(self, rect, testOnly):
        # Get initial coordinates of the drawing region (should be 0, 0)
        x = rect.x()
        y = rect.y()
        # Initalize column width and line height
        columnWidth = 0
        lineHeight = 0

        # Space between items
        spaceX = 0
        spaceY = 0

        # Variables that will represent the position of the widgets in a 2D Array
        i = 0
        j = 0
        for item in self.itemList:
            wid = item.widget()
            # Space X and Y is item spacing horizontally and vertically
            spaceX = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal)
            spaceY = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical)
            # Determine the coordinate we want to place the item at
            # It should be placed at : initial coordinate of the rect + width of the item + spacing
            nextY = y + item.sizeHint().height() + spaceY
            # If the calculated nextY is greater than the outer bound, move to the next column
            if nextY - spaceY > rect.bottom() and columnWidth > 0:
                y = rect.y() # Reset y coordinate to origin of drawing region
                x = x + columnWidth + spaceX # Move X coordinate to the next column
                nextY = y + item.sizeHint().height() + spaceY # Recalculate nextX based on the new X coordinate
                # Reset the column width
                columnWidth = 0

                # Set indexes of the item for the 2D array
                j += 1
                i = 0

            # Assign 2D array indexes
            item.x_index = i
            item.y_index = j

            # Only call setGeometry (which place the actual widget using coordinates) if testOnly is false
            # For some reason, Qt framework calls the doLayout methods with testOnly set to true (WTF ??)
            if not testOnly:
                item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))

            y = nextY # Store the next starting Y coordinate for next item
            columnWidth = max(columnWidth, item.sizeHint().width()) # Update the width of the column
            lineHeight = max(lineHeight, item.sizeHint().height()) # Update the height of the line

            i += 1 # Increment i

        # Only call setGeometry (which place the actual widget using coordinates) if testOnly is false
        # For some reason, Qt framework calls the doLayout methods with testOnly set to true (WTF ??)
        if not testOnly:
            self.calculateMaxWidth(i)
            self.widthChanged.emit(self.totalMaxWidth + spaceX * self.itemsOnWidestRow)
        return lineHeight

    # Method to calculate the maximum width among each "row" of the flow layout
    # This will be useful to let the UI know the total width of the flow layout
    def calculateMaxWidth(self, numberOfRows):
        # Init variables
        self.totalMaxWidth = 0
        self.itemsOnWidestRow = 0

        # For each "row", calculate the total width by adding the width of each item
        # and then update the totalMaxWidth if the calculated width is greater than the current value
        # Also update the number of items on the widest row
        for i in range(numberOfRows):
            rowWidth = 0
            itemsOnWidestRow = 0
            for item in self.itemList:
                # Only compare items from the same row
                if (item.x_index == i):
                    rowWidth += item.sizeHint().width()
                    itemsOnWidestRow += 1
                if (rowWidth > self.totalMaxWidth):
                    self.totalMaxWidth = rowWidth
                    self.itemsOnWidestRow = itemsOnWidestRow

To use it do the following:

  • When declaring a FlowLayout, specify its orientation :

    myFlowLayout = FlowLayout(containerWidget, orientation=Qt.Vertical)

  • Connect the FlowLayout's widthChanged signal to the setMinimumWidth method of the container:

    myFlowLayout.widthChanged.connect(containerWidget.setMinimumWidth)

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 musicamante
Solution 2