0

My problem is that collapsible QToolButton is acting weird in QScrollArea. It's not my first problem with collapsible QToolButton and at first it was my layout not stretching , so I added stretching(addStretch(1)) and it started working fine. Today, I tried to add QScrollArea to layout and then adding QToolButtons with widgets under it and same looking problem started again. So I thought that is again problem with stretching , so I was trying to search way of stretching QScrollArea and after looking, tried setting setSizePolicy(), sizeHint(), but it wouldn't fix problem. Can somebody help me find problem ?

More detailed explanation of problem : when you are expanding all collapsible QToolButton's first time there is no problem , but when you close them all and start opening again , starting from second QToolButton they start not opening from first few clicks. Also, I don't know if it's problem or not , but at first when you expand those buttons out of UI , they start shaking back and forward a little, basically not opening smoothly.

Here is code:

import random
from PySide2.QtGui import QPixmap, QBrush, QColor, QIcon, QPainterPath, QPolygonF, QPen, QTransform
from PySide2.QtCore import QSize, Qt, Signal, QPointF, QRect, QPoint, QParallelAnimationGroup, QPropertyAnimation, QAbstractAnimation
from PySide2.QtWidgets import QMainWindow, QDialog, QVBoxLayout, QHBoxLayout, QGraphicsView, QGraphicsScene, QFrame, \
    QSizePolicy, QGraphicsPixmapItem, QApplication, QRubberBand, QMenu, QMenuBar, QTabWidget, QWidget, QPushButton, \
    QSlider, QGraphicsPolygonItem, QToolButton, QScrollArea, QLabel

extraDict = {'buttonSetA': ['test'], 'buttonSetB': ['test'], 'buttonSetC': ['test'], 'buttonSetD': ['test']}

class MainWindow(QDialog):
    def __init__(self, parent=None):
        QDialog.__init__(self, parent=parent)
        self.create()

    def create(self, **kwargs):
        main_layout = QVBoxLayout()
        tab_widget = QTabWidget()
        main_layout.addWidget(tab_widget)

        tab_extra = QWidget()
        tab_widget.addTab(tab_extra, 'Extra')
        tab_main = QWidget()
        tab_widget.addTab(tab_main, 'Main')

        tab_extra.layout = QVBoxLayout()
        tab_extra.setLayout(tab_extra.layout)

        scroll = QScrollArea()
        content_widget = QWidget()
        scroll.setWidget(content_widget)
        scroll.setWidgetResizable(True)
        #scroll.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        tab_extra.layout.addWidget(scroll)
        content_layout = QVBoxLayout(content_widget)

        for name in extraDict.keys():
            box = CollapsibleBox(name)
            content_layout.addWidget(box)
            box_layout = QVBoxLayout()
            for j in range(8):
                label = QLabel("{}".format(j))
                color = QColor(*[random.randint(0, 255) for _ in range(3)])
                label.setStyleSheet("background-color: {}; color : white;".format(color.name()))
                label.setAlignment(Qt.AlignCenter)
                box_layout.addWidget(label)
            box.setContentLayout(box_layout)
        content_layout.addStretch(1)
        self.setLayout(main_layout)

class CollapsibleBox(QWidget):
    def __init__(self, name):
        super(CollapsibleBox, self).__init__()
        self.toggle_button = QToolButton(text=name, checkable=True, checked=False)
        self.toggle_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self.toggle_button.setArrowType(Qt.RightArrow)
        self.toggle_button.pressed.connect(self.on_pressed)
        self.toggle_animation = QParallelAnimationGroup(self)
        self.content_area = QScrollArea(maximumHeight=0, minimumHeight=0)
        self.content_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        self.content_area.setFrameShape(QFrame.NoFrame)

        lay = 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(QPropertyAnimation(self, b"minimumHeight"))
        self.toggle_animation.addAnimation(QPropertyAnimation(self, b"maximumHeight"))
        self.toggle_animation.addAnimation(QPropertyAnimation(self.content_area, b"maximumHeight"))

    def on_pressed(self):
        checked = self.toggle_button.isChecked()
        self.toggle_button.setArrowType(Qt.DownArrow if not checked else Qt.RightArrow)
        self.toggle_animation.setDirection(QAbstractAnimation.Forward
            if not checked
            else 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(500)
            animation.setStartValue(collapsed_height)
            animation.setEndValue(collapsed_height + content_height)
        content_animation = self.toggle_animation.animationAt(self.toggle_animation.animationCount() - 1)
        content_animation.setDuration(500)
        content_animation.setStartValue(0)
        content_animation.setEndValue(content_height)

if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)
    window = MainWindow()
    window.setGeometry(500, 100, 500, 500)
    window.show()
    sys.exit(app.exec_())

