Qt: FlowLayout in a Collapsible QScrollArea. ResizeEvent issue?

Junior Tech Artist here!
Hey there I have adapted a FlowLayout into a custom Collapsible Scroll Area and am getting a few issues (due to my lack of Qt knowledge).
I found the FlowLayout by someone else from about 7 years ago, and have just switched out the QtCore for QtWidgets where it was erroring. It seems to be working properly.

However when I put it into the custom Collapsible Scroll Area it has issues when you open one of the collapsed areas. The QVBoxLayout housing the FlowLayout does not resize to the FlowLayout vertically. However when you resize the window horizontally the layout stretches out and properly shows the FlowLayout (if they end up in a single line).

My goals here were:

  1. Have the layout open to the correct size depending on the amount of space the FlowLayout needs, and also to resize when you adjust the window.
  2. To have the collapse resize the distance between the other collapsed layouts. They seem to get really funky the more you open/close.
  3. Resolve the need to have the layout focused(? highlighted ? clicked twice ?) in order to collapse.

My thoughts are that the TestWindow()'s box_inner_layout is not correctly resizing? A colleague mentioned about doing a resizeEvent(?), but he doesn’t have time to look at it, as this is a personal project. I’ve never rewritten a base class function. Whoever wrote the FlowLayout knew far more than I, and was able to rewrite a few events it seems to make that part work correctly.

Any help/insight would be huuuugely appreciated!
Sorry for the dump of code but the easiest way I could share exactly what’s being used.

from PySide2 import QtCore
from PySide2 import QtGui
from PySide2 import QtWidgets
from shiboken2 import wrapInstance
import os
import maya.cmds as cmds
import maya.OpenMayaUI as omui

def maya_main_window():
    """
    Return the Maya main window widget as a Python object
    """
    main_window_ptr = omui.MQtUtil.mainWindow()
    if not main_window_ptr:
        return None
    return wrapInstance(long(main_window_ptr), QtWidgets.QWidget)


class TestWindow(QtWidgets.QDialog):

    def __init__(self, parent=maya_main_window()):
        super(TestWindow, self).__init__(parent)
        window_title = "test_window"

        self.setWindowTitle(window_title)
        if cmds.about(ntOS=True):
            self.setWindowFlags(self.windowFlags() ^ QtCore.Qt.WindowContextHelpButtonHint)
        elif cmds.about(macOS=True):
            self.setWindowFlags(QtCore.Qt.Tool)    
        
        self.create_content()
        self.setMinimumWidth(211)
        
    def create_content(self):
        main_layout = QtWidgets.QVBoxLayout(self)
        
        for i in range(10):
            collapse_box = CollapsibleBox(str(i))
            main_layout.addWidget(collapse_box)
            box_inner_layout = QtWidgets.QVBoxLayout(collapse_box)
            flow = FlowLayout(parent=None)
            for j in range(12):
                btn = QtWidgets.QPushButton()
                btn.setFixedSize(36, 36)
                flow.addWidget(btn)
            box_inner_layout.addLayout(flow)
            collapse_box.setContentLayout(box_inner_layout)


