0

Building on to an awesome answer from here by eyllanesc, I have added QListWidget to the collapsible box.

There is a text box below where user will provide search string and my aim is to highlight that item in the QListWidget (if present).

The current code works fine ie it will highlight the text and scroll it to top if the QToolButton is already expanded but it will just open and highlight the item but wont scroll it to top if it is not already expanded. (So the user now doesn't know if he has found the item or not as he cant see it highlighted.) Strange thing is if I press enter again then it will scroll it to top.

I tried various things like making the QlistWidget active, in focus etc but didnt help.

Please tell me what I am missing so that I dont need to press Enter twice in case the QToolButton is not expanded already.

EDIT: Removing the animation part from code as suggested.


import time
from PyQt5 import QtCore
from PyQt5.QtWidgets import QMainWindow, QListWidget, QLabel, QApplication, QVBoxLayout, \
    QWidget, QSizePolicy, QToolButton, QScrollArea, QFrame, QDockWidget, QLineEdit, QHBoxLayout, QAbstractItemView

collapsed_list = ['What', 'should', 'be', 'done', 'to', 'fix', 'this', 'issue?', 'I', 'am', 'confused']


class CollapsibleDemo(QWidget):
    def __init__(self, title="", parent=None):
        super(CollapsibleDemo, self).__init__(parent)
        self.toggle_button = QToolButton(
            text=title, checkable=True, checked=False
        )
        self.toggle_button.setSizePolicy(
            QSizePolicy.Expanding, QSizePolicy.Fixed
        )
        self.toggle_button.setToolButtonStyle(
            QtCore.Qt.ToolButtonTextBesideIcon
        )
        self.toggle_button.setArrowType(QtCore.Qt.RightArrow)
        self.toggle_button.pressed.connect(self.on_pressed)

        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(10, 10, 1, 1)
        lay.addWidget(self.toggle_button)
        lay.addWidget(self.content_area)


    @QtCore.pyqtSlot()
    def on_pressed(self):
        checked = self.toggle_button.isChecked()
        self.toggle_button.setArrowType(
            QtCore.Qt.DownArrow if not checked else QtCore.Qt.RightArrow
        )

        if not checked:
            self.content_area.setMaximumHeight(self.content_height + self.collapsed_height)
        else:
            self.content_area.setMaximumHeight(0)


    def setContentLayout(self, layout):
        self.content_area.setLayout(layout)
        self.collapsed_height = (
                self.sizeHint().height() - self.content_area.maximumHeight()
        )
        self.content_height = layout.sizeHint().height()

    def set_text(self, title):
        self.toggle_button.setText(title)


class Try(QMainWindow):

    def __init__(self, ):
        super().__init__()
        self.width = 800
        self.height = 800
        self.init_ui()

    def init_ui(self):
        self.resize(self.width, self.height)
        self.create_background()
        self.add_collapsed_list_box()
        self.add_find_text_box()
        self.vlay.addStretch()
        self.find_text_box.setFocus()

    def create_background(self):
        dock = QDockWidget()
        self.setCentralWidget(dock)
        dock.setFeatures(QDockWidget.NoDockWidgetFeatures)
        scroll = QScrollArea()
        dock.setWidget(scroll)
        content = QWidget()
        scroll.setWidget(content)
        scroll.setWidgetResizable(True)
        self.vlay = QVBoxLayout(content)
        self.vlay.setSpacing(10)

    def add_collapsed_list_box(self):
        self.box = CollapsibleDemo(f"Whats inside here!")
        self.vlay.addWidget(self.box)
        lay_diff = QVBoxLayout()

        self.qlist = QListWidget()
        self.qlist.addItems(collapsed_list)

        lay_diff.addWidget(self.qlist)
        self.box.setContentLayout(lay_diff)


    def add_find_text_box(self):
        self.find_text_box = QLineEdit("am")

        find_label = QLabel("    Search text:   ")
        enter_label = QLabel(" (Press Enter)")
        hlayout = QHBoxLayout()
        hlayout.addWidget(find_label)
        hlayout.addWidget(self.find_text_box)
        hlayout.addWidget(enter_label)
        hlayout.addStretch(3)
        self.vlay.addLayout(hlayout)
        self.find_text_box.returnPressed.connect(self.find_selected)

    def find_selected(self):
        user_text = self.find_text_box.text()

        if user_text in collapsed_list:
            if not self.box.toggle_button.isChecked():
                self.box.on_pressed()
                self.box.toggle_button.setChecked(True)
            item = self.qlist.findItems(user_text, QtCore.Qt.MatchRegExp)[0]
            item.setSelected(True)
            self.qlist.scrollToItem(item, QAbstractItemView.PositionAtTop)


if __name__ == "__main__":
    app = QApplication([])
    window = Try()
    window.show()
    app.exec()

  • I see that you've set very short durations for the animations: 5 milliseconds are 1/200 of a second, even assuming that a computer was so fast to be able to actually show an animation that fast, the human eye is not capable of seeing that (we barely see any difference that lasts 20-30ms). So, in my opinion, there's no point at all to use animations, which will solve your problem, since when you call `scrollToItem` the listview is still collapsed, so it won't be able to scroll to anything. – musicamante Oct 07 '21 at 22:27
  • I think keeping low numbers even if it appears as instant to user's eye should actually favor the situation of scrolling. Still to test, I increased the duration to 500 as in original code, I also added a 1 sec sleep just before scroll command and it still doesn't solve it. – Abhinav Saini Oct 07 '21 at 22:41
  • Adding a sleep won't change anything, since blocking functions prevent the event loop to properly process everything (including animation). I believe you still don't understand the problem: animations are asynchronous, when you call `on_pressed`, the animation *starts*, but `scrollToItem` is called *right after that*, and at that point the box is still collapsed, so it won't scroll to anything. Using a short interval for the animation will ***not*** solve the problem, because the problem is the fact that there *is* an animation. Even setting the duration to 0 will not change that. – musicamante Oct 07 '21 at 23:00
  • There's also another aspect to consider: item views require some time in order to properly process size changes, and the scroll bars are not instantly updated: even if you don't use animations and instantly resize the box, `scrollToItem` won't work in any case like that. So, you have to clarify if you still want the animation feature (with a *proper* duration) or you are not interested in that, because the solution would be very different. – musicamante Oct 07 '21 at 23:06
  • Got it. How should I write setContentLayout without any animation? It should basically switch between content_height and collapsed_height on clicking. – Abhinav Saini Oct 07 '21 at 23:11
  • I have updated my ques with the animation part removed from the code. The issue of not scrolling to top still persists. – Abhinav Saini Oct 07 '21 at 23:35
  • That's because of the aforementioned reason: scroll areas require some time in order to update themselves, and when you call `scrollToItem` right after resizing it the scroll bars do not reflect the contents yet. – musicamante Oct 07 '21 at 23:36
  • Is there any way to make this work? (no animations required) – Abhinav Saini Oct 07 '21 at 23:44

1 Answers1

0

There are two problems:

  1. animations are asynchronous: when they are started, control is immediately returned, so any function called after animation.start() is instantly executed; this means that at that point the value of the animation is still the start value;
  2. item views (and scroll areas in general) require some time in order to properly update their scroll bars; even if the box is being collapsed immediatly (without animation), scrollToItem will not work, because the scroll area has not layed out its contents yet;

In order to solve the problem, the solution is to delay the scrollToItem call using a singleShot QTimer.

If an animation is being used, it's mandatory that you wait for the animation to end, because the scroll bars are being updated along with the box size.

A possible solution is to create a local function that delays the scrollToItem and, if the box is collapsed, call it only whenever the animation is completed by connecting to the animation finished signal, and then disconnect it (this is very important!).

def find_selected(self):
    user_text = self.find_text_box.text()

    if user_text in collapsed_list:
        collapsed = not self.box.toggle_button.isChecked()
        if collapsed:
            self.box.on_pressed()
            self.box.toggle_button.setChecked(True)
        item = self.qlist.findItems(user_text, QtCore.Qt.MatchRegExp)[0]
        item.setSelected(True)

        def scrollTo():
            QtCore.QTimer.singleShot(1, 
                lambda: self.qlist.scrollToItem(item, QAbstractItemView.PositionAtTop))
            if collapsed:
                # disconnect the function!!!
                self.box.toggle_animation.finished.disconnect(scrollTo)
        if collapsed:
            self.box.toggle_animation.finished.connect(scrollTo)
        else:
            scrollTo()

If you're not interested in the animation, then it's even easier:

class CollapsibleDemo(QWidget):
    def __init__(self, title="", parent=None):
        # ...
        # remove all the animations in here

    @QtCore.pyqtSlot()
    def on_pressed(self):
        checked = self.toggle_button.isChecked()
        self.toggle_button.setArrowType(
            QtCore.Qt.DownArrow if not checked else QtCore.Qt.RightArrow
        )
        if checked:
            self.setFixedHeight(self.collapsed_height)
            self.content_area.setFixedHeight(0)
        else:
            self.setFixedHeight(self.expanded_height)
            self.content_area.setFixedHeight(self.content_height)

    def setContentLayout(self, layout):
        self.content_area.setLayout(layout)
        self.collapsed_height = self.sizeHint().height() - self.content_area.maximumHeight()
        self.content_height = layout.sizeHint().height()
        self.expanded_height = self.collapsed_height + self.content_height

    def set_text(self, title):
        self.toggle_button.setText(title)


class Try(QMainWindow):
    def find_selected(self):
        user_text = self.find_text_box.text()

        if user_text in collapsed_list:
            if not self.box.toggle_button.isChecked():
                self.box.on_pressed()
                self.box.toggle_button.setChecked(True)
            item = self.qlist.findItems(user_text, QtCore.Qt.MatchRegExp)[0]
            item.setSelected(True)
            QtCore.QTimer.singleShot(1, lambda:
                self.qlist.scrollToItem(item, QAbstractItemView.PositionAtTop))

As explained in the comments, animations make sense when their duration is at least 50-100ms; using a duration too short makes the animation useless (computers are not able to show the effect and our eyes would not be able to see it anyway) and only complicates things (you have to wait for the animation to end).

musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Thanks a lot for the help. I fidgeted around with the diff between your code and mine. Actually the four lines of `setFixedHeight` in `on_pressed` method fixed the issue without using `singleShot`. I am not sure why that happened though. – Abhinav Saini Oct 08 '21 at 23:14
  • @AbhinavSaini Note that you can ***not*** rely on the fact that it works in your case, as it's probably just a case valid for your situation. Events are not triggered in the same way on all platforms and in any situations, their order depends on a lot of things (OS, computer performance, scheduling, Qt version, etc). As already said, it's not guaranteed that the view has been properly resized (and its scroll bars updated) when you change the fixed size of the widget. Note that using a `QTimer.singleShot` is actually a (normally safe) workaround, but it's not the *strictly proper* way to do it. – musicamante Oct 08 '21 at 23:22
  • @AbhinavSaini in fact, whenever a scrolling based on the contents is required (as `scrollToItem` is), it is *mandatory* to wait for events to be properly processed before actually do any action based on size changes. In certain cases, even using the `QTimer.singleShot` is not enough, as changes might be delayed due to loading and laying out of items (for instance, whenever the model has not been fully loaded, like for QFileSystemModel) or animations are in use, which might also be due to the current style (some styles use animations internally, and you have absolutely *no control* over them). – musicamante Oct 08 '21 at 23:29
  • @AbhinavSaini So, I *strongly* suggest to use a QTimer anyway as a *minimum* safe precaution. Remember: what you get on your computer is ***not*** what others get. A proper, safer and more accurate approach would be to create a system that monitors the view size and waits until the resizing has been completed (which is what I did in my first example, since we know that the resizing is based on the animation), and ***only then*** calls `scrollToItem`. – musicamante Oct 08 '21 at 23:31