qtpyqt5qtstylesheets

Cleaner methods for creating multi-variable animations in PyQt5 (css)?


I recently came across an example code for animating a widget using QVariantAnimation, the QVariantAnimation has a start and end point, and the calculated value is injected into the css of the button object and refreshed, at every step in the animation.

The result is obviously decent, and highly useable, I don't know how well it scales if you were to use highly complicated animations or sequences, I don't even know how you'd set things like frame-rate, but those are things I can worry about later.

Here is the gist of the example I found, you can copy and run this code yourself:

from PyQt5 import QtCore, QtGui, QtWidgets

class LoginButton(QtWidgets.QPushButton):
    def __init__(self, parent=None):
        super().__init__(parent)

        self.setMinimumSize(60, 60)

        self.color1 = QtGui.QColor(207, 207, 207)
        self.color2 = QtGui.QColor(242, 242, 242)

        self._animation = QtCore.QVariantAnimation(
            self,
            valueChanged=self._animate,
            startValue=0.00001,
            endValue=0.9999,
            duration=750
        )

    def _animate(self, value):
        qss = """
            font: 75 10pt "Microsoft YaHei UI";
            font-weight: bold;
            color: rgb(255, 255, 255);
            border-style: solid;
            border-radius:21px;
        """
        grad = "background-color: qlineargradient(spread:pad, x1:0, y1:0, x20:, y2:1, stop:0 {color1}, stop:{value} {color2}, stop: 1.0 {color1});".format(
            color1=self.color1.name(), color2=self.color2.name(), value=value
        )
        qss += grad
        self.setStyleSheet(qss)

    def enterEvent(self, event):
        self._animation.setDirection(QtCore.QAbstractAnimation.Forward)
        self._animation.start()
        super().enterEvent(event)

    def leaveEvent(self, event):
        self._animation.setDirection(QtCore.QAbstractAnimation.Backward)
        self._animation.start()
        super().enterEvent(event)

if __name__ == "__main__":
    import sys

    app = QtWidgets.QApplication(sys.argv)

    w = QtWidgets.QWidget()
    lay = QtWidgets.QVBoxLayout(w)

    button = LoginButton()
    lay.addWidget(button)
    #lay.addStretch()
    w.resize(300, 300)
    w.show()
    sys.exit(app.exec_())

This code is very creative because the varied value is actually the "stop" position on a qlineargradient, I don't actually need this, but it's the code I used for inspiration.

Here's the scenario:

As a bit of a thought experiment, I want to tackle the following scenario: I have a button, it's also made of a qlineargradient, but mine is, say, 4 stops. And let's say I want the animation to change the opacity of the individual steps on the qlineargradient... keeping it simple. My problem is that the opacities are all different, and thus typically change at different rates.

For example, here's a scenario with some opacities, at a duration of 175 ("quick" animation):

Whilst it is possible to come up with some code which will handle this exact animation, the code I came up does not always produce decent results... here is the solution I have at the moment.

I used a "QParallelAnimationGroup", and made a QVariantAnimation for every single value which changes at a unique rate. Each animation sets it's changed value to a variable, then the last animation in the group also sets a value, but then finally also updates the css using all the new values - this process "loops" at every frame of the animation and seems to work flawlessly.

Here is the full code example, showing how I animate two different opacities, changing at different rates:

from PyQt5 import QtCore, QtGui, QtWidgets

class CustomButton(QtWidgets.QPushButton):
    def __init__(self, parent=None):
        super().__init__(parent)

        self.setMinimumSize(15, 21)
        
        self.color_1 = "65,69,245"
        self.opacity_1 = 50
        self.opacity_1a = 170
        
        self.color_2 = "172,174,251"
        self.opacity_2 = 40
        self.opacity_2a = 100

        self.anim_1 = QtCore.QVariantAnimation(
            self,
            valueChanged=self._animate_1,
            startValue=self.opacity_1,
            endValue=self.opacity_1a,
            duration=175
            )

        self.anim_2 = QtCore.QVariantAnimation(
            self,
            valueChanged=self._animate_2,
            startValue=self.opacity_2,
            endValue=self.opacity_2a,
            duration=175
            )

        self.anim_group = QtCore.QParallelAnimationGroup()
        self.anim_group.addAnimation(self.anim_1)
        self.anim_group.addAnimation(self.anim_2)

    def _animate_1(self, value):
        self.opa_1_prog = value

    def _animate_2(self,value):
        qss = """
            font: 9;
            color: rgb(255, 255, 255);
            border: none;
            border-radius: 8px;
        """
        grad = "background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 rgba({color_1}, {opacity_1}),stop: 1.0 rgba({color_2},{opacity_2}));".format(
            opacity_1=int(self.opa_1_prog), color_1=self.color_1, opacity_2=int(value), color_2 = self.color_2
            )
        #print(grad)
        qss += grad
        self.setStyleSheet(qss)

    def enterEvent(self, event):
        self.anim_group.setDirection(QtCore.QAbstractAnimation.Forward)
        self.anim_group.start()
        super().enterEvent(event)

    def leaveEvent(self, event):
        self.anim_group.setDirection(QtCore.QAbstractAnimation.Backward)
        self.anim_group.start()
        super().enterEvent(event)