Edit :

Here is link to a small video with problem I recorded , for more clear picture. (problem start on 0:12) I noticed problem occurs only when I open all QToolBox and then close it one by one from below and then start to open them again.

eyllanesc
  • 235,170
  • 19
  • 170
  • 241
Vlad
  • 387
  • 3
  • 17
  • I'm not noticing the first problem (I'm using PyQt5, though). Can you explain the *exact* procedure, with the precise order of click operation, including possible scrollbar movement done by you? – musicamante Dec 06 '19 at 12:58
  • Sorry, just noticed that this problem occurs with specific pattern. I edited my question and there is a link to 30 second video I recorded and more precise description of pattern i use to get problem. Thank you – Vlad Dec 06 '19 at 13:20

1 Answers1

2

The problem comes from the fact that when the collapsible box starts resizing, your mouse button will probably still be pressed, and if the button moves due to the scrolling, it will be moved "outside" the cursor position, resulting in the button release event being received outside the button area.

It's a common convention with buttons that if the user clicks on it but moves the cursor outside the button area and then releases the button, the button is not considered as clicked (or checked).
Also, checkable buttons become checked (see down property) when pressed, but are not toggled until the button is released, and if they are already checked, they become unchecked (as in "not down") when released (not when clicked), but, again, the toggled signal is is emitted if the mouse button is released within their geometry.

If you carefully look at your video, you can see that the unchecked buttons are gray, while when checked they have a light blue shade. When you try to uncheck them back, they still receive the pressed event (so the animation works as expected), but then they still remain pressed (blue-ish), and that's because they received the button release event outside their area. You can see the color difference when you try to click the second button after trying to expand it the second time. So, when you click them again, they are already down, they receive the "pressed" signal, but since they're already down, they state is actually checked.

One would think that using the toggled signal would suffice, but this would mean to wait for the mouse button release (as explained before) and for similar cases is not that intuitive, since the user might prefer an immediate reaction to the mouse press, without waiting the release; this is another common convention for this kind of collapsible widgets.

The only solution I can think of is to create a fake release event and send it to the button as soon as the pressed signal is received. This will make the button "think" that the mouse has been released, thus applying the correct checked state.

    def on_pressed(self):
        checked = self.toggle_button.isChecked()
        fakeEvent = QtGui.QMouseEvent(
            QtCore.QEvent.MouseButtonRelease, self.toggle_button.rect().center(), 
            QtCore.Qt.LeftButton, QtCore.Qt.LeftButton, QtCore.Qt.NoModifier)
        QApplication.postEvent(self.toggle_button, fakeEvent)

This class constructor of QMouseEvent (there are 4 of them) is the simplest and you only need to set the local position based on the button rectangle; using the center ensures that the event is always received.
Finally, with postEvent the event is actually sent to the widget through QApplication (it's usually better to avoid sending an event directly to the receiver).


About the "shaking" widgets, that's probably due to the fact that you're using a parallel animation that sets the heights of both the contents and the container; while technically this happens in parallel, I believe that the problem comes from there are certain moments during which the two sizes are not "synchronized", and the layout is receiving (temporarily) unreliable data about their size and hints, probably due to the fact that the widget gets both minimum and maximum size, with the content area being resized afterwards.

After some tests I can tell that there's some slight difference between what could happen between the setMinimumHeight and setMaximumHeight.

   def __init__(self, name):
        # ... 
        self.toggle_animation.animationAt(0).valueChanged.connect(self.checkSizePre)
        self.toggle_animation.animationAt(1).valueChanged.connect(self.checkSizePost)

    def checkSizePre(self, value):
        self.pre = self.y()

    def checkSizePost(self, value):
        QApplication.processEvents()
        post = self.y()
        if self.pre != post:
            print('pre {} post {} diff {}'.format(self.pre, post, abs(self.pre - post)))

This results in a difference that varies between 0 and 6 pixel, which shows that setting those min/max values affects the overall positioning of the widgets. Obviously, those values are usually insignificant for when manually resizing a widget, but since resize events are always slightly delayed, in this case there's no sufficient time for the whole layout system to adjust everything without glitches.

Unfortunately, I can't think of a solution right now, sorry.

musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Big thanks for such detailed answer :) It fixed my problem with collapsing buttons. About animation, I will try to think somethink by myself and will edit later if will find answer – Vlad Dec 07 '19 at 16:36
  • 1
    You're welcome! I'm not sure about using qt animations for this kind of things, because of their "asynchronous" behavior. Since this kind of animation doesn't need *that* level of smoothness (that is usually required for longer events) a timer event driven might be better. I'll think about that, if I get to something I'll add a comment to notify about the possible update. – musicamante Dec 08 '19 at 01:31