Home > Mobile >  Qt Custom Animated Button (Ellipse effect)
Qt Custom Animated Button (Ellipse effect)

Time:07-16

I am trying to make a custom animated button on PyQt. I found a website which has custom buttons: My try for 19th button on website

My code for 19th button:

import sys, os, time
from math import *
from PySide6 import QtCore, QtWidgets, QtGui
from PySide6.QtWidgets import *
from PySide6.QtCore import *
from PySide6.QtGui import *
class Button19(QPushButton):
    Radius = 10
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.backgroundColors = (QtGui.QColor(QtCore.Qt.lightGray),QtGui.QColor(QtCore.Qt.white))
        self.foregroundColors = (QtGui.QColor(QtCore.Qt.black), QtGui.QColor(QtCore.Qt.lightGray))
        font = self.font()
        font.setBold(True)
        self.setFont(font)
        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)

        self.hoverAnimation = QtCore.QVariantAnimation(self)
        self.hoverAnimation.setStartValue(0.)
        self.hoverAnimation.setEndValue(1.)
        self.hoverAnimation.setEasingCurve(QtCore.QEasingCurve.OutCubic)
        self.hoverAnimation.setDuration(400)
        self.hoverAnimation.valueChanged.connect(self.update)
        self.setText("Button")


    _m_isHover = False
    def enterEvent(self, event):
        super().enterEvent(event)
        self._m_isHover = True
        self.hoverAnimation.setDirection(self.hoverAnimation.Forward)
        self.hoverAnimation.start()

    def leaveEvent(self, event):
        super().leaveEvent(event)
        self._m_isHover = False
        self.hoverAnimation.setDirection(self.hoverAnimation.Backward)
        self.hoverAnimation.start()
    def isHover(self):
        return self._m_isHover
    def paintEvent(self, event):
        aniValue = self.hoverAnimation.currentValue()
        painter = QPainter(self)
        painter.setRenderHint(QPainter.Antialiasing)
        path, path2 = QPainterPath(), QPainterPath()
        painter.setBrush(QBrush(self.backgroundColors[0]))
        painter.setPen(Qt.NoPen)

        rect = QRectF(0, 0, self.width(), self.height())
        padding = 10
        rect = rect.adjusted(-padding * aniValue, -padding * aniValue, padding * aniValue, padding * aniValue)
        path.addRoundedRect(rect.adjusted(padding / 2, padding, -padding / 2, -padding), self.Radius, self.Radius)

        painter.drawPath(path)

        painter.setBrush(QBrush(self.foregroundColors[0]))

        painter.setClipPath(path)
        radiusEffectSize = 75
        path2.addEllipse(self.rect().center(), radiusEffectSize * aniValue, radiusEffectSize * aniValue)

        painter.drawPath(path2)
        if self.isHover() or self.hoverAnimation.currentValue() > 0.1: # when leaveEvent triggered, still background color black. So must wait to change textcolor (ofcourse there is probably a better way)
            painter.setPen(self.foregroundColors[1])
        else:
            painter.setPen(self.foregroundColors[0])
        painter.drawText(self.rect(), Qt.AlignCenter, self.text())

if __name__ == "__main__":
    app = QApplication(sys.argv)
    wind = QMainWindow()
    wind.setStyleSheet("QMainWindow{background-color:rgb(247,247,250)}")
    wind.resize(150, 80)
    wid = QWidget()
    lay = QHBoxLayout(wid)
    lay.setAlignment(Qt.AlignCenter)

    mycustombutton = Button19()
    lay.addWidget(mycustombutton)
    wind.setCentralWidget(wid)
    wind.show()
    sys.exit(app.exec())  

Still feels different, not the same. I need help, thanks!

CodePudding user response:

The main issue in your code is that the padding computation is wrong.

You are increasing the size of the padding from the current rectangle and then decrease it by half the padding size, which doesn't make a lot of sense.

You should instead consider the default padding minus the extent based on the animation value, then adjust (reduce) the rectangle based to it:

        padding = 10 * (1 - aniValue)
        path.addRoundedRect(
            rect.adjusted(padding, padding, -padding, -padding), 
            self.Radius, self.Radius
        )

