10

While Qt provides the QGraphicsDropShadowEffect, there's no "Neumorphism" effect available:

Comparison between drop shadow and neumorphism

In css there is the box-shadow property (that's how it's done in the image above), which can have multiple colors, but Qt lacks support for that property, and it's not possible to apply more than one graphics effect at once.

Can this be done?

musicamante
  • 41,230
  • 6
  • 33
  • 58

2 Answers2

19

The solution is to create a custom subclass of QGraphicsEffect and using gradients.

At first I thought about following the same concept used for CSS, subclassing QGraphicsDropShadowEffect and using another one internally to draw the "other" shadow, but I didn't like the result: in certain situations (usually when radius and contrast are too big) it just doesn't work:

wrong result

If you look closely, you'll find out that the result is too similar to the drop shadow, like the object is floating, while it should be "extruding".

The only effective solution I found was to manually draw everything, using linear gradients for borders and composite gradients for the corners. While the first is pretty logical, the second required a bit of ingenuity by using composite modes of QPainter: Qt only has radials and conical gradients, but there is no "mix" between them.

The trick then was to create a radial gradient for the "light" color with the full color at the center and the same color at the border with 0 alpha, then superimpose a conical gradient for the "dark" color (with the "dark" color on start and the "light" at 90°), which will be painted using the alpha component of the first gradient.

steps to create the composite gradient

Then it's just a matter of creating functions to update each one of the properties: distance (the extent of the effect), color (used for the gradients, defaults to the application's QPalette.Window color role), origin (the corner used as the "source" for the light) and an optional clipRadius for rounded borders.

Some important notes:

  • since it's a QGraphicsEffect, it can only be applied to a "parent" widget: children cannot have another effect applied on them, which means that if you have a container like QGroupBox or QTabWidget, you have to choose if you want to apply it to the parent or to each of the children;
  • due to its "simple" nature, it only supports rectangular shapes: if a widget has a mask, the effect shape will still be based on a rectangle;
  • layout margins and spacings should be taken into account, as multiple effects could overlap if the widgets that use them are too narrow; I'd suggest using a QProxyStyle and set a minimum default for both PM_Layout[*]Margin and PM_Layout[*]Spacing, and return a value according to the length property;
  • the clipRadius property allows rounded border clipping, but it's not perfect, since QPainter's clipping doesn't support antialiasing; I'll see if I can address this issue in the future;
  • when applied to QGraphicsScene items, similarly to QGraphicsDropShadowEffect, the effect is in device coordinates, so transformations (rotation, scale, shearing) won't be applied; I'll update this answer whenever I'll be able to solve this issue too;

final neumorphism effect result

And here is a comparison between the Qt QGraphicsDropShadowEffect, the css emulation, and my NeumorphismEffect (the last two have rounded borders: the css version uses the border-radius property while mine is set with clipRadius):

a cool comparison