if __name__ == "__main__":
    import sys

    app = QtWidgets.QApplication(sys.argv)

    window_container = QtWidgets.QWidget()
    window_container.setWindowTitle("Buttons")
    window_container.setMinimumSize(200,125)

    button_container = QtWidgets.QWidget()
    button_container.setObjectName("button_container")

    Button_1 = CustomButton()
    Button_2 = CustomButton()

    button_layout = QtWidgets.QVBoxLayout()
    button_layout.addWidget(Button_1)
    button_layout.addWidget(Button_2)

    button_container.setLayout(button_layout)

    window_layout = QtWidgets.QHBoxLayout()
    window_container.setLayout(window_layout)

    Spacing_1 = QtWidgets.QSpacerItem(25,25)

    window_layout.addItem(Spacing_1)
    window_layout.addWidget(button_container)
    window_layout.addItem(Spacing_1)

    window_container.show()
    sys.exit(app.exec_())

In the above example, notice how "_animate_1" literally just sets a value to a variable, and lets the code move on, and "_animate_2" is the one which does the css update.

Here's the problem:

The above code, at first glance, seemed to work brilliantly, I even added extra stops in the gradient, and made extra animations/functions for each individual opacity, all seemed well.

However it has come to my attention that the "refresh rate" of this animation is basically limited to the refresh rate of whatever the "final" animation has - if you have a decent difference between the values being animated, and the animation takes place quickly, all is good - however in a scenario where opacities and even the animation durations may be customised, it is incredibly easy for this code to produce an embarassingly poor result.

Second example, animation duration 1750 ("long" animation to exaggerate the problem):

In such a scenario the animation should still have plenty of frames of animation, due to stop:0 being animated from 50 to 170, however what actually happens is that refresh is tied to the second stop, so you get 5 painfully slow refreshes with big differences in color. Essentially meaning whatever animation in my QParallelAnimaionGroup handles the css refresh needs to itself have many steps, or every other animation is limited by it, and this seems wrong to me.

On a different gradient animation with 4 stops I was even able to have certain stops never reach their end destinations in the animation, because the animation responsible for the refresh may finish first and declare the animation to be done.

So how can I effectively update css using multi-variable animations? I am open to any suggestions that can mitigate the above explained problems.