That will not be sufficient, though: the radius has to consider the actual size of the widget, but that can be misleading: if you take the smaller dimension (between width and height) the ellipse could be smaller than the rectangle, while in the opposite case it would grow up too early, making the animation quite odd. The actual radius should actually be computed using the hypotenuse of the right triangle of the widget width and height (a "perfect" implementation should also consider the radius of the rounded rectangle, but that would be quite too much):

        # using hypot() from the math module
        radius = hypot(self.width(), self.height()) / 2
        path2.addEllipse(self.rect().center(), radius, radius)

Not enough, though: if you closely look at the original animation, you'll see that the "leave" event will not be the same: there is no circle, the "black" rounded rectangle just fades out. We need to take care of that too:

        radius = min(self.width(), self.height())
        if (self.hoverAnimation.state() 
            and self.hoverAnimation.direction() == self.hoverAnimation.Forward):
                radius *= aniValue
                # always full opacity on "fade in"
                opacity = 1.
        else:
            # "fade out"
            opacity = aniValue
        path2.addEllipse(self.rect().center(), radius, radius)

        painter.save()
        painter.setOpacity(opacity)
        painter.drawPath(path2)
        painter.restore()

Nearly there. But the text drawing still has issues. First of all, the "base" should always be painted, and the "hover" should be painted over with the opacity value specified above (unless you want an alpha value). Then, we should always remember that buttons could also use "mnemonics" (keyboard shortcuts that are always highlighted with an underlined character, specified with a preceding & in Qt).

For optimization reasons, it's better to "replicate" similar functions instead of using local variables. It might not be wonderful for reading purposes, but painting functions should be always try to be as fast as possible.

So, here's the final result:

    def paintEvent(self, event):
        aniValue = self.hoverAnimation.currentValue()

        painter = QPainter(self)
        painter.setRenderHint(QPainter.Antialiasing)
        painter.setBrush(QBrush(self.backgroundColors[0]))
        painter.setPen(Qt.NoPen)

        rect = self.rect()
        path = QPainterPath()
        padding = 10 * (1 - aniValue)
        path.addRoundedRect(
            QRectF(rect).adjusted(padding, padding, -padding, -padding), 
            self.Radius, self.Radius
        )

        painter.setClipPath(path)

        painter.drawPath(path)
        painter.setBrush(QBrush(self.foregroundColors[0]))
        if aniValue < 1:
            # only draw the default text when the animation isn't finished yet
            painter.setPen(self.foregroundColors[0])
            painter.drawText(rect, Qt.AlignCenter|Qt.TextShowMnemonic, self.text())
        if not aniValue:
            # no hover, just ignore the rest
            return

        hoverPath = QPainterPath()
        radius = hypot(self.width(), self.height()) / 2
        if (aniValue and self.hoverAnimation.state()
            and self.hoverAnimation.direction() == self.hoverAnimation.Forward):
                hoverPath.addEllipse(rect.center(), 
                    radius * aniValue, radius * aniValue)
                painter.drawPath(hoverPath)
        else:
            hoverPath.addEllipse(rect.center(), radius, radius)
            painter.save()
            painter.setOpacity(aniValue)
            painter.drawPath(hoverPath)
            painter.restore()

        painter.setPen(self.foregroundColors[1])
        painter.drawText(rect, Qt.AlignCenter|Qt.TextShowMnemonic, self.text())

Some further notes:

  • isHover() is quite pointless unless you need it for something else but painting: except from extreme performance requirements (for which value caching would make sense), underMouse() is usually sufficient; for this case, it is also a bit irrelevant, as we can be quite sure that the hover state only happens when the animation value is 1 or the animation is active (animation.state()) and its direction is Forward;
  • the "smoothness" of the animation completely depends on its easingCurve(), so please do experiment with all available curves to find what best suits your needs;
  • when working with plain shapes and no borders ("pens"), Qt normally works fine, as it happens with the code above, but be aware that painting with pixel-based devices (as QWidgets) could create artifacts while using anti-aliasing; in that case you have to consider the "pen width" and translate the drawing by half its size to obtain a "perfect" shape on the screen;
  • Related