class NeumorphismEffect(QtWidgets.QGraphicsEffect):
    originChanged = QtCore.pyqtSignal(QtCore.Qt.Corner)
    distanceChanged = QtCore.pyqtSignal(float)
    colorChanged = QtCore.pyqtSignal(QtGui.QColor)
    clipRadiusChanged = QtCore.pyqtSignal(int)

    _cornerShift = (QtCore.Qt.TopLeftCorner, QtCore.Qt.TopRightCorner, 
        QtCore.Qt.BottomRightCorner, QtCore.Qt.BottomLeftCorner)

    def __init__(self, distance=4, color=None, origin=QtCore.Qt.TopLeftCorner, clipRadius=0):
        super().__init__()

        self._leftGradient = QtGui.QLinearGradient(1, 0, 0, 0)
        self._leftGradient.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)
        self._topGradient = QtGui.QLinearGradient(0, 1, 0, 0)
        self._topGradient.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)

        self._rightGradient = QtGui.QLinearGradient(0, 0, 1, 0)
        self._rightGradient.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)
        self._bottomGradient = QtGui.QLinearGradient(0, 0, 0, 1)
        self._bottomGradient.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)

        self._radial = QtGui.QRadialGradient(.5, .5, .5)
        self._radial.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)
        self._conical = QtGui.QConicalGradient(.5, .5, 0)
        self._conical.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)

        self._origin = origin
        distance = max(0, distance)
        self._clipRadius = min(distance, max(0, clipRadius))
        self._setColor(color or QtWidgets.QApplication.palette().color(QtGui.QPalette.Window))
        self._setDistance(distance)

    def color(self):
        return self._color

    @QtCore.pyqtSlot(QtGui.QColor)
    @QtCore.pyqtSlot(QtCore.Qt.GlobalColor)
    def setColor(self, color):
        if isinstance(color, QtCore.Qt.GlobalColor):
            color = QtGui.QColor(color)
        if color == self._color:
            return
        self._setColor(color)
        self._setDistance(self._distance)
        self.update()
        self.colorChanged.emit(self._color)

    def _setColor(self, color):
        self._color = color
        self._baseStart = color.lighter(125)
        self._baseStop = QtGui.QColor(self._baseStart)
        self._baseStop.setAlpha(0)
        self._shadowStart = self._baseStart.darker(125)
        self._shadowStop = QtGui.QColor(self._shadowStart)
        self._shadowStop.setAlpha(0)

        self.lightSideStops = [(0, self._baseStart), (1, self._baseStop)]
        self.shadowSideStops = [(0, self._shadowStart), (1, self._shadowStop)]
        self.cornerStops = [(0, self._shadowStart), (.25, self._shadowStop), 
            (.75, self._shadowStop), (1, self._shadowStart)]

        self._setOrigin(self._origin)

    def distance(self):
        return self._distance

    def setDistance(self, distance):
        if distance == self._distance:
            return
        oldRadius = self._clipRadius
        self._setDistance(distance)
        self.updateBoundingRect()
        self.distanceChanged.emit(self._distance)
        if oldRadius != self._clipRadius:
            self.clipRadiusChanged.emit(self._clipRadius)

    def _getCornerPixmap(self, rect, grad1, grad2=None):
        pm = QtGui.QPixmap(self._distance + self._clipRadius, self._distance + self._clipRadius)
        pm.fill(QtCore.Qt.transparent)
        qp = QtGui.QPainter(pm)
        if self._clipRadius > 1:
            path = QtGui.QPainterPath()
            path.addRect(rect)
            size = self._clipRadius * 2 - 1
            mask = QtCore.QRectF(0, 0, size, size)
            mask.moveCenter(rect.center())
            path.addEllipse(mask)
            qp.setClipPath(path)
        qp.fillRect(rect, grad1)
        if grad2:
            qp.setCompositionMode(qp.CompositionMode_SourceAtop)
            qp.fillRect(rect, grad2)
        qp.end()
        return pm

    def _setDistance(self, distance):
        distance = max(1, distance)
        self._distance = distance
        if self._clipRadius > distance:
            self._clipRadius = distance
        distance += self._clipRadius
        r = QtCore.QRectF(0, 0, distance * 2, distance * 2)

        lightSideStops = self.lightSideStops[:]
        shadowSideStops = self.shadowSideStops[:]
        if self._clipRadius:
            gradStart = self._clipRadius / (self._distance + self._clipRadius)
            lightSideStops[0] = (gradStart, lightSideStops[0][1])
            shadowSideStops[0] = (gradStart, shadowSideStops[0][1])

        # create the 4 corners as if the light source was top-left
        self._radial.setStops(lightSideStops)
        topLeft = self._getCornerPixmap(r, self._radial)

        self._conical.setAngle(359.9)
        self._conical.setStops(self.cornerStops)
        topRight = self._getCornerPixmap(r.translated(-distance, 0), self._radial, self._conical)

        self._conical.setAngle(270)
        self._conical.setStops(self.cornerStops)
        bottomLeft = self._getCornerPixmap(r.translated(0, -distance), self._radial, self._conical)

        self._radial.setStops(shadowSideStops)
        bottomRight = self._getCornerPixmap(r.translated(-distance, -distance), self._radial)

        # rotate the images according to the actual light source
        images = topLeft, topRight, bottomRight, bottomLeft
        shift = self._cornerShift.index(self._origin)
        if shift:
            transform = QtGui.QTransform().rotate(shift * 90)
            for img in images:
                img.swap(img.transformed(transform, QtCore.Qt.SmoothTransformation))

        # and reorder them if required
        self.topLeft, self.topRight, self.bottomRight, self.bottomLeft = images[-shift:] + images[:-shift]

    def origin(self):
        return self._origin

    @QtCore.pyqtSlot(QtCore.Qt.Corner)
    def setOrigin(self, origin):
        origin = QtCore.Qt.Corner(origin)
        if origin == self._origin:
            return
        self._setOrigin(origin)
        self._setDistance(self._distance)
        self.update()
        self.originChanged.emit(self._origin)

    def _setOrigin(self, origin):
        self._origin = origin

        gradients = self._leftGradient, self._topGradient, self._rightGradient, self._bottomGradient
        stops = self.lightSideStops, self.lightSideStops, self.shadowSideStops, self.shadowSideStops

        # assign color stops to gradients based on the light source position
        shift = self._cornerShift.index(self._origin)
        for grad, stops in zip(gradients, stops[-shift:] + stops[:-shift]):
            grad.setStops(stops)

    def clipRadius(self):
        return self._clipRadius

    @QtCore.pyqtSlot(int)
    @QtCore.pyqtSlot(float)
    def setClipRadius(self, radius):
        if radius == self._clipRadius:
            return
        oldRadius = self._clipRadius
        self._setClipRadius(radius)
        self.update()
        if oldRadius != self._clipRadius:
            self.clipRadiusChanged.emit(self._clipRadius)

    def _setClipRadius(self, radius):
        radius = min(self._distance, max(0, int(radius)))
        self._clipRadius = radius
        self._setDistance(self._distance)

    def boundingRectFor(self, rect):
        d = self._distance + 1
        return rect.adjusted(-d, -d, d, d)

    def draw(self, qp):
        restoreTransform = qp.worldTransform()

        qp.setPen(QtCore.Qt.NoPen)
        x, y, width, height = self.sourceBoundingRect(QtCore.Qt.DeviceCoordinates).getRect()
        right = x + width
        bottom = y + height
        clip = self._clipRadius
        doubleClip = clip * 2

        qp.setWorldTransform(QtGui.QTransform())
        leftRect = QtCore.QRectF(x - self._distance, y + clip, self._distance, height - doubleClip)
        qp.setBrush(self._leftGradient)
        qp.drawRect(leftRect)

        topRect = QtCore.QRectF(x + clip, y - self._distance, width - doubleClip, self._distance)
        qp.setBrush(self._topGradient)
        qp.drawRect(topRect)

        rightRect = QtCore.QRectF(right, y + clip, self._distance, height - doubleClip)
        qp.setBrush(self._rightGradient)
        qp.drawRect(rightRect)

        bottomRect = QtCore.QRectF(x + clip, bottom, width - doubleClip, self._distance)
        qp.setBrush(self._bottomGradient)
        qp.drawRect(bottomRect)

        qp.drawPixmap(x - self._distance, y - self._distance, self.topLeft)
        qp.drawPixmap(right - clip, y - self._distance, self.topRight)
        qp.drawPixmap(right - clip, bottom - clip, self.bottomRight)
        qp.drawPixmap(x - self._distance, bottom - clip, self.bottomLeft)

        qp.setWorldTransform(restoreTransform)
        if self._clipRadius:
            path = QtGui.QPainterPath()
            source, offset = self.sourcePixmap(QtCore.Qt.DeviceCoordinates)

            sourceBoundingRect = self.sourceBoundingRect(QtCore.Qt.DeviceCoordinates)
            qp.save()
            qp.setTransform(QtGui.QTransform())
            path.addRoundedRect(sourceBoundingRect, self._clipRadius, self._clipRadius)
            qp.setClipPath(path)
            qp.drawPixmap(source.rect().translated(offset), source)
            qp.restore()
        else:
            self.drawSource(qp)
musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Is there a simple way to move the shadows from being on the outside to being inner shadows - say for when someone hovers/clicks on the button? I can move the edge gradients inside and reflect the gradient patterns, but I'm not sure how to go about doing the corners? – SamG101 Mar 24 '20 at 09:27
  • @SamG101 I'm not sure I've understood you, can you provide a mockup image of what you'd like to achieve? – musicamante Mar 24 '20 at 09:44
  • On this link, https://dribbble.com/shots/9890709-Neumorphism-UI-Trend-2020 the middle example (inner shadow). – SamG101 Mar 24 '20 at 09:55
  • @SamG101 I did some testing with it, but I decided to avoid that feature that because it would make it almost unusable with standard widgets: almost all QWidgets make their own drawing near their borders (many of which have their own "3D" effect, like buttons) which would result in a terrible result: it would only work fine with widgets that have margins big enough (such as centered text labels) and have sizes twice the effect length. Anyway, you can try to do it on your own, by using internal coordinates and reversing the clipping of the corners. – musicamante Mar 24 '20 at 10:33
  • 1
    Ok, thanks a lot! Side note to fix the non-smooth clipping: in the `draw` function, set `qp.setRenderHints(qp.Antialiasing | qp.SmoothPixmapTransform)`, and in the `_getCornerPixmap` function do the same. – SamG101 Mar 24 '20 at 11:39
  • I have been using a slightly modified version of this code in my project, but when I apply the effect to a `QMenu` object, the effect is not shown. The menu has rounded corners, and I can see the effect around the corners, where the cutout from the normal rectangle is. Do you have any idea why the menu isn't showing the effect? I have also done `menu.setWindowFlags(menu.windowFlags() | Qt.NoDropShadowWindowHint)` before adding the effect. Thanks. – SamG101 Apr 26 '20 at 11:35
  • You cannot apply such an effect on a QMenu, since the base requirement of this effect is to have enough space *around* it to show it (the effect is *outside* the widget geometry), hence it has to be a child of another widget. – musicamante Apr 27 '20 at 07:21
  • Hi, I have another question - when the shadows have no alpha property, the corner blending works perfectly, but if I give both shadows an alpha property, the the corners only blend with one colour. Do you know the reason for this? Thanks! – SamG101 Jun 11 '20 at 15:25
  • @SamG101 I cannot test it right now, but I suppose that's due to the way composite works. I'd suggest you to make some tests with the composition modes and QPainter (on a QPixmap, not within a paintEvent). – musicamante Jun 11 '20 at 15:33
  • Amazing work here. I'm new to PyQt5 - is there a way to convert the above code to a .qss stylesheet? @musicamante – avi Mar 18 '21 at 19:52
  • @avi no, QSS doesn't provide support for graphics effects, this can only be applied by code. – musicamante Apr 17 '21 at 14:31
  • It's nicely works with PyQt5 but it needs bigger spaces when using circular widgets.- Btw; Does anyone know why the effect is not shown with PySide2 or how to make it work? – lymphatic Aug 13 '21 at 10:32
