Search code examples
pythonpython-3.xpyside6pyqt6

Lagging QProgressBar animation with text format without %v


I want to animate the change in the value of the QProgressBar. I haven't found a better solution than just multiplying all values by 1000 for example, and then animating the transition. For example, if I want to change a value from 5 to 4, the progress bar animation changes the value from 5000 to 4000, but the numbers 5 and 4 are displayed, instead of 5000 and 4000. Here's how I implemented it:

class CustomProgressBar(QWidget):
    def __init__(self,
                 parent,
                 curr_value: int,
                 max_value: int):
        super().__init__(parent=parent)
        self.MULTIPLIER = 1000

        layout = QVBoxLayout(self)

        self.progress_bar = self.pg = QProgressBar(self)
        self.progress_bar.setTextVisible(True)
        self.progress_bar.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.progress_bar.setFixedSize(QSize(1000, 100))
        self.progress_bar.setMaximum(max_value * self.MULTIPLIER)
        self.progress_bar.setValue(curr_value * self.MULTIPLIER)

        displayed_value = int(self.pg.value() / self.MULTIPLIER)
        displayed_max_value = int(self.pg.maximum() / self.MULTIPLIER)
        self.progress_bar.setFormat(f"{displayed_value}/{displayed_max_value}")

        layout.addWidget(self.progress_bar)

        # Animation
        self.animation = QPropertyAnimation(self.progress_bar, b"value", self.progress_bar)
        self.animation.setDuration(500)
        self.animation.setEasingCurve(QEasingCurve.Type.OutCubic)

    def set_value(self, new_value):
        new_value = new_value * self.MULTIPLIER if new_value > 0 else 0
        self.animate_value_change(new_value)

        displayed_new_value = int(new_value / self.MULTIPLIER)
        displayed_max_value = int(self.pg.maximum() / self.MULTIPLIER)
        self.progress_bar.setFormat(f"{displayed_new_value}/{displayed_max_value}")

    def animate_value_change(self, new_value):
        """Animation of value change"""

        # changing text color
        if new_value <= self.progress_bar.maximum() / 2:
            self.progress_bar.setStyleSheet("QProgressBar {color: white;}")
        else:
            self.progress_bar.setStyleSheet("QProgressBar {color: black;}")

        # animating bar
        self.animation.stop()
        self.animation.setStartValue(self.progress_bar.value())
        self.animation.setEndValue(new_value)
        self.animation.start()

And here's how it works: https://youtu.be/tUyuoVAD-KY

The animation turned out a bit jerky and glitchy. I found an extremely strange solution: add %v to the format:

self.progress_bar.setFormat(f"{displayed_value}/{displayed_total_value} %v")

And yes, the animation is much smoother: https://youtu.be/8eOC9xC8K7w

Why does it work that way? And is it possible to achieve the same smoothness without displaying the real value of QProgressBar (%v) ?

Full Python example:

from PySide6.QtWidgets import QWidget, QProgressBar, QVBoxLayout, QApplication
from PySide6.QtCore import Qt, QPropertyAnimation, QEasingCurve, QObject, Signal, QSize
from threading import Thread
import time


STYLE = """
QProgressBar {
    border-radius: 3px;
    background-color: rgb(26, 39, 58);
}

QProgressBar::chunk {
    border-radius: 3px;
    background-color: qlineargradient(spread:pad, x1:0, y1:0.54, x2:1, y2:0.5625, stop:0 rgba(0, 85, 255, 255), stop:1 rgba(8, 255, 227, 255));
}
"""


class CustomProgressBar(QWidget):
    def __init__(self,
                 parent,
                 curr_value: int,
                 max_value: int):
        super().__init__(parent=parent)
        self.MULTIPLIER = 1000
        self.setStyleSheet(STYLE)
        layout = QVBoxLayout(self)

        self.progress_bar = self.pg = QProgressBar(self)
        self.progress_bar.setTextVisible(True)
        self.progress_bar.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.progress_bar.setFixedSize(QSize(1000, 100))
        self.progress_bar.setMaximum(max_value * self.MULTIPLIER)
        self.progress_bar.setValue(curr_value * self.MULTIPLIER)

        displayed_value = int(self.pg.value() / self.MULTIPLIER)
        displayed_max_value = int(self.pg.maximum() / self.MULTIPLIER)
        self.progress_bar.setFormat(f"{displayed_value}/{displayed_max_value}")

        layout.addWidget(self.progress_bar)

        # Animation
        self.animation = QPropertyAnimation(self.progress_bar, b"value", self.progress_bar)
        self.animation.setDuration(500)
        self.animation.setEasingCurve(QEasingCurve.Type.OutCubic)

    def set_value(self, new_value):
        new_value = new_value * self.MULTIPLIER if new_value > 0 else 0
        self.animate_value_change(new_value)

        displayed_new_value = int(new_value / self.MULTIPLIER)
        displayed_max_value = int(self.pg.maximum() / self.MULTIPLIER)
        self.progress_bar.setFormat(f"{displayed_new_value}/{displayed_max_value}")

    def animate_value_change(self, new_value):
        """Animation of value change"""

        # changing text color
        if new_value <= self.progress_bar.maximum() / 2:
            self.progress_bar.setStyleSheet("QProgressBar {color: white;}")
        else:
            self.progress_bar.setStyleSheet("QProgressBar {color: black;}")

        # animating bar
        self.animation.stop()
        self.animation.setStartValue(self.progress_bar.value())
        self.animation.setEndValue(new_value)
        self.animation.start()


class MyCounter(QObject):
    value_changed = Signal(int)

    def __init__(self, value: int, step: int = 1):
        super().__init__()

        self._value = value
        self.step = step

    @property
    def value(self) -> int:
        return self._value

    @value.setter
    def value(self, value: int):
        self._value = value if value >= 0 else 0
        self.value_changed.emit(self.value)

    def make_step(self):
        self.value -= self.step

    def process(self, delay: int | float):
        time.sleep(3)
        default_value = self.value
        while True:
            while self.value:
                self.make_step()
                time.sleep(delay)
            self.value = default_value
            time.sleep(delay)

    def run(self, delay: int | float = 1):
        Thread(target=self.process, args=(delay, ), daemon=True).start()


if __name__ == "__main__":
    app = QApplication()

    widget = QWidget()
    layout = QVBoxLayout(widget)

    counter = MyCounter(30, 4)
    progress_bar = CustomProgressBar(widget, 30, 30)

    counter.value_changed.connect(progress_bar.set_value)

    layout.addWidget(progress_bar)

    widget.show()
    counter.run(1)
    app.exec()

Solution

  • tl;dr

    QProgressBar has an internal function that checks if it really needs to repaint itself, but under some circumstances (especially when using QSS and large values) that is not effective.

    To force the update of the progress bar, connect its valueChanged signal to the update() function:

        self.progress_bar.valueChanged.connect(self.progress_bar.update)
    

    Why the %v format text causes a proper update

    We have to consider that QProgressBar is one of the oldest Qt widgets, and at that time it was quite common to display such bars with arbitrary "chunks" that showed an approximation of a progress, which also allowed some level of optimization: the widget was only updated when the amount of "displayed chunks" (the progress) actually changed. Remember that we're talking about something that was written for computers of 20 years ago, based on UI concepts that first appeared 20 years earlier, where a "chunk" was a simple character displayed on the screen: such optimizations were quite important then.

    Now, all this is caused by a private function of QProgressBar, repaintRequired() (see the Qt6 sources, but it existed since Qt4) that checks if it actually needs repainting when the value property is changed.

    More specifically, when setValue() is called (which is what happens when your animation updates the property value), it checks if the new value actually requires painting also by calling that repaintRequired() function, and eventually does a repaint depending on its returned value.

    This is an optimization done to avoid unnecessary repainting when the appearance of the progress bar doesn't require a full repaint if small value changes would not (theoretically) affect its look.

    QProgressBar actually calls repaint() only if that function returns True, and that may happen depending based on the following checks:

    • compute a "painted value difference", which is the absolute difference between the new value and the last painted one (an internal value updated in paintEvent()); if it's the same, it returns False (no repaint);
    • if the value is equal to the minimum or the maximum, it returns True;
    • if the text is visible, it returns True only if the displayed value is changed (since it requires repainting), which happens when at least one of the following conditions is met:
      • the format contains %v (which is your "working" case);
      • the format contains %p and the "painted difference" above is greater or equal than the total steps (maximum - minimum) divided by 100; this means that in this case the visual accuracy of the "progress rectangle" is primarily based on the percentage, which is integer based;
    • it queries the current style and returns whether the groove size (the space in which the full bar is shown) multiplied by the painted difference is greater than the chunk size multiplied by the total steps;

    The last point is what causes your issue, because under certain conditions it "thinks" that the chunk size has not caused changes to the appearance, due to the great difference between the groove size and the value span.

    Now, a simple solution for your specific case, is to add the following line:

        self.animation.valueChanged.connect(self.progress_bar.update)
    

    This will ensure that, no matter what, the progress bar will always request a repaint when the value is changed, even if the private function above would tell otherwise.

    A more appropriate approach, though, which would work in any case (even without animations), is to do the following:

        self.progress_bar.valueChanged.connect(self.progress_bar.update)
    

    Note that, since you're practically overriding the overall look of the progress bar with QSS, it's better to use the default fusion style for it, and avoid setting the chunk width property in the QSS; that's because that style has the more appropriate implementation of style sheets, and works consistently on all platforms:

        self.progress_bar.setStyle(QFactoryStyle.create('fusion'))
    

    The above is partially related to the unresolved QTBUG-50919, as setting the chunk width has issues with rounded borders that use a radius bigger than the width. This is because, by default, the chunk size is much bigger and is used to display "blocks" instead of the whole progress bar "value area".

    A possible alternative

    While the above suggestion will technically solve the issue, your implementation has a few important issues:

    • the text color is only updated when the "real" value is changed and the animation is started, but does not consider the current animation position;
    • due to the above and the fact that you decide the color only based on the "target" value, there are cases for which the text might become partially or almost completely unreadable;
    • most importantly, it uses a further widget as container for the progress bar, instead of an actual progress bar, limiting its proper access and usage, including functions and signals;

    I suggest you a different approach that may be more appropriate. It involves overriding the painting of the widget, but since it uses the existing QStyle features, it won't change the overall appearance, as that's fundamentally what QProgressBar does in paintEvent().

    The concept is that QProgressBar painting uses a QStyleOptionProgressBar to draw its contents, and that option is set up using the current progress bar state, most importantly the minimum, maximum and progress values.

    In the override, instead, we substitute those values with those of the animation, which does use a multiplier. The major benefit of this is that the actual value property of the progress bar is always consistent, instead of returning a multiplied value.

    Since we are already overriding painting, I also improved the text appearance: the color is painted both white and black, but clipped to the underlying progress rectangle so that it's always readable, even when the progress is in the middle of the text. The benefit of this is that you can also set the related colors by using QSS with the color and selection-color properties.

    Screenshot of the result

    STYLE = '''
        QProgressBar {
            color: navy;
            selection-color: lavender;
            border-radius: 3px;
            background-color: rgb(26, 39, 58);
        }
    
        QProgressBar::chunk:horizontal {
            border-radius: 3px;
            background-clip: padding;
            background-color: qlineargradient(spread:pad, 
                x1:0, y1:0.54, x2:1, y2:0.5625, 
                stop:0 rgba(0, 85, 255, 255), stop:1 rgba(8, 255, 227, 255)
            );
        }
    '''
    
    class CustomProgressBar(QProgressBar):
        MULTIPLIER = 1000
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
            self.setStyleSheet(STYLE)
            self.setFormat('%v/%m')
            self.setAlignment(Qt.AlignmentFlag.AlignCenter)
    
            self.animation = QVariantAnimation(self)
            self.animation.setDuration(500)
            self.animation.setEasingCurve(QEasingCurve.Type.OutCubic)
    
            # set the animation state based on the current progress bar state; this
            # is required for proper painting until the value is actually changed
            aniValue = (self.value() - self.minimum()) * self.MULTIPLIER
            self.animation.setStartValue(aniValue)
            self.animation.setEndValue(aniValue)
    
            self.animation.valueChanged.connect(self.update)
    
        def setValue(self, value):
            current = self.value()
            if current == value:
                return
            super().setValue(value)
            if self.animation.state():
                self.animation.stop()
                self.animation.setStartValue(self.animation.currentValue())
            else:
                self.animation.setStartValue(current * self.MULTIPLIER)
            self.animation.setEndValue(value * self.MULTIPLIER)
            self.animation.start()
    
            # setValue() uses repaint(), we need to do it too to avoid flickering
            self.repaint()
    
        def minimumSizeHint(self):
            return super().sizeHint()
    
        def sizeHint(self):
            return QSize(1000, 100)
    
        def paintEvent(self, event):
            qp = QStylePainter(self)
            opt = QStyleOptionProgressBar()
            self.initStyleOption(opt)
    
            # set the option values based on the animation values
            opt.minimum = self.minimum() * self.MULTIPLIER
            opt.maximum = self.maximum() * self.MULTIPLIER
            opt.progress = self.animation.currentValue()
            opt.textVisible = False
    
            style = self.style()
            qp.drawControl(style.ControlElement.CE_ProgressBar, opt)
    
            progRect = style.subElementRect(
                style.SubElement.SE_ProgressBarContents, opt, self)
            progressPos = (
                (opt.progress - opt.minimum) 
                * progRect.width() 
                / (opt.maximum - opt.minimum)
            )
            left = QRect(
                progRect.left(), progRect.top(), 
                int(progressPos), progRect.height()
            )
            textRect = style.subElementRect(
                style.SubElement.SE_ProgressBarLabel, opt, self)
    
            if (left & textRect).isValid():
                qp.setClipRect(left)
                qp.setPen(self.palette().color(QPalette.ColorRole.Text))
                qp.drawText(textRect, Qt.AlignmentFlag.AlignCenter, self.text())
    
            if left.right() < textRect.right():
                qp.setPen(self.palette().color(QPalette.ColorRole.HighlightedText))
                right = progRect.adjusted(left.right() + 1, 0, 0, 0)
                qp.setClipRect(right)
                qp.drawText(textRect, Qt.AlignmentFlag.AlignCenter, self.text())
    
    
    ...
    
    if __name__ == "__main__":
        app = QApplication([])
    
        widget = QWidget()
        layout = QVBoxLayout(widget)
    
        counter = MyCounter(30, 3)
        progress_bar = CustomProgressBar(maximum=30, value=30)
    
        counter.value_changed.connect(progress_bar.setValue)
    
        layout.addWidget(progress_bar)
    
        widget.show()
        counter.run(1)
        app.exec()
    

    Final notes

    Directly using a subclass of the "target" widget is always the preferred choice, since it allows to properly and directly access Qt functions and features and also improves object management and memory usage. Consider, for instance, the benefit of accessing the value property of the actual progress bar, and the fact that it returns its real value.
    Also, if you are not careful enough, adding a further widget can cause layout discrepancies, due to default layout margins and spacings.

    Note that the above setValue() override will not substitute the existing slot, meaning that any external change to the property that is not achieved with explicit calls to the "new" setValue() method will not change affect the animation. For example, using progress_bar.setProperty('value', 5).

    While your MyCounter implementation is acceptable in principle, the fact that it's based on a not persistent Thread object may create some issues, since you have no direct nor easy access to it once run is called, for instance, if you need to stop that thread or properly interact with it.
    The more appropriate solutions are QObject/QThread pairs (using moveToThread()) or even a QThread subclass that overrides run(). Unless you really know what you're doing and why, always try to use existing Qt classes; in situations like these, using Python's Thread would pose no benefit, especially considering that the low level implementation of the thread is fundamentally identical.