class CollapsibleBox(QtWidgets.QWidget):
    def __init__(self, title="", parent=None):
        super(CollapsibleBox, self).__init__(parent)

        self.toggle_button = QtWidgets.QToolButton(
            text=title, checkable=True, checked=False
        )
        self.toggle_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)
        self.toggle_button.setArrowType(QtCore.Qt.RightArrow)
        self.toggle_button.pressed.connect(self.on_pressed)

        self.toggle_animation = QtCore.QParallelAnimationGroup(self)

        self.content_area = QtWidgets.QScrollArea(maximumHeight=0, minimumHeight=0)
        self.content_area.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)

        lay = QtWidgets.QVBoxLayout(self)
        lay.setSpacing(0)
        lay.setContentsMargins(0, 0, 0, 0)
        lay.addWidget(self.toggle_button)
        lay.addWidget(self.content_area)

        self.toggle_animation.addAnimation(
            QtCore.QPropertyAnimation(self, b"minimumHeight")
        )
        self.toggle_animation.addAnimation(
            QtCore.QPropertyAnimation(self, b"maximumHeight")
        )
        self.toggle_animation.addAnimation(
            QtCore.QPropertyAnimation(self.content_area, b"maximumHeight")
        )

    def on_pressed(self):
        checked = self.toggle_button.isChecked()
        print(checked)
        self.toggle_button.setArrowType(
            QtCore.Qt.DownArrow if not checked else QtCore.Qt.RightArrow
        )
        self.toggle_animation.setDirection(
            QtCore.QAbstractAnimation.Forward
            if not checked
            else QtCore.QAbstractAnimation.Backward
        )
        self.toggle_animation.start()
        
    def setContentLayout(self, layout):
        lay = self.content_area.layout()
        del lay
        self.content_area.setLayout(layout)
        collapsed_height = (
            self.sizeHint().height() - self.content_area.maximumHeight()
        )
        content_height = layout.sizeHint().height()
        for i in range(self.toggle_animation.animationCount()):
            animation = self.toggle_animation.animationAt(i)
            animation.setDuration(0)
            animation.setStartValue(collapsed_height)
            animation.setEndValue(collapsed_height + content_height)

        content_animation = self.toggle_animation.animationAt(
            self.toggle_animation.animationCount() - 1
        )
        content_animation.setDuration(0)
        content_animation.setStartValue(0)
        content_animation.setEndValue(content_height)


class FlowLayout(QtWidgets.QLayout):
    """Custom layout that mimics the behaviour of a flow layout"""

    def __init__(self, parent=None, margin=0, spacing=2):
        """
        Create a new FlowLayout instance.
        This layout will reorder the items automatically.
        @param parent (QWidget)
        @param margin (int)
        @param spacing (int)
        """
        super(FlowLayout, self).__init__(parent)
        # Set margin and spacing
        if parent is not None: self.setMargin(margin)
        self.setSpacing(spacing)

        self.itemList = []

    def __del__(self):
        """Delete all the items in this layout"""
        item = self.takeAt(0)
        while item:
            item = self.takeAt(0)

    def addItem(self, item):
        """Add an item at the end of the layout.
        This is automatically called when you do addWidget()
        item (QWidgetItem)"""
        self.itemList.append(item)

    def count(self):
        """Get the number of items in the this layout
        @return (int)"""
        return len(self.itemList)

    def itemAt(self, index):
        """Get the item at the given index
        @param index (int)
        @return (QWidgetItem)"""
        if index >= 0 and index < len(self.itemList):
            return self.itemList[index]
        return None

    def takeAt(self, index):
        """Remove an item at the given index
        @param index (int)
        @return (None)"""
        if index >= 0 and index < len(self.itemList):
            return self.itemList.pop(index)
        return None

    def insertWidget(self, index, widget):
        """Insert a widget at a given index
        @param index (int)
        @param widget (QWidget)"""
        item = QtGui.QWidgetItem(widget)
        self.itemList.insert(index, item)

    def expandingDirections(self):
        """This layout grows only in the horizontal dimension"""
        return QtCore.Qt.Orientations(QtCore.Qt.Horizontal)

    def hasHeightForWidth(self):
        """If this layout's preferred height depends on its width
        @return (boolean) Always True"""
        return True

    def heightForWidth(self, width):
        """Get the preferred height a layout item with the given width
        @param width (int)"""
        height = self.doLayout(QtCore.QRect(0, 0, width, 0), True)
        return height

    def setGeometry(self, rect):
        """Set the geometry of this layout
        @param rect (QRect)"""
        super(FlowLayout, self).setGeometry(rect)
        self.doLayout(rect, False)

    def sizeHint(self):
        """Get the preferred size of this layout
        @return (QSize) The minimum size"""
        return self.minimumSize()

    def minimumSize(self):
        """Get the minimum size of this layout
        @return (QSize)"""
        # Calculate the size
        size = QtCore.QSize()
        for item in self.itemList:
            size = size.expandedTo(item.minimumSize())
        # Add the margins
        size += QtCore.QSize(2 * self.margin(), 2 * self.margin())
        return size

    def doLayout(self, rect, testOnly):
        """Layout all the items
        @param rect (QRect) Rect where in the items have to be laid out
        @param testOnly (boolean) Do the actual layout"""
        x = rect.x()
        y = rect.y()
        lineHeight = 0

        for item in self.itemList:
            wid = item.widget()
            spaceX = self.spacing()
            spaceY = self.spacing()
            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(QtCore.QRect(QtCore.QPoint(x, y), item.sizeHint()))

            x = nextX
            lineHeight = max(lineHeight, item.sizeHint().height())

        return y + lineHeight - rect.y()