0

I just a little bit eddit.Namely, removed the value change events, added the ability to change light and dark colors, fixed the display of the contents of the container. Also, based on this class, another class was created to add inner shadows.It work with PySide6.

Important! Inner shadows are drawing after the content is drawed, so don't make the shadows too big as this can obscure the content. This is done in order to preserve the ability to change the background color.

class OutsideNeumorphismEffect(QtWidgets.QGraphicsEffect):
    _cornerShift = (
    QtCore.Qt.TopLeftCorner, QtCore.Qt.TopLeftCorner, QtCore.Qt.BottomRightCorner, QtCore.Qt.BottomLeftCorner)

    def __init__(self, distance: int = 4, lightColor: QtGui.QColor = QtGui.QColor("#FFFFFF"),
                 darkColor: QtGui.QColor = QtGui.QColor("#7d7d7d"), clipRadius: int = 4,
                 origin=QtCore.Qt.TopLeftCorner):
        super().__init__()

        self._leftGradient = QtGui.QLinearGradient(1, 0, 0, 0)
        self._leftGradient.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)
        self._topGradient = QtGui.QLinearGradient(0, 1, 0, 0)
        self._topGradient.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)

        self._rightGradient = QtGui.QLinearGradient(0, 0, 1, 0)
        self._rightGradient.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)
        self._bottomGradient = QtGui.QLinearGradient(0, 0, 0, 1)
        self._bottomGradient.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)

        self._radial = QtGui.QRadialGradient(.5, .5, .5)
        self._radial.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)
        self._conical = QtGui.QConicalGradient(.5, .5, 0)
        self._conical.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)

        self._origin = origin
        distance = max(0, distance)
        self._clipRadius = min(distance, max(0, clipRadius))
        self._setColors(lightColor, darkColor)
        self._setDistance(distance)

    def setColors(self, color1, color2):
        if isinstance(color1, QtCore.Qt.GlobalColor) and isinstance(color2, QtCore.Qt.GlobalColor):
            color1 = QtGui.QColor(color1)
            color2 = QtGui.QColor(color2)

            self._setColors(color1, color2)

            self._setDistance(self._distance)
            self.update()

    def _setColors(self, color1, color2):

        self._baseStart = color1
        self._baseStop = QtGui.QColor(color1)
        self._baseStop.setAlpha(0)
        self._shadowStart = color2
        self._shadowStop = QtGui.QColor(color2)
        self._shadowStop.setAlpha(0)

        self.lightSideStops = [(0, self._baseStart), (1, self._baseStop)]
        self.shadowSideStops = [(0, self._shadowStart), (1, self._shadowStop)]
        self.cornerStops = [(0, self._shadowStart), (.25, self._shadowStop),
                            (.75, self._shadowStop), (1, self._shadowStart)]

        self._setOrigin(self._origin)

    def distance(self):
        return self._distance

    def setDistance(self, distance):
        if distance == self._distance:
            return

        self._setDistance(distance)
        self.updateBoundingRect()

    def _getCornerPixmap(self, rect, grad1, grad2=None):
        pm = QtGui.QPixmap(self._distance + self._clipRadius, self._distance + self._clipRadius)
        pm.fill(QtCore.Qt.transparent)
        qp = QtGui.QPainter(pm)
        if self._clipRadius > 1:
            path = QtGui.QPainterPath()
            path.addRect(rect)
            size = self._clipRadius * 2 - 1
            mask = QtCore.QRectF(0, 0, size, size)
            mask.moveCenter(rect.center())
            path.addEllipse(mask)
            qp.setClipPath(path)
        qp.fillRect(rect, grad1)
        if grad2:
            qp.setCompositionMode(qp.CompositionMode_SourceAtop)
            qp.fillRect(rect, grad2)
        qp.end()
        return pm

    def _setDistance(self, distance):
        distance = max(1, distance)
        self._distance = distance
        if self._clipRadius > distance:
            self._clipRadius = distance
        distance += self._clipRadius
        r = QtCore.QRectF(0, 0, distance * 2, distance * 2)

        lightSideStops = self.lightSideStops[:]
        shadowSideStops = self.shadowSideStops[:]

        if self._clipRadius:
            gradStart = self._clipRadius / (self._distance + self._clipRadius)
            lightSideStops[0] = (gradStart, lightSideStops[0][1])
            shadowSideStops[0] = (gradStart, shadowSideStops[0][1])

        # create the 4 corners as if the light source was top-left
        self._radial.setStops(lightSideStops)
        topLeft = self._getCornerPixmap(r, self._radial)

        self._conical.setAngle(359.9)
        self._conical.setStops(self.cornerStops)
        topRight = self._getCornerPixmap(r.translated(-distance, 0), self._radial, self._conical)

        self._conical.setAngle(270)
        self._conical.setStops(self.cornerStops)
        bottomLeft = self._getCornerPixmap(r.translated(0, -distance), self._radial, self._conical)

        self._radial.setStops(shadowSideStops)
        bottomRight = self._getCornerPixmap(r.translated(-distance, -distance), self._radial)

        # rotate the images according to the actual light source
        images = topLeft, topRight, bottomRight, bottomLeft
        shift = self._cornerShift.index(self._origin)
        if shift:
            transform = QtGui.QTransform().rotate(shift * 90)
            for img in images:
                img.swap(img.transformed(transform, QtCore.Qt.SmoothTransformation))

        # and reorder them if required
        self.topLeft, self.topRight, self.bottomRight, self.bottomLeft = images[-shift:] + images[:-shift]

    def origin(self):
        return self._origin

    def setOrigin(self, origin):
        origin = QtCore.Qt.Corner(origin)
        if origin == self._origin:
            return
        self._setOrigin(origin)
        self._setDistance(self._distance)
        self.update()

    def _setOrigin(self, origin):
        self._origin = origin

        gradients = self._leftGradient, self._topGradient, self._rightGradient, self._bottomGradient
        stops = self.lightSideStops, self.lightSideStops, self.shadowSideStops, self.shadowSideStops

        # assign color stops to gradients based on the light source position
        shift = self._cornerShift.index(self._origin)
        for grad, stops in zip(gradients, stops[-shift:] + stops[:-shift]):
            grad.setStops(stops)

    def clipRadius(self):
        return self._clipRadius

    def setClipRadius(self, radius):
        if radius == self._clipRadius:
            return
        self._setClipRadius(radius)
        self.update()

    def _setClipRadius(self, radius):
        radius = min(self._distance, max(0, int(radius)))
        self._clipRadius = radius
        self._setDistance(self._distance)

    def boundingRectFor(self, rect):
        d = self._distance
        return rect.adjusted(-d, -d, d, d)

    def draw(self, qp):
        restoreTransform = qp.worldTransform()

        qp.setPen(QtCore.Qt.NoPen)
        x, y, width, height = self.sourceBoundingRect(QtCore.Qt.DeviceCoordinates).getRect()
        right = x + width
        bottom = y + height
        clip = self._clipRadius
        doubleClip = clip * 2

        if self._clipRadius:
            path = QtGui.QPainterPath()
            source = self.sourcePixmap(QtCore.Qt.DeviceCoordinates)
            sourceBoundingRect = self.sourceBoundingRect(QtCore.Qt.DeviceCoordinates)
            qp.save()
            qp.setTransform(QtGui.QTransform())
            path.addRoundedRect(sourceBoundingRect.x(), sourceBoundingRect.y(), sourceBoundingRect.width(),
                                sourceBoundingRect.height(), self._clipRadius, self._clipRadius)
            qp.setClipPath(path)
            qp.drawPixmap(sourceBoundingRect.x() - self._distance, sourceBoundingRect.y() - self._distance, source)
            qp.restore()
        else:
            path = QtGui.QPainterPath()
            source = self.sourcePixmap(QtCore.Qt.DeviceCoordinates)
            sourceBoundingRect = self.sourceBoundingRect(QtCore.Qt.DeviceCoordinates)
            qp.save()
            qp.setTransform(QtGui.QTransform())
            path.addRect(sourceBoundingRect.x(), sourceBoundingRect.y(), sourceBoundingRect.width(),
                         sourceBoundingRect.height())
            qp.setClipPath(path)
            qp.drawPixmap(sourceBoundingRect.x() - self._distance, sourceBoundingRect.y() - self._distance, source)
            qp.restore()

        qp.setWorldTransform(QtGui.QTransform())
        leftRect = QtCore.QRectF(x - self._distance, y + clip, self._distance, height - doubleClip)
        qp.setBrush(self._leftGradient)
        qp.drawRect(leftRect)

        topRect = QtCore.QRectF(x + clip, y - self._distance, width - doubleClip, self._distance)
        qp.setBrush(self._topGradient)
        qp.drawRect(topRect)

        rightRect = QtCore.QRectF(right, y + clip, self._distance, height - doubleClip)
        qp.setBrush(self._rightGradient)
        qp.drawRect(rightRect)

        bottomRect = QtCore.QRectF(x + clip, bottom, width - doubleClip, self._distance)
        qp.setBrush(self._bottomGradient)
        qp.drawRect(bottomRect)

        qp.drawPixmap(x - self._distance, y - self._distance, self.topLeft)
        qp.drawPixmap(right - clip, y - self._distance, self.topRight)
        qp.drawPixmap(right - clip, bottom - clip, self.bottomRight)
        qp.drawPixmap(x - self._distance, bottom - clip, self.bottomLeft)

        qp.setWorldTransform(restoreTransform)


