qtpyqtpyside6qmainwindowqdockwidget

Prevent Flickering in PyQT when add and remove QDockWidgets on QMainWindow


I have a QMainWindow where I want to “add” & "remove" QDockWidgets with some matplotlib content.

Task:

  1. Each new added QDockWidget should initially have content of certain width. Let’s say 192 pixels.
  2. Each new added QDockWidget should not change existing widths of already displayed QDockWidgets.

Solution: I save widths of currently displayed QDockWidgets in a list I extend the whole size of QMainWidget by 192+4 (to accommodate for extra splitters width)I resize all QDockWidgets using saved list (at stage 1) and new width=192 only for last added QDockWidget

Solution works, but it FLICKERS! Try to hit “Add” action many times rapidly. "Remove" flickers especially badly! It looks like when I perform stage 2 and resize the QMainWidget, at first QMainWidget automatically resizes and displays all existing QDockWidgets based on some weird native rule, and only afterwards my stage 3 code resizes QDockWidgets how I need it. So, for a short time all QDockWidgets have this weird autoresizing implemented when QMainWindow changes size and redrawing them with proper sizes creates FLICKERING.

I wonder how the same result as mine can be achieved without this Flickering???

Upd. I found the way to avoid flickering on "add" by performing either QDockWidget.show() or QDockWidget.setVisible (see commented lines of code in provided example). However I don't understand why it work AND anyway similar code doesn't work on "remove" option. It still flickers.

Regards D.

from PySide6.QtWidgets import (
    QApplication,
    QMainWindow,
    QDockWidget,
    QToolBar,
    QLabel
)
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QAction

import sys

import random

from matplotlib.figure import Figure 
from matplotlib.backends.backend_qtagg import FigureCanvas

names1 = ["a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z"]
names2 = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
colors = ["black", "orange", "green", "blue", "yellow"]
 
def generate_random_name():
    return f"{random.choice(names1)}_{random.choice(names2)}{random.choice(names2)}"

def generate_random_color():
    return f"{random.choice(colors)}"



class CustomContentWidget(FigureCanvas):
    def __init__(self):
        super().__init__(Figure())

        self.setAttribute(Qt.WA_StyledBackground, True)
        self.setStyleSheet(f"background-color:{generate_random_color()};")

        self.figure.add_axes([0.1, 0.1, 0.8, 0.8])

        self.sizeHintSize = QSize(192, 600)
        self.minimumSizeHintSize = QSize(0, 0)

    def sizeHint(self): 
        return self.sizeHintSize   
    def minimumSizeHint(self): 
        return self.minimumSizeHintSize
    


class CustomLabel (QLabel):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setStyleSheet(f"border: 1px solid {generate_random_color()};")