Solution

  • It's conceptually acceptable, but... not

    Your approach is reasonable in principle, but it has some important issues.

    Inconsistent updates

    First of all, it updates the QSS only when the second animation updates.

    While this may be acceptable whenever the value range is compatible and the actual updates happens at a frequency high enough, the reality is that you need to update the QSS whenever any of both animations change their value.

    To clarify this, just make the value range of the second animation small enough (for instance, from 40 to 45), and the duration quite long.

    Supposing that the animation lasts three seconds, and since you're correctly using integer values, the animation between 40 and 45 will only have 11 steps, half a second apart (40., 40.5, [...], 44.5, 45.). The button will just jump to the gradients, no matter if the first animation actually changed its values in the meantime.

    One possible fix for your case would be to call _animate_2() from _animate_1() using the currentValue() of the second animation:

        def _animate_1(self, value):
            self.opa_1_prog = value
            self._animate_2(self.anim_1.currentValue())
    

    Proper connection

    Common approach and practice tell that we should connect to a function/slot that specifically takes action upon the possible arguments of the emitted signals, but that's not an absolute rule.

    Considering this, it makes little sense to set an attribute for the value of the first animation (unless we need to retrieve a persistent value based on the previously set one).

    A more ideal solution could be the following:

        def _animate_1(self, value):
            self._animate(value, self.anim_2.currentValue())
    
        def _animate_2(self, value):
            self._animate(self.anim_1.currentValue(), value)
    
        def _animate(self, op1, op2):
            ...
            .format(
                opacity_1=op1, color_1=self.color_1, 
                opacity_2=op2, color_2=self.color_2
            )
    

    The above is certainly more consistent, but considering the situation, there's really little point in having three functions that finally achieve the same result. A more appropriate approach would be to connect all the animation's valueChanged signals to a common function, which would just do the following:

        def _animate(self):
            ...
            .format(
                opacity_1=self.anim_1.currentValue(), color_1=self.color_1, 
                opacity_2=self.anim_2.currentValue(), color_2=self.color_2
            )
    

    Notice how the argument of the signal is completely ignored in the function definition

    Single animation approach

    Ideally, since you're using two integer values, there's a further possible approach: using QPoint, which is listed in the implemented types that QVariantAnimation features.

    Instead of using three animations, you could just use one:

            self.opacity_1 = 50
            self.opacity_1a = 170
            
            self.opacity_2 = 40
            self.opacity_2a = 100
    
            self.anim = QVariantAnimation(self, duration=175)
            self.anim.setStartValue(QPoint(
                self.opacity_1, self.opacity_2))
            self.anim.setEndValue(QPoint(
                self.opacity_1a, self.opacity_2a))
            self.anim.valueChanged.connect(self._animate)
            ...
    

    This approach will have results similar to the above, but with the benefit of only using one animation.

    Considering the list of supported types of QVariantAnimation, we could even go up to 4 integer values, using QRect: its width and height would be used for the third and fourth values, respectively. For higher levels of animations, a QVariantAnimation subclass is probably a feasible option, by overriding its interpolated() function.

    Qt Style Sheet (QSS) considerations with animations

    All this, though, requires a moment to stop and think about the overall "using QSS with animations".

    You wisely thought that connecting to just one signals would prevent unnecessary QSS updates. Unfortunately, as shown above, your approach actually creates other unwanted problems.

    Still, doing updates based on all signal changes isn't always preferable, and must be done with awareness.

    We have to consider that whenever setStyleSheet() is called, Qt does a lot of things under the hood. Shortly put:

    1. parse the new QSS (no matter if the current one is identical! It will always be reparsed and computed again!);
    2. internally create rules based on the QSS contents, also consider possibly inherited QSS (from any parent and the whole QApplication);
    3. invalidate all current basic properties (font, palette, etc.) and request the style to validate them again;
    4. possibly call for geometry changes (see updateGeometry()) that may require the layout managers to layout again their contents (and, therefore, redraw most of their widgets), for instance when changing borders, margins or paddings;
    5. finally, fully redraw the widget (and, possibly, all other widgets affected by the above changes);

    The above is all done on the "C++ side", so it's relatively fast, but that doesn't mean it's instantly achieved, nor without performance detriment.

    While we could eventually check if the current styleSheet() is different from the newly computed one, considering the changes in play, it's more likely that 99.9% of the time it will, making that verification an unnecessary overhead.

    So, while it's acceptable to use animations and QSS (as long as they're properly cared about), you should always consider how those changes affect the overall UI. If the updates are too frequent and may affect inner or outer widgets too, maybe you should consider another approach (eg. custom painting, possibly with caching).

    Other issues

    Unfortunately, not all has been fixed.
    Let's see.

    Signal connections in the constructors

    There's another major problem in your code: the connection created in the animation constructor.

    PyQt allows to use signals as keyworded arguments, but that feature must be used with awareness, because keyword arguments are treated as dictionaries.

    Up to Python 3.5, dictionaries were always unordered, and this was also valid for keyword arguments, meaning that key1=value1, key2=value2 may have been evaluated in the opposite order.

    Since 3.7 (3.6 was an internal change, but still matters to our case), dictionaries always respect insertion order, meaning that key1 will always be evaluated before key2.

    In reality, the implementation change above doesn't change the problem, but just shows different ways it may fail.

    You are putting the signal connection as the very beginning of the constructor:

            self.anim_1 = QtCore.QVariantAnimation(
                self,
                valueChanged=self._animate_1,
                startValue=self.opacity_1,
                endValue=self.opacity_1a,
                duration=175
                )
    

    Now consider what happens if _animate_1 actually requests self.anim_1.currentValue() as suggested above.
    What happens, then, is:

    1. a QVariantAnimation object is created;
    2. PyQt connects its valueChanged signal to self._animate_1;
    3. startValue is set, which therefore causes emitting the valueChanged signal;
    4. _animate_1 tries to use `self.anim_1.currentValue();
    5. an exception is raised;

    If you run your program in a terminal or prompt, you'll see something like the following:

    Traceback (most recent call last):
        ... # skipping
    AttributeError: 'CustomButton' object has no attribute 'anim_1'
    

    Why does that happen? Because when the start value is changed, it triggers the valueChanged signal, causing a call to _animate_1(), but at that point the QVariantAnimation constructor has not returned yet, meaning that self.anim_1 doesn't exist.

    For Python <3.7, doing such a connection (that relies on an existing reference) must always be avoided, as it would work unexpectedly: sometimes it will, others it won't.
    For >=3.7 the keyworded connection must be set after anything that triggers the signal is used in the constructor:

            self.anim_1 = QtCore.QVariantAnimation(
                self,
                startValue=self.opacity_1,
                endValue=self.opacity_1a,
                duration=175,
                valueChanged=self._animate_1,
                )
    

    In reality, unless you're completely aware of the underlying mechanisms, keyworded connections must always be avoided, and should normally be placed as a separate, explicit connection (usually near the end of the function that creates the object).

    Considering all written above (ignoring the QPoint suggestion), it should be something like this:

            self.anim_1 = QtCore.QVariantAnimation(
                self,
                startValue=self.opacity_1,
                endValue=self.opacity_1a,
                duration=175,
                )
            self.anim_2 = QtCore.QVariantAnimation(
                self,
                startValue=self.opacity_2,
                endValue=self.opacity_2a,
                duration=175
                )
    
            ...
    
            self.anim_1.valueChanged.connect(self._animate_1)
            self.anim_2.valueChanged.connect(self._animate_2)
    

    Proper initialization

    There's another issue, which the above suggestion clearly demonstrates: unless one of the functions connected to the valueChanged signals are called, the button won't have any QSS set anyway.

    Considering everything suggested above (except for the QPoint usage), here is a more appropriate, safe and consistent modification:

    class CustomButton(QtWidgets.QPushButton):
        def __init__(self, parent=None):
            super().__init__(parent)
    
            self.setMinimumSize(15, 21)
            
            self.color_1 = "65, 69, 245"
            opacity_1 = 50
            opacity_1a = 170
            
            self.color_2 = "172, 174, 251"
            opacity_2 = 40
            opacity_2a = 45
    
            self.anim_1 = QtCore.QVariantAnimation(
                self,
                startValue=opacity_1,
                endValue=opacity_1a,
                duration=750, 
                )
    
            self.anim_2 = QtCore.QVariantAnimation(
                self,
                startValue=opacity_2,
                endValue=opacity_2a,
                duration=750, 
                )
    
            self.anim_group = QtCore.QParallelAnimationGroup()
            self.anim_group.addAnimation(self.anim_1)
            self.anim_group.addAnimation(self.anim_2)
    
            self.anim_1.valueChanged.connect(self._animate)
            self.anim_2.valueChanged.connect(self._animate)
    
            self._animate()
    
        def _animate(self):
            qss = """
                font: 9;
                color: rgb(255, 255, 255);
                border: none;
                border-radius: 8px;
            """
            grad = """
                background-color: qlineargradient(spread:pad, 
                    x1:0, y1:0, x2:0, y2:1, 
                    stop:0 rgba({color_1}, {opacity_1}), 
                    stop:1.0 rgba({color_2},{opacity_2})
                );
            """.format(
                color_1=self.color_1, 
                opacity_1=self.anim_1.currentValue(), 
                color_2 = self.color_2, 
                opacity_2=self.anim_2.currentValue(), 
                )
            qss += grad
            self.setStyleSheet(qss)
    
        ...
    

    Not finished yet...

    A final, unrelated problem.

    You're using the same QSpacerItem instance twice. While (probably for historical reasons) Qt and PyQt allow that, you should never use the same QSpacerItem object more than once in any layout manager.

        Spacing_1 = QtWidgets.QSpacerItem(25,25)
    
        window_layout.addItem(Spacing_1)
        window_layout.addWidget(button_container)
        window_layout.addItem(Spacing_1)
    

    The above is highly discouraged (especially in Python, see this).

    Remember that UI elements are normally unique to their context (for example, a widget cannot be shown in more than one window), and shall never be "reused" in different places, unless they're expected to be shown elsewhere.

    Either create two explicit instances, or eventually create a helper function:

    
    def makeSpacer(width=25, height=25):
        return QtWidgets.QSpacerItem(25,25)
    
    ...
        window_layout.addItem(makeSpacer())
        window_layout.addWidget(button_container)
        window_layout.addItem(makeSpacer())
    

    PS: since you're using integers for both start and end values, there's no point in using int(). Remember that, when using numbers, both start and end values must be consistent: both must be either be integers, floats or any acceptable numeric value that has the same type for both.