class InsideNeumorphismEffect(QtWidgets.QGraphicsEffect):
    _cornerShift = (QtCore.Qt.TopLeftCorner, QtCore.Qt.TopRightCorner,
                    QtCore.Qt.BottomRightCorner, QtCore.Qt.BottomLeftCorner)

    def __init__(self, distance: int = 4, lightColor: QtGui.QColor = QtGui.QColor("#FFFFFF"),
                 darkColor: QtGui.QColor = QtGui.QColor("#7d7d7d"), clipRadius: int = 4,
                 origin=QtCore.Qt.BottomRightCorner):
        super().__init__()

        self._leftGradient = QtGui.QLinearGradient(0, 0, 1, 0)
        self._leftGradient.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)
        self._topGradient = QtGui.QLinearGradient(0, 0, 0, 1)
        self._topGradient.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)

        self._rightGradient = QtGui.QLinearGradient(1, 0, 0, 0)
        self._rightGradient.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)
        self._bottomGradient = QtGui.QLinearGradient(0, 1, 0, 0)
        self._bottomGradient.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)

        self._radial = QtGui.QRadialGradient(.5, .5, .5)
        self._radial.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)
        self._conical = QtGui.QConicalGradient(.5, .5, 0)
        self._conical.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)

        self._origin = origin
        distance = max(0, distance)
        self._clipRadius = min(distance, max(0, clipRadius))
        self._setColors(lightColor, darkColor)
        self._setDistance(distance)

    def setColors(self, color1, color2):
        if isinstance(color1, QtCore.Qt.GlobalColor) and isinstance(color2, QtCore.Qt.GlobalColor):
            color1 = QtGui.QColor(color1)
            color2 = QtGui.QColor(color2)

            self._setColors(color1, color2)

            self._setDistance(self._distance)
            self.update()

    def _setColors(self, color1, color2):

        self._baseStart = color1
        self._baseStop = QtGui.QColor(color1)
        self._baseStop.setAlpha(0)
        self._shadowStart = color2
        self._shadowStop = QtGui.QColor(color2)
        self._shadowStop.setAlpha(0)

        self.lightSideStops = [(0, self._baseStart), (1, self._baseStop)]
        self.shadowSideStops = [(0, self._shadowStart), (1, self._shadowStop)]
        self.cornerStops = [(0, self._shadowStart), (.25, self._shadowStop),
                            (.75, self._shadowStop), (1, self._shadowStart)]

        self._setOrigin(self._origin)

    def distance(self):
        return self._distance

    def setDistance(self, distance):
        if distance == self._distance:
            return

        self._setDistance(distance)
        self.updateBoundingRect()

    def _getCornerPixmap(self, rect, grad1, grad2=None):
        pm = QtGui.QPixmap(self._distance + self._clipRadius, self._distance + self._clipRadius)
        pm.fill(QtCore.Qt.transparent)
        qp = QtGui.QPainter(pm)
        if self._clipRadius > 1:
            path = QtGui.QPainterPath()
            size = self._clipRadius * 2 - 1
            el = QtCore.QRectF(0, 0, size, size)
            path.addEllipse(rect)
            qp.setClipPath(path)
        qp.fillRect(rect, grad1)
        if grad2:
            qp.setCompositionMode(qp.CompositionMode_SourceAtop)
            qp.fillRect(rect, grad2)
        qp.end()
        return pm

    def _setDistance(self, distance):
        distance = max(1, distance)
        self._distance = distance
        if self._clipRadius > distance:
            self._clipRadius = distance
        distance += self._clipRadius
        r = QtCore.QRectF(0, 0, distance * 2, distance * 2)
        lightSideStops = self.lightSideStops[:]
        shadowSideStops = self.shadowSideStops[:]
        if self._clipRadius:
            gradStart = self._clipRadius / (self._distance + self._clipRadius)
            lightSideStops[0] = (1, lightSideStops[0][1])
            lightSideStops[1] = (gradStart, lightSideStops[1][1])
            shadowSideStops[1] = (gradStart, shadowSideStops[1][1])
            shadowSideStops[0] = (1, shadowSideStops[0][1])
        else:
            lightSideStops[0] = (1, lightSideStops[0][1])
            lightSideStops[1] = (0, lightSideStops[1][1])
            shadowSideStops[1] = (0, shadowSideStops[1][1])
            shadowSideStops[0] = (1, shadowSideStops[0][1])

        # create the 4 corners as if the light source was top-left
        self._radial.setStops(lightSideStops)
        topLeft = self._getCornerPixmap(r, self._radial)

        self._conical.setAngle(359.9)
        self._conical.setStops(self.cornerStops)
        topRight = self._getCornerPixmap(r.translated(-distance, 0), self._radial, self._conical)

        self._conical.setAngle(270)
        self._conical.setStops(self.cornerStops)
        bottomLeft = self._getCornerPixmap(r.translated(0, -distance), self._radial, self._conical)

        self._radial.setStops(shadowSideStops)
        bottomRight = self._getCornerPixmap(r.translated(-distance, -distance), self._radial)

        # rotate the images according to the actual light source
        images = topLeft, topRight, bottomRight, bottomLeft
        shift = self._cornerShift.index(self._origin)
        if shift:
            transform = QtGui.QTransform().rotate(shift * 90)
            for img in images:
                img.swap(img.transformed(transform, QtCore.Qt.SmoothTransformation))

        # and reorder them if required
        self.topLeft, self.topRight, self.bottomRight, self.bottomLeft = images[-shift:] + images[:-shift]

    def origin(self):
        return self._origin

    def setOrigin(self, origin):
        origin = QtCore.Qt.Corner(origin)
        if origin == self._origin:
            return
        self._setOrigin(origin)
        self._setDistance(self._distance)
        self.update()

    def _setOrigin(self, origin):
        self._origin = origin

        gradients = self._leftGradient, self._topGradient, self._rightGradient, self._bottomGradient
        stops = self.lightSideStops, self.lightSideStops, self.shadowSideStops, self.shadowSideStops

        # assign color stops to gradients based on the light source position
        shift = self._cornerShift.index(self._origin)
        for grad, stops in zip(gradients, stops[-shift:] + stops[:-shift]):
            grad.setStops(stops)

    def clipRadius(self):
        return self._clipRadius

    def setClipRadius(self, radius):
        if radius == self._clipRadius:
            return

        self._setClipRadius(radius)
        self.update()

    def _setClipRadius(self, radius):
        radius = min(self._distance, max(0, int(radius)))
        self._clipRadius = radius
        self._setDistance(self._distance)

    def boundingRectFor(self, rect):
        d = self._distance
        return rect.adjusted(-d, -d, d, d)

    def draw(self, qp):

        restoreTransform = qp.worldTransform()

        qp.setPen(QtCore.Qt.NoPen)
        x, y, width, height = self.sourceBoundingRect(QtCore.Qt.DeviceCoordinates).getRect()
        right = x + width
        bottom = y + height
        clip = self._clipRadius
        doubleClip = clip * 2

        if self._clipRadius:
            path = QtGui.QPainterPath()
            source = self.sourcePixmap(QtCore.Qt.DeviceCoordinates)
            sourceBoundingRect = self.sourceBoundingRect(QtCore.Qt.DeviceCoordinates)
            qp.save()
            qp.setTransform(QtGui.QTransform())
            path.addRoundedRect(sourceBoundingRect, self._clipRadius, self._clipRadius)
            qp.setClipPath(path)
            qp.drawPixmap(sourceBoundingRect.x() - self._distance, sourceBoundingRect.y() - self._distance, source)
            qp.restore()
        else:
            path = QtGui.QPainterPath()
            source = self.sourcePixmap(QtCore.Qt.DeviceCoordinates)
            sourceBoundingRect = self.sourceBoundingRect(QtCore.Qt.DeviceCoordinates)
            qp.save()
            qp.setTransform(QtGui.QTransform())
            path.addRect(sourceBoundingRect.x(), sourceBoundingRect.y(), sourceBoundingRect.width(),
                         sourceBoundingRect.height())
            qp.setClipPath(path)
            qp.drawPixmap(sourceBoundingRect.x() - self._distance, sourceBoundingRect.y() - self._distance, source)
            qp.restore()

        qp.setWorldTransform(QtGui.QTransform())
        leftRect = QtCore.QRectF(x, y + clip + self._distance, self._distance,
                                 height - doubleClip - self._distance * 2)
        qp.setBrush(self._leftGradient)
        qp.drawRect(leftRect)

        topRect = QtCore.QRectF(x + clip + self._distance, y, width - doubleClip - self._distance * 2,
                                self._distance)
        qp.setBrush(self._topGradient)
        qp.drawRect(topRect)

        rightRect = QtCore.QRectF(right - self._distance, y + clip + self._distance, self._distance,
                                  height - doubleClip - self._distance * 2)
        qp.setBrush(self._rightGradient)
        qp.drawRect(rightRect)

        bottomRect = QtCore.QRectF(x + clip + self._distance, bottom - self._distance,
                                   width - doubleClip - self._distance * 2, self._distance)
        qp.setBrush(self._bottomGradient)
        qp.drawRect(bottomRect)

        qp.drawPixmap(x, y, self.topLeft)
        qp.drawPixmap(right - clip - self._distance, y, self.topRight)
        qp.drawPixmap(right - clip - self._distance, bottom - clip - self._distance, self.bottomRight)
        qp.drawPixmap(x, bottom - clip - self._distance, self.bottomLeft)

        qp.setWorldTransform(restoreTransform)
  • Can you please try to clarify be what you actually changed (besides the obvious differences of PyQt/Side and version), so that people don't have to find that out on their own? Also, ensure that the code is properly shown and indented (read more about [formatting code](https://meta.stackoverflow.com/a/251362)). – musicamante Oct 18 '22 at 11:07
  • I have created a new effect for shadows. Now you can mix them and specify multiple shadows. They are also cut off if you specify a border-radius. https://github.com/GvozdevLeonid/BoxShadow-in-PyQt-PySide – leonid gvozdev Dec 28 '22 at 16:24