class CustomQDockWidget(QDockWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.dockedWidgetLabel=CustomLabel(str(self.size().width()))
        self.setTitleBarWidget(self.dockedWidgetLabel)

        self.sizeHintSize = QSize(192, 600)
        self.minimumSizeHintSize = QSize(0, 0)

    def sizeHint(self): 
        return self.sizeHintSize   
    def minimumSizeHint(self): 
        return self.minimumSizeHintSize   
    def resizeEvent(self, event):
        self.dockedWidgetLabel.setText(str(self.size().width()))




class DummyQDockWindowsmanager(QMainWindow):
    def __init__(self):
        super().__init__()

        self.setDockOptions(QMainWindow.AnimatedDocks) 

        toolbar = QToolBar("DockerToolbar")
        self.addToolBar(toolbar)

        addAction = QAction("Add", self)
        toolbar.addAction(addAction)
        addAction.triggered.connect(self.on_addAction)

        removeAction = QAction("Remove", self)
        toolbar.addAction(removeAction)
        removeAction.triggered.connect(self.on_removeAction)

        self.listOfQDockWidgets = []
        self.listofQDockWidgetsContent = []


    def on_addAction(self):
        listOfSavedWidths = []
        if len(self.listOfQDockWidgets) > 0:
            listOfSavedWidths = [dw.size().width() for dw in self.listOfQDockWidgets]
        dockedWidget = CustomQDockWidget(generate_random_name(), self)
        self.listOfQDockWidgets.append(dockedWidget)
        dockedWidget.setAllowedAreas(Qt.TopDockWidgetArea)
        dockedWidget.setFeatures(QDockWidget.DockWidgetFloatable | QDockWidget.DockWidgetMovable)

        contentWidget = CustomContentWidget()
        dockedWidget.setWidget(contentWidget)

        self.addDockWidget(Qt.TopDockWidgetArea, dockedWidget)
        listOfSavedWidths.append(192)

        if len(self.listOfQDockWidgets) > 1:
            self.resize(self.size().width() + 192 + 4, self.size().height()) 
            self.resizeDocks(self.listOfQDockWidgets, listOfSavedWidths, Qt.Horizontal)
            
            # HINT !!! below commented code removes flickering on adding. Don't know why.
            #for qdockw in self.findChildren(QDockWidget):
            #    qdockw.show() # this removes flickering
            #    qdockw.setVisible(True) # or this as well removes flickering


    def on_removeAction(self):
        if len(self.listOfQDockWidgets) > 0:
            listOfSavedWidths = [dw.size().width() for dw in self.listOfQDockWidgets]
            widthToSubstract = listOfSavedWidths.pop()
            wDockWidgetToDelete = self.listOfQDockWidgets.pop()
            wDockWidgetToDelete.deleteLater()
            finWidth = self.size().width() - widthToSubstract - 4
            if finWidth <= 0: finWidth = 192 # handling final width when last widget is removed
            self.resize(finWidth, self.size().height())
            self.resizeDocks(self.listOfQDockWidgets, listOfSavedWidths, Qt.Horizontal)

if __name__ == "__main__":
    app = QApplication(sys.argv)
    w = DummyQDockWindowsmanager()
    w.show()
    w.resize(192,300)
    sys.exit(app.exec())

Solution

  • An important thing that must always be considered is that not every function call has immediate results when dealing with event driven programming.

    Events are processed sequentially, and some may revert the state a previous event did. In normal conditions, this happens so fast that no visible effect is shown, but that is not always possible.

    In order to avoid complex issues (including possible recursion), some UI related functions actually delay some events, by posting them on the event loop. In complex scenarios, usually dealing with advanced layout management and scroll areas, many events are actually processed some time after others, possibly resulting in some flickering.

    What happens here is that when addDockWidget() is called, the dock is not immediately shown.
    In fact, if you try to add a print(dockedWidget.isVisible()) within on_addAction(), you'll see that it will print False: at that point, the dock widget is not visible yet, and hidden widgets are normally ignored by layout managers. The dock widget will become visible, but that is being delayed due to the way dock widget management works.

    The result is that you're resizing the main window by providing a larger width than the currently visible dock widgets would need, thus causing them to instantly expand.

    When the new dock widget actually becomes visible (some time after that), the layout is again invalidated, causing flickering, because now the proper sizes are being applied.

    As you found out, explicitly showing the dock widget is a possible work around: this is because at that point the window layout will know that the dock widget is visible and will try to resize all widgets considering the expected sizes.

    An alternative would be to force the immediate handling of events in the queue by using QApplication.processEvents(). But this should be normally used as last resource: if explicitly setting the visibility works, you should use that (note that show() implicitly calls setVisible(True)).

    Be aware that, though, you shouldn't use hard coded values for sizes, especially if those values are uniquely accessible:

    This is a more appropriate implementation of the dock addition:

        def on_addAction(self):
            # no point in recreating a list if listOfQDockWidgets isn't empty
            listOfSavedWidths = [dw.width() for dw in self.listOfQDockWidgets]
    
            dockedWidget = CustomQDockWidget(generate_random_name(), self)
            dockedWidget.setAllowedAreas(Qt.TopDockWidgetArea)
            dockedWidget.setFeatures(
                QDockWidget.DockWidgetFloatable
                | QDockWidget.DockWidgetMovable
            )
            self.listOfQDockWidgets.append(dockedWidget)
    
            contentWidget = CustomContentWidget()
            dockedWidget.setWidget(contentWidget)
    
            self.addDockWidget(Qt.TopDockWidgetArea, dockedWidget)
            dockWidth = dockedWidget.sizeHint().width()
            listOfSavedWidths.append(dockWidth)
    
            if len(self.listOfQDockWidgets) == 1:
                return
    
            # dynamically get the real width of a QSplitterHandle; note the final
            # "self" argument, which is important especially when using QSS
            handleWidth = self.style().pixelMetric(
                QStyle.PM_SplitterWidth, None, self)
    
            self.resize(
                self.size().width() + dockWidth + handleWidth, 
                self.size().height()
            )
            self.resizeDocks(
                self.listOfQDockWidgets, listOfSavedWidths, Qt.Horizontal)
    
            dockedWidget.show()
    

    The flickering on removal is caused by similar aspects, but the main reason for it is that you missed an important step: you didn't "tell" the main window that the dock widget has been removed.

    As the name suggests, calling deleteLater() results in deleting an object later. This means that its deletion is put at the end of the event queue, so, when the main window layout tries to adjust itself, it still exists, resulting in trying to resize all dock widgets based on the given width (they would be smaller). When it's finally deleted, the remaining dock widgets will be resized again, thus causing the flickering again.

    Similarly to the above, you could call hide() (or setVisible(False)), which will correctly compute the overall layout requirements.
    The more appropriate approach, though, is to call removeDockWidget(), which has an immediate effect.

    I cannot provide an authoritative or reliable explanation for which removeDockWidget() has an immediate effect, as opposed to addDockWidget(), but, knowing how Qt works, I can make an assumption: computing a new layout based on an arbitrary new widget may be costly, as it requires calling its size hints (including that of the layout manager, which can have an unknown "depth" of possible nested layouts); removing one, instead, allows reusing possibly cached size hints, so it should theoretically be faster.

    Here is a similarly fixed code for removal:

        def on_removeAction(self):
            if not self.listOfQDockWidgets:
                return
    
            listOfSavedWidths = [dw.width() for dw in self.listOfQDockWidgets]
            widthToSubstract = listOfSavedWidths.pop()
            wDockWidgetToDelete = self.listOfQDockWidgets.pop()
            wDockWidgetToDelete.deleteLater()
    
            self.removeDockWidget(wDockWidgetToDelete)
    
            if not self.listOfQDockWidgets:
                minWidth = max(
                    self.width() - widthToSubstract,
                    self.minimumWidth(), self.minimumSizeHint().width()
                )
                self.resize(minWidth, self.size().height())
    
                return
    
            handleWidth = self.style().pixelMetric(
                QStyle.PM_SplitterWidth, None, self)
    
            self.resize(
                self.width() - widthToSubstract - handleWidth, 
                self.size().height()
            )
            self.resizeDocks(
                self.listOfQDockWidgets, listOfSavedWidths, Qt.Horizontal)
    

    Further, unrelated notes: