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()
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)
%v
format text causes a proper updateWe 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:
paintEvent()
); if it's the same, it returns False
(no repaint);True
;True
only if the displayed value is changed (since it requires repainting), which happens when at least one of the following conditions is met:
%v
(which is your "working" case);%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;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".
While the above suggestion will technically solve the issue, your implementation has a few important issues:
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.
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()
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.