gui = TestWindow()
gui.show()

Re-writing the CollapsibleBox box to use the visibility flag should reduce the amount of lines of code and remove any issues set by trying to juggle the collapsed maximum height etc.
You can also use resizeEvent to get the height of the flow layout and set that to be the max height to make sure the box is using as little space as needed.

def __init__(self, title="", parent=None):
        super(CollapsibleBox, self).__init__(parent)

        self.toggle_button = QtWidgets.QToolButton(text=title, checkable=True, checked=False)
        self.toggle_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)
        self.toggle_button.setArrowType(QtCore.Qt.RightArrow)
        self.toggle_button.pressed.connect(self.on_pressed)
        
        self.toggle_animation = QtCore.QParallelAnimationGroup(self)

        self.content_area = QtWidgets.QScrollArea()

        lay = QtWidgets.QVBoxLayout(self)
        lay.setSpacing(0)
        lay.setContentsMargins(0, 0, 0, 0)
        lay.addWidget(self.toggle_button)
        lay.addWidget(self.content_area)

        self.content_area.setVisible(False)
        self.setSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.MinimumExpanding)
    def on_pressed(self):
        checked = self.toggle_button.isChecked()
        print(checked)
        self.toggle_button.setArrowType(
            QtCore.Qt.DownArrow if not checked else QtCore.Qt.RightArrow
        )
        self.content_area.setVisible(not checked)
        
    def setContentLayout(self, layout):
        lay = self.content_area.layout()
        del lay
        self.content_area.setLayout(layout)
        
    def resizeEvent(self, event):
        self.content_area.setMaximumHeight((self.content_area.layout().heightForWidth(self.width()))) 
        return super(CollapsibleBox, self).resizeEvent(event)

It is a little confusing that you use a flowlayout (that will expand and contract the widgets to fit the given space) inside of a QScrollArea (which will give you scrollbars to see all the widgets as it expects them to be outside of the given space) as only one would come into play by default.
Depending on the effect your after, you might want to either swap out the QScrollArea for a QWidget if you don’t need to scroll, or add a hard limit on X/Y where the scroll bars come into play.

@Geoff.samuel you are a legend mate!

Since posting I had gone back in and removed the animation and instead used visibility, as I was advised to do that if the animation wasn’t important to me. I had grabbed the CollapsibleBox() from a post on Stack-Overflow and only slightly modified it to fit my needs, of what I knew how to anyway. Not realizing the amount of bloat the animation caused (besides that it was a headache to work with as a new coder). Who knew that you get issues with code you don’t fully understand? haha.

I was hoping the CollapsibleBox() would work just like the maya formLayout().

Great tip on the switch to QWidget! I originally had QScrollArea as that’s what the original code had. The QWidget item is much cleaner looking in my opinion :slight_smile: , it’s also nice that they are swappable in this case.

resizeEvent()
This was my biggest misunderstanding and fear. I am reading your code and trying to understand it, perhaps you could correct me on a couple things?

resizeEvent() is a built in function to every layout type widget (all widgets?) and it is called any time a window or widget is resized either by user input or programmatically. So you have “overloaded”(?) this event? Someone told me that the resizeEvent was something that could be “overloaded”, and I assume that means expanded upon?
So here you are changing self.content_area's maximum height to the content_area’s child layout’s(?) width(?)
The return statement is kinda like a second language to me because I don’t know “super”, but I’d guess you are returning the class’s resizeEvent? And it just automatically knows what event to use?

Sorry for so many questions! I really do appreciate all your help. A wonderful holiday present for me! haha.

If you’re able to stomach another question, I was wondering why the QToolButton needs two presses to work if it’s not in focus I think it’s that it’s not in focus? Or any button for that matter, it looks like QPushButton does the same thing.

Thanks again Geoff!
Looking forward to any links/knowledge you have on this. With you’re suggestions and rewrite the UI is well on it’s way!

Great to hear thats working for you!

I’m running the code outside of Maya and the QToolButton seems to work as expected without the double click, so that might be a maya specific issue, but worth experimenting with a QPushButton instead to see if that solves it for you.

I feel your terminology is might be slightly off here, Overloaded is more akin to Polymorphisim (Wiki Link) in C++ and in Python its manifested as the args and kwargs in a method definition.
What your looking at is reimplemtation, akin to virtualization in C++ (Wiki) and that is when you take an existing method and reimplement it so your code is called first. We use the Super method in python to call to its parent’s implementation of a given method. This does a great job explaining super and showing its practical applications (link).

So resizeEvent is a virtually implemented method (Qt Docs) so by reimplementing it in your CollapsibleBox class, whenever the widget’s resize event is triggered, it will call this new method, as we call super resizeEvent inside, it calls the base QWidgets resize event and the widget is resize as expected.

Hope that helps, Enjoy the holidays!

Geoff, thanks for the links! They dive a lot deeper into the stuff than I think I fully understand, but I will revisit them as time goes on to recheck what I know/grasp as I learn :slight_smile:.

You’ve been a great help with this, and Chris Zurbrigg on Patreon as well.

With both of your help and tutorials I was able to create this relatively handy UI from a FlowLayout and a button that sorta emulates maya’s formLayout!

If anyone is interested, the code I have for this is below. I was able to solve the button press issues by changing the CollapsibleButton() to be taken from a QVBoxLayout, so that I could use .setAlignment(QtCore.Qt.AlignTop) on them. This fixed the spacing of them :slight_smile:.
Using the Scroll Area also helps make things not so jarring to the user when you open a tab.

Happy Holidays!

from PySide2 import QtCore
from PySide2 import QtGui
from PySide2 import QtWidgets
from shiboken2 import wrapInstance
import os
import maya.cmds as cmds
import maya.OpenMayaUI as omui

def maya_main_window():
   """
   Return the Maya main window widget as a Python object
   """
   main_window_ptr = omui.MQtUtil.mainWindow()
   if not main_window_ptr:
       return None
   return wrapInstance(long(main_window_ptr), QtWidgets.QWidget)


class TestBox(QtWidgets.QDialog):

   def __init__(self, parent=maya_main_window()):
       super(TestBox, self).__init__(parent)
       window_title = "Assets Toolkit"

       self.setWindowTitle(window_title)
       if cmds.about(ntOS=True):
           self.setWindowFlags(self.windowFlags() ^ QtCore.Qt.WindowContextHelpButtonHint)
       elif cmds.about(macOS=True):
           self.setWindowFlags(QtCore.Qt.Tool)    
       
       self.setMinimumWidth(235)
       self.resize(235,250)
       
       self.main_layout = QtWidgets.QVBoxLayout(self)
       
       self.holder_wdg = QtWidgets.QWidget()
       
       self.create_content()

       self.scroll_area = QtWidgets.QScrollArea()
       self.scroll_area.setWidgetResizable(True)
       self.scroll_area.setWidget(self.holder_wdg)
       self.scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
       
       self.main_layout.addWidget(self.scroll_area)
       
   def create_content(self):
       self.collapse_layout = QtWidgets.QVBoxLayout(self.holder_wdg)
       
       for i in range(12):
           category_box = CollapsibleBoxLayout(str(i))
           flow = FlowLayout(parent=None)
           
           for i in range(12):
               self.create_button("test", "test", flow)
           
           category_box.setContentLayout(flow)
           self.collapse_layout.setAlignment(QtCore.Qt.AlignTop)
           self.collapse_layout.addLayout(category_box)
           
   def create_button(self, img_path, sub_path, flow):
       icon_pmap = QtGui.QPixmap(img_path)
       btn = QtWidgets.QPushButton()
       btn.setFixedSize(34,34)
       btn.setIcon(QtGui.QIcon(img_path))
       btn.setIconSize(btn.rect().size())
       flow.addWidget(btn)    


class CollapsibleBoxLayout(QtWidgets.QVBoxLayout):
   def __init__(self, title="", parent=None):
       super(CollapsibleBoxLayout, self).__init__(parent)

       self.toggle_button = QtWidgets.QPushButton(text=title, checkable=True, checked=False)
       self.toggle_button.clicked.connect(self.on_pressed)
       self.toggle_button.setChecked(False)

       self.content_area = QtWidgets.QWidget()

       self.addWidget(self.toggle_button)
       self.addWidget(self.content_area)
       self.setSpacing(0)

       self.content_area.setVisible(False)
       
   def on_pressed(self):
       checked = self.toggle_button.isChecked()
       if checked:
           self.toggle_button.setChecked(True)
           self.content_area.setVisible(True)
       if not checked:
           self.toggle_button.setChecked(False)
           self.content_area.setVisible(False)
       
   def setContentLayout(self, layout):
       lay = self.content_area.layout()
       del lay
       self.content_area.setLayout(layout)
       
   def resizeEvent(self, event):
       self.content_area.setMaximumHeight((self.content_area.layout().heightForWidth(self.width())))
       return super(CollapsibleBoxLayout, self).resizeEvent(event)


class FlowLayout(QtWidgets.QLayout):
   """Custom layout that mimics the behaviour of a flow layout"""

   def __init__(self, parent=None, margin=0, spacing=2):
       """
       Create a new FlowLayout instance.
       This layout will reorder the items automatically.
       @param parent (QWidget)
       @param margin (int)
       @param spacing (int)
       """
       super(FlowLayout, self).__init__(parent)
       # Set margin and spacing
       if parent is not None: self.setMargin(margin)
       self.setSpacing(spacing)

       self.itemList = []

   def __del__(self):
       """Delete all the items in this layout"""
       item = self.takeAt(0)
       while item:
           item = self.takeAt(0)

   def addItem(self, item):
       """Add an item at the end of the layout.
       This is automatically called when you do addWidget()
       item (QWidgetItem)"""
       self.itemList.append(item)

   def count(self):
       """Get the number of items in the this layout
       @return (int)"""
       return len(self.itemList)

   def itemAt(self, index):
       """Get the item at the given index
       @param index (int)
       @return (QWidgetItem)"""
       if index >= 0 and index < len(self.itemList):
           return self.itemList[index]
       return None

   def takeAt(self, index):
       """Remove an item at the given index
       @param index (int)
       @return (None)"""
       if index >= 0 and index < len(self.itemList):
           return self.itemList.pop(index)
       return None

   def insertWidget(self, index, widget):
       """Insert a widget at a given index
       @param index (int)
       @param widget (QWidget)"""
       item = QtGui.QWidgetItem(widget)
       self.itemList.insert(index, item)

   def expandingDirections(self):
       """This layout grows only in the horizontal dimension"""
       return QtCore.Qt.Orientations(QtCore.Qt.Horizontal)

   def hasHeightForWidth(self):
       """If this layout's preferred height depends on its width
       @return (boolean) Always True"""
       return True

   def heightForWidth(self, width):
       """Get the preferred height a layout item with the given width
       @param width (int)"""
       height = self.doLayout(QtCore.QRect(0, 0, width, 0), True)
       return height

   def setGeometry(self, rect):
       """Set the geometry of this layout
       @param rect (QRect)"""
       super(FlowLayout, self).setGeometry(rect)
       self.doLayout(rect, False)

   def sizeHint(self):
       """Get the preferred size of this layout
       @return (QSize) The minimum size"""
       return self.minimumSize()

   def minimumSize(self):
       """Get the minimum size of this layout
       @return (QSize)"""
       # Calculate the size
       size = QtCore.QSize()
       for item in self.itemList:
           size = size.expandedTo(item.minimumSize())
       # Add the margins
       size += QtCore.QSize(2 * self.margin(), 2 * self.margin())
       return size

   def doLayout(self, rect, testOnly):
       """Layout all the items
       @param rect (QRect) Rect where in the items have to be laid out
       @param testOnly (boolean) Do the actual layout"""
       x = rect.x()
       y = rect.y()
       lineHeight = 0

       for item in self.itemList:
           wid = item.widget()
           spaceX = self.spacing()
           spaceY = self.spacing()
           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(QtCore.QRect(QtCore.QPoint(x, y), item.sizeHint()))

           x = nextX
           lineHeight = max(lineHeight, item.sizeHint().height())

       return y + lineHeight - rect.y()

gui = TestBox()
gui.show()