[plasma/plasma-mobile] components/mobileshell/qml: popups/notifications: Notification Popup Code Refactoring

Micah Stanley <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 53da8e8e03f67a89ca5bed8ba3efce6954772713 by Micah Stanley.
Committed on 19/07/2026 at 16:59.
Pushed by micahstanley into branch 'master'.

popups/notifications: Notification Popup Code Refactoring

Refactor the notification popup code around a `ListView` instead on manually managing the positions of the popups ourselves, making the codebase for it significantly smaller and easier to maintain.

M  +228  -473  components/mobileshell/qml/popups/notifications/NotificationPopup.qml
M  +226  -185  components/mobileshell/qml/popups/notifications/NotificationPopupManager.qml
M  +4    -6    components/mobileshell/qml/popups/notifications/NotificationPopupProvider.qml
M  +12   -3    components/mobileshell/qml/widgets/notifications/NotificationCard.qml
M  +4    -0    components/mobileshell/qml/widgets/notifications/NotificationItem.qml
M  +4    -0    components/mobileshell/qml/widgets/notifications/NotificationPopupItem.qml

https://invent.kde.org/plasma/plasma-mobile/-/commit/53da8e8e03f67a89ca5bed8ba3efce6954772713

diff --git a/components/mobileshell/qml/popups/notifications/NotificationPopup.qml b/components/mobileshell/qml/popups/notifications/NotificationPopup.qml
index 6b8e28a65..0a2e1d075 100644
--- a/components/mobileshell/qml/popups/notifications/NotificationPopup.qml
+++ b/components/mobileshell/qml/popups/notifications/NotificationPopup.qml
@@ -1,7 +1,7 @@
 /*
- *  SPDX-FileCopyrightText: 2024-2025 Micah Stanley <[email protected]>
- *
- *  SPDX-License-Identifier: GPL-2.0-or-later
+    SPDX-FileCopyrightText: 2024-2025 Micah Stanley <[email protected]>
+
+    SPDX-License-Identifier: GPL-2.0-or-later
  */
 
 import QtQuick
@@ -19,290 +19,224 @@ import org.kde.notificationmanager as NotificationManager
 import org.kde.plasma.clock
 
 Item {
-    id: notificationPopup
-
-    readonly property int popupHeight: notificationItem.implicitHeight
-    readonly property bool isClosing: notificationItem.state == "closeWithMove" || notificationItem.state == "closeWithScale"
-    readonly property real closedOffset: -(popupHeight + Kirigami.Units.smallSpacing)
-    // 'popupWidth' and 'openOffset' is set by the 'notificationPopupManager'
-    property int popupWidth
-    property real openOffset
-
-    // calculate the position needed to at when the expanded drawer is active
-    readonly property real fullOpenOffset: popupDrawerOpened ? aboveNotificationFullOffset + aboveNotificationHeight + Kirigami.Units.largeSpacing : 0
-    property real aboveNotificationFullOffset: 0
-    property int aboveNotificationHeight: 0
-
-    // set to true when notification is swiped up by user
-    property bool closedWithSwipe: false
-
-    // the drag offset on the current popup notification - used to position notification when stacked underneath
-    property real currentDragOffset: {
-        let current = popupNotifications.currentPopupIndex == notificationPopup.popupIndex;
-        return current || popupDrawerOpened ? 0 : Math.max(popupNotifications.currentDragOffset, 0)
-    }
-
-    // due to it not looking great to have a notification sliding up while another one is sliding down
-    // we use a timer so that the current notification can know to use "closeWithScale" instead
-    property Timer queueTimer: Timer {
-        interval: Kirigami.Units.veryLongDuration
-        running: true
-        onTriggered: {
-            visible = true;
-            updateNotificationPopups();
-        }
-    }
+    id: popupDelegate
 
-    // The timer for when the notification will dismiss
-    Timer {
-        id: hideTimer
-        interval: notificationPopup.effectiveTimeout
-        running: {
-            if (interval <= 0) {
-                return false;
-            }
-            if (notificationPopup.preventDismissTimeout) {
-                return false;
-            }
-            if (notificationPopup.inPopupDrawer) {
-                return false;
-            }
-            if (notificationPopup.popupDrawerOpened) {
-                return false;
-            }
-            return true;
-        }
-        onTriggered: notificationPopup.closePopup(popupIndex);
-    }
+    height: (notificationItem.cardHeight + Kirigami.Units.largeSpacing) * heightAnimationValue
+    opacity: Math.max(1 * popupDelegate.popupDrawerAnimationValue, popupDelegate.opacityAnimationValue)
 
-    // the value of how much time is left, normalized from 1 to 0
-    property real remainingTimeProgress: 1
-    NumberAnimation on remainingTimeProgress {
-        from: 1
-        to: 0
-        duration: hideTimer.interval
-        running: hideTimer.running
-    }
+    required property var model
+    required property int index
 
-    // set the height and width of the notification container with a extra space for starting a drag
-    width: popupWidth + Kirigami.Units.gridUnit
-    height: popupHeight + Kirigami.Units.gridUnit
+    property var popupManager
+    property var keyboardInteractivity
+    property bool popupDrawerOpened: false
+    property var notificationsModel
+    property int notificationsModelType
+    property var timeDataSource
+    property int notificationCount
+    property real topPopupOffset
+    property real popupDrawerAnimationValue: 0
+
+    property bool isCurrentPopup: index < 1
+    property real currentPopupHeight
+    property real currentPopupDragOffset // the drag offset on the current popup notification - used to position notification when stacked underneath
+    property real offsetFromCurrentPopup
+
+    property real heightAnimationValue: 1
+    property real opacityAnimationValue: index < 3 ? 1 : 0
+    property real verticalOffset: 0
+    property real dragOffset: 0
+    property bool closedWithSwipe: false // set to true when notification is swiped up by user
+    property string removalType: "slide"
 
-    visible: false
+    property bool preventDismissTimeout: false
+    property int timeout
+    property int dismissTimeout
+    property int defaultTimeout: 5000
+    property bool isExpired: model.expired
+    property real remainingTimeProgress: 1 // the value of how much time is left, normalized from 1 to 0
 
-    Kirigami.Theme.colorSet: Kirigami.Theme.View
-    Kirigami.Theme.inherit: false
+    readonly property real cardHeight: notificationItem.cardHeight
+    readonly property real closedOffset: -(notificationItem.height + Kirigami.Units.smallSpacing)
+    readonly property int effectiveTimeout: {
+        if (timeout === -1) return defaultTimeout;
+        if (dismissTimeout) return dismissTimeout;
+        return model.timeout;
+    }
 
-    signal expired()
-    signal dismissClicked()
     signal updateTouchArea()
-    signal setInputTransparent()
     signal openPopupDrawer()
     signal setKeyboardFocus()
     signal removeKeyboardFocus()
+    signal dismissClicked()
+    signal expired()
 
-    // animate the notifications entering and exiting the expanded drawer
-    property real fullOffsetAn: fullOpenOffset
-    Behavior on fullOffsetAn {
-        NumberAnimation {
-            duration: Kirigami.Units.veryLongDuration * 1.5
-            easing.type: Easing.OutExpo
+    onHeightChanged: {
+        if (isCurrentPopup) {
+            popupDelegate.updateTouchArea();
         }
     }
 
-    // when a notification is grouped behind the current Notification
-    // we need the y origin to be a the bottom
-    // however we need it at the center when "closeWithScale" is used
-    // animate this value so that the popup in some situations will not jump around
-    property real scaleOriginY: inPopupDrawer && !popupDrawerOpened ? popupNotifications.currentPopupHeight : Math.round(popupHeight / 2)
-    Behavior on scaleOriginY {
-        NumberAnimation {
-            duration: Kirigami.Units.veryLongDuration
-            easing.type: Easing.OutExpo
+    onIsCurrentPopupChanged: {
+        if (isCurrentPopup) {
+            popupDelegate.updateTouchArea();
         }
     }
 
-    // the vertical drag offset for the notification popup
-    // we drag is released, animate back to 0
-    property real dragOffset: 0
-    NumberAnimation on dragOffset {
-        id: dragOffsetAn
-        running: false
-        to: 0
-        duration: Kirigami.Units.veryLongDuration * 1.5
-        easing.type: Easing.OutExpo
-    }
-
-    // if the popup height ever changes, update the notification below with new height
-    // also update the allowed touch area for the main window
-    onPopupHeightChanged: {
-        let abovePopup = popupNotifications.objectAt(popupIndex + 1)
-        if (popupIndex + 1 < popupCount && abovePopup) {
-            abovePopup.aboveNotificationHeight = popupHeight;
-        }
-        if (popupNotifications.currentPopupIndex == notificationPopup.popupIndex && notificationItem.state == "open") {
-            notificationPopup.updateTouchArea();
+    onIsExpiredChanged: {
+        if (isExpired) {
+            popupDelegate.closePopup();
         }
     }
 
-    // if the offset position need in the expanded drawer changes, update the notification below with new offset
-    onFullOpenOffsetChanged: {
-        let abovePopup = popupNotifications.objectAt(popupIndex + 1)
-        if (popupIndex + 1 < popupCount && abovePopup) {
-            abovePopup.aboveNotificationFullOffset = fullOpenOffset;
-        }
-    }
-    // if the notification is being dragged and is the current one
-    // update 'currentDragOffset' so all notifications can easily access this value
-    onDragOffsetChanged: {
-        let abovePopup = popupNotifications.objectAt(popupIndex + 1)
-        if (popupNotifications.currentPopupIndex == notificationPopup.popupIndex) {
-            popupNotifications.currentDragOffset = dragOffset;
+    // attached property action animations
+    ListView.onAdd: {
+        if (popupDrawerOpened) {
+            notificationItem.opacity = 0;
+            fadeInAnimation.restart();
+        } else {
+            popupDelegate.verticalOffset = -popupDelegate.height - popupDelegate.topPopupOffset;
+            slideInAnimation.restart();
         }
     }
-    // if a new notification is added, update the above notification values need for the expanded drawer
-    onPopupCountChanged: {
-        let abovePopup = popupNotifications.objectAt(popupIndex + 1)
-        if (popupIndex + 1 < popupCount && abovePopup) {
-            abovePopup.aboveNotificationHeight = popupHeight;
-            abovePopup.aboveNotificationFullOffset = fullOpenOffset;
+
+    ListView.onRemove: {
+        fadeInAnimation.stop();
+        slideInAnimation.stop();
+        notificationItem.opacity = 1;
+        verticalOffset = 0;
+        ListView.delayRemove = true;
+        popupManager.activeRemovalAnimations += 1;
+
+        if (popupDrawerOpened || !isCurrentPopup) {
+            fadeOutAnimation.restart();
+        } else {
+            slideOutAnimation.restart();
         }
     }
-    // update the current popup index value if the index ever changes.
-    onPopupIndexChanged: {
-        if (!isClosing && !inPopupDrawer && !waiting) {
-            // if index goes below zero, assume it is being closed externally and move over to the next popup
-            if (popupIndex < 0 ) {
-                closePopup(0);
-                return;
-            }
-            popupNotifications.currentPopupIndex = popupIndex;
+
+    // state transition animations / timers
+    Behavior on opacityAnimationValue {
+        NumberAnimation {
+            duration: Kirigami.Units.veryLongDuration * 1.5
+            easing.type: Easing.OutQuint
         }
     }
-    property bool isActionDrawerOpen: MobileShellState.ShellDBusClient.isActionDrawerOpen
-
-    property bool waiting: true
-    property bool popupDrawerOpened: false
-    property bool inPopupDrawer: false
-
-    property var keyboardInteractivity
-    property Instantiator popupNotifications
-    property int popupCount: popupNotifications.count
-    property int popupIndex
 
-    property var popupModel
-    property var notificationsModel
-    property int notificationsModelType
-    property var timeDataSource
+    NumberAnimation on dragOffset {
+        id: dragOffsetAnimation
+        running: false
+        to: 0
+        duration: Kirigami.Units.veryLongDuration * 1.5
+        easing.type: Easing.OutExpo
+    }
 
-    property bool preventDismissTimeout: true
-    property int timeout
-    property int dismissTimeout
+    NumberAnimation on remainingTimeProgress {
+        from: 1
+        to: 0
+        duration: hideTimer.interval
+        running: hideTimer.running
+    }
 
-    property int defaultTimeout: 5000
-    readonly property int effectiveTimeout: {
-        if (timeout === -1) {
-            return defaultTimeout;
-        }
-        if (dismissTimeout) {
-            return dismissTimeout;
+    ParallelAnimation {
+        id: slideInAnimation
+        NumberAnimation {
+            target: popupDelegate
+            property: "verticalOffset"
+            to: 0
+            duration: Kirigami.Units.veryLongDuration * 1.25
+            easing.type: Easing.OutQuint
         }
-        return model.timeout;
     }
 
-    // show the top most notification in the list and move the rest to the popup drawer
-    function updateNotificationPopups() {
-        if (popupCount != 1) {
-            for (var i = 0; i < popupCount - 1; i++)  {
-                popupNotifications.objectAt(i + 1).moveToPopupDrawer();
+    ParallelAnimation {
+        id: slideOutAnimation
+        NumberAnimation {
+            target: popupDelegate
+            property: "verticalOffset"
+            to: {
+                if (popupDelegate.closedWithSwipe) {
+                    return -popupDelegate.height - popupDelegate.topPopupOffset - popupDelegate.dragOffset;
+                } else {
+                    return -popupDelegate.height - popupDelegate.topPopupOffset;
+                }
             }
+            duration: (popupDelegate.closedWithSwipe || popupDelegate.notificationCount > 0) ? Kirigami.Units.veryLongDuration * 0.5 : Kirigami.Units.veryLongDuration * 1.25
+            easing.type: (popupDelegate.closedWithSwipe || popupDelegate.notificationCount > 0) ? Easing.Linear : Easing.InQuint
+        }
+        onStopped: {
+            popupDelegate.ListView.delayRemove = false;
+            popupManager.activeRemovalAnimations -= 1;
         }
-        popupNotifications.objectAt(0).showNotificationPopup();
-        visible = true;
     }
 
-    function showNotificationPopup() {
-        if (isClosing) {
-            closePopup(popupIndex);
-            return;
+    ParallelAnimation {
+        id: fadeOutAnimation
+        NumberAnimation {
+            target: notificationItem
+            property: "opacity"
+            to: 0
+            duration: Kirigami.Units.veryLongDuration * 1.25
+            easing.type: Easing.OutQuint
         }
-        if (notificationItem.state != "open") {
-            preventDismissTimeout = true;
+        NumberAnimation {
+            target: popupDelegate
+            property: "heightAnimationValue"
+            to: 0
+            duration: Kirigami.Units.veryLongDuration * 1.25
+            easing.type: Easing.OutQuint
+        }
+        onStopped: {
+            popupDelegate.ListView.delayRemove = false;
+            popupManager.activeRemovalAnimations -= 1;
         }
-        waiting = false;
-        inPopupDrawer = false;
-        popupNotifications.currentPopupIndex = popupIndex;
-        visible = true;
-        openPopup();
-        updateTouchArea();
     }
 
-    function moveToPopupDrawer() {
-        if (isClosing) {
-            return;
+    ParallelAnimation {
+        id: fadeInAnimation
+        NumberAnimation {
+            target: notificationItem
+            property: "opacity"
+            to: 1
+            duration: Kirigami.Units.veryLongDuration * 1.25
+            easing.type: Easing.InQuint
         }
-        waiting = false;
-        inPopupDrawer = true;
-        if (notificationPopup.popupDrawerOpened && notificationItem.state != "inDrawerClosed" && notificationItem.state != "open") {
-            notificationItem.offset = openOffset;
-            notificationItem.scale = 0.75;
-            notificationItem.popupOpacity = 0.0;
+        NumberAnimation {
+            target: popupDelegate
+            property: "heightAnimationValue"
+            from: 0
+            to: 1
+            duration: Kirigami.Units.veryLongDuration * 1.25
+            easing.type: Easing.OutQuint
         }
-        notificationItem.state = "inDrawerClosed";
-        notificationPopup.removeKeyboardFocus();
-        visible = true;
     }
 
-    function openPopup() {
-        if (notificationPopup.popupDrawerOpened && notificationItem.state != "open" && notificationItem.state != "inDrawerClosed") {
-            notificationItem.offset = openOffset;
-            notificationItem.scale = 0.75;
-            notificationItem.popupOpacity = 0.0;
+    // the timer for when the notification will dismiss
+    Timer {
+        id: hideTimer
+        interval: popupDelegate.effectiveTimeout
+        running: {
+            if (interval <= 0) return false;
+            if (popupDelegate.preventDismissTimeout) return false;
+            if (!popupDelegate.isCurrentPopup) return false;
+            if (popupDelegate.popupDrawerOpened) return false;
+            return true;
         }
-        notificationItem.state = "open";
-        notificationPopup.removeKeyboardFocus();
+        onTriggered: popupDelegate.closePopup()
     }
 
-    // if the notification ever expires, close it and move on to the next one in the list.
-    property bool isExpired: model.expired
-    onIsExpiredChanged: closePopup(popupIndex)
-
-    // this closes the popup notification with the relvent animation while updating the popup below to show, if any exist
-    function closePopup(index: int) {
-        notificationPopup.removeKeyboardFocus();
-        notificationPopup.setInputTransparent();
-        if (index + 1 < popupCount) {
-            popupNotifications.objectAt(index + 1).aboveNotificationHeight = 0;
-            popupNotifications.objectAt(index + 1).aboveNotificationFullOffset = 0;
-        }
-
-        if (popupCount > 1) {
-            let nextNotificationIdx = index + (index < popupCount - 1 ? 1 : -1);
-            let nextNotification = popupNotifications.objectAt(nextNotificationIdx);
-
-            if (nextNotification != null) {
-                nextNotification.showNotificationPopup();
-                if (!isExpired) {
-                    if (!dragOffsetAn.running && nextNotification.queueTimer.running) {
-                        nextNotification.queueTimer.stop();
-                        notificationItem.state = "closeWithScale";
-                    } else {
-                        notificationItem.state = "closeWithMove";
-                    }
-                    return;
-                }
-            }
-        }
+    // helper functions
+    function closePopup() {
         if (isExpired) {
             notificationItem.close();
             return;
         }
-        notificationItem.state = "closeWithMove";
+        dismissClicked();
     }
 
-    function calculateResistance(value : double, threshold : int) : double {
+    function calculateResistance(value: real, threshold: int): real {
         if (value > threshold) {
-            return threshold + Math.pow(value - threshold + 1, Math.max(0.8 - (value - threshold) / ((longestLength - threshold) * 15), 0.35));
+            return threshold + Math.pow(value - threshold + 1, Math.max(0.8 - (value - threshold) / ((Screen.height - threshold) * 15), 0.35));
         } else {
             return value;
         }
@@ -311,290 +245,111 @@ Item {
     NotificationPopupItem {
         id: notificationItem
 
-        inPopupDrawer: notificationPopup.inPopupDrawer && !notificationPopup.popupDrawerOpened
-
-        anchors.horizontalCenter: parent.horizontalCenter
-        anchors.top: parent.top
-
-        width: notificationPopup.popupWidth
-        height: notificationPopup.popupHeight
-
-        model: notificationPopup.popupModel
-        modelIndex: notificationPopup.popupIndex
-        notificationsModel: notificationPopup.notificationsModel
-        notificationsModelType: notificationPopup.notificationsModelType
-        clockSource: notificationPopup.timeDataSource
-        panelType:  notificationPopup.popupDrawerOpened ?
-                    MobileShell.PanelBackground.PanelType.Drawer :
-                    MobileShell.PanelBackground.PanelType.Popup
-
-        currentPopupHeight: popupNotifications.currentPopupHeight
+        anchors.left: parent.left
+        anchors.right: parent.right
 
-        remainingTimeProgress: notificationPopup.remainingTimeProgress
+        inPopupDrawer: !popupDelegate.isCurrentPopup && !popupDelegate.popupDrawerOpened
+        model: popupDelegate.model
+        modelIndex: popupDelegate.index
+        notificationsModel: popupDelegate.notificationsModel
+        notificationsModelType: popupDelegate.notificationsModelType
+        clockSource: popupDelegate.timeDataSource
+        panelType: popupDelegate.popupDrawerOpened ? MobileShell.PanelBackground.PanelType.Drawer : MobileShell.PanelBackground.PanelType.Popup
+        animateHeight: popupDelegate.isCurrentPopup
+        currentPopupHeight: popupDelegate.currentPopupHeight
+        remainingTimeProgress: popupDelegate.remainingTimeProgress
         closeTimerRunning: hideTimer.running
 
-        onDragStart: preventDismissTimeout = true
-        onDragEnd: preventDismissTimeout = (keyboardInteractivity == LayerShell.Window.KeyboardInteractivityOnDemand)
+        property real popupScale: (popupDelegate.index > 0) ? (1 - Math.min(popupDelegate.index, 3) * 0.075) : 1
 
-        onTakeFocus: {
-            notificationPopup.setKeyboardFocus();
-            preventDismissTimeout = true;
-        }
-
-        onDismissRequested: closePopup(popupIndex)
-
-        property real offset: closedOffset
-        property real scale: 1.0
-        property real popupOpacity: 1.0 // controls the opacity of the notification popup when outside the popup drawer
-        property real drawerScale: {
-            if (notificationPopup.popupDrawerOpened) {
-                return 0; // when popup drawer is opened, reset scale to 0
-            }
-            let index = notificationPopup.popupIndex - popupNotifications.currentPopupIndex;
-            // clamp the index value to avoid scaling too much with animations
-            let indexClamped = Math.max(Math.min(index, 2), 0);
-            return indexClamped * 0.075;
-        }
-        property real drawerAddedOffset: {
-            if (notificationPopup.popupDrawerOpened) {
-                return 0; // when popup drawer is opened, reset any added height to 0
-            }
-            let index = notificationPopup.popupIndex - popupNotifications.currentPopupIndex;
-            // clamp the index value to avoid moving too much with animations
-            let indexClamped = Math.max(Math.min(index, 2), -1);
-            return Kirigami.Units.gridUnit * 0.5 * indexClamped;
-        }
-        property real drawerOpacity: {
-            let index = notificationPopup.popupIndex - popupNotifications.currentPopupIndex;
-            if (index > 2 && !notificationPopup.popupDrawerOpened) {
-                return 0; // make this popup invisible if it is below 3 other popups
-            } else {
-                return 1; // when popup drawer is opened, reset opacity to 1
-            }
-        }
-        Behavior on drawerScale {
+        Behavior on popupScale {
             NumberAnimation {
-                duration: Kirigami.Units.veryLongDuration * 1.25
+                duration: Kirigami.Units.veryLongDuration * 1.5
                 easing.type: Easing.OutQuint
             }
         }
-        Behavior on drawerAddedOffset {
-            NumberAnimation {
-                duration: Kirigami.Units.veryLongDuration * 1.25
-                easing.type: Easing.OutQuint
-            }
-        }
-        Behavior on drawerOpacity {
-            NumberAnimation {
-                duration: Kirigami.Units.veryLongDuration * 1.25
-                easing.type: Easing.OutQuint
-            }
-        }
-
-        opacity: Math.min(popupOpacity, drawerOpacity)
-
-        state: ""
 
-        states: [
-            State {
-                name: "open"
-                PropertyChanges {
-                    target: notificationItem; offset: notificationPopup.openOffset
-                }
-                PropertyChanges {
-                    target: notificationItem; scale: 1.0
-                }
-                PropertyChanges {
-                    target: notificationItem; popupOpacity: 1.0
-                }
-            },
-            State {
-                name: "closeWithMove"
-                PropertyChanges {
-                    target: notificationItem; offset: notificationPopup.closedOffset
-                }
-                PropertyChanges {
-                    target: notificationItem; scale: 1.0
-                }
-                PropertyChanges {
-                    target: notificationItem; popupOpacity: 1.0
-                }
-            },
-            State {
-                name: "closeWithScale"
-                PropertyChanges {
-                    target: notificationItem; offset: notificationPopup.openOffset
-                }
-                PropertyChanges {
-                    target: notificationItem; scale: 0.75
-                }
-                PropertyChanges {
-                    target: notificationItem; popupOpacity: 0.0
-                }
+        transform: [
+            Scale {
+                origin.x: Math.round(notificationItem.width * 0.5)
+                origin.y: popupDelegate.currentPopupHeight + Kirigami.Units.gridUnit * 5
+                xScale: (notificationItem.popupScale * (1 - popupDelegate.popupDrawerAnimationValue)) + popupDelegate.popupDrawerAnimationValue
+                yScale: (notificationItem.popupScale * (1 - popupDelegate.popupDrawerAnimationValue)) + popupDelegate.popupDrawerAnimationValue
             },
-            State {
-                name: "inDrawerClosed"
-                PropertyChanges {
-                    target: notificationItem; offset: notificationPopup.openOffset
-                }
-                PropertyChanges {
-                    target: notificationItem; scale: 1
-                }
-                PropertyChanges {
-                    target: notificationItem; popupOpacity: 1
-                }
+            Translate {
+                y: (popupDelegate.offsetFromCurrentPopup * (1 - popupDelegate.popupDrawerAnimationValue)) + popupDelegate.verticalOffset + popupDelegate.dragOffset + Math.max(currentPopupDragOffset, 0)
             }
         ]
 
-        readonly property int notificationEasing: {
-            // check whether the popup is the current one or above it
-            let topPopup = popupNotifications.currentPopupIndex >= notificationPopup.popupIndex;
-            // check whether the popup has any popups below it
-            let popupBelow = notificationPopup.popupCount - notificationPopup.popupIndex > 1;
-            let popupOpening = notificationItem.state == "open" || notificationItem.state == "inDrawerClosed";
-            let popupClosing = notificationItem.state == "closeWithMove" || notificationItem.state == "closeWithScale"
-            if (notificationPopup.closedWithSwipe || (topPopup && popupClosing && popupBelow)) {
-                // set the easing type to linear when closed with a swipe or if a popup is below when closing
-                // as to make sure the popup feels like it is keeping it's momentum
-                return Easing.Linear;
-            } else if (popupOpening) {
-                // set the easing type to 'Out' when opening so the popup will have a gentle landing
-                return Easing.OutQuint;
-            } else {
-                // if above conditions fail, set the easing type to 'In' so the popup will build up speed for it's exit
-                return Easing.InQuint;
-            }
-        }
+        onDragStart: preventDismissTimeout = true
+        onDragEnd: preventDismissTimeout = (keyboardInteractivity == LayerShell.Window.KeyboardInteractivityOnDemand)
 
-        readonly property real notificationDuration: {
-            // check whether the popup is the current one or above it
-            let topPopup = popupNotifications.currentPopupIndex >= notificationPopup.popupIndex;
-            // check whether the popup has any popups below it
-            let popupBelow = notificationPopup.popupCount - notificationPopup.popupIndex > 1;
-            let popupClosing = notificationItem.state == "closeWithMove" || notificationItem.state == "closeWithScale"
-            if (notificationPopup.closedWithSwipe || (topPopup && popupClosing && popupBelow)) {
-                // make sure the speed it faster when closed with a swipe or if there is a popup below when closing
-                // as to make sure the speed feels comparable with the easing type is set to linear
-                return Kirigami.Units.veryLongDuration * 0.5;
-            } else {
-                return Kirigami.Units.veryLongDuration * 1.25;
-            }
+        onTakeFocus: {
+            popupDelegate.setKeyboardFocus();
+            preventDismissTimeout = true;
         }
 
-        transitions: Transition {
-            SequentialAnimation {
-                ParallelAnimation {
-                    PropertyAnimation {
-                        properties: "offset"
-                        easing.type: notificationItem.notificationEasing
-                        duration: notificationItem.notificationDuration
-                    }
-                    PropertyAnimation {
-                        properties: "scale"
-                        easing.type: notificationItem.notificationEasing
-                        duration: notificationItem.notificationDuration
-                    }
-                    PropertyAnimation {
-                        properties: "popupOpacity"
-                        easing.type: notificationItem.notificationEasing
-                        duration: notificationItem.notificationDuration
-                    }
-                }
-                ScriptAction {
-                    script: {
-                        if (notificationItem.state == "open") {
-                            notificationPopup.preventDismissTimeout = false;
-                            notificationPopup.updateTouchArea();
-                        } else if (notificationItem.state == "closeWithMove" || notificationItem.state == "closeWithScale") {
-                            notificationPopup.preventDismissTimeout = true;
-                            if (notificationPopup.dismissTimeout) {
-                                notificationPopup.dismissClicked();
-                            } else {
-                                notificationPopup.expired();
-                            }
-                        }
-                    }
-                }
-            }
-        }
+        onDismissRequested: notificationItem.close()
 
-        transform: [
-            Scale {
-                origin.x: Math.round(notificationPopup.popupWidth / 2)
-                origin.y: notificationPopup.scaleOriginY
-                xScale: notificationItem.scale - notificationItem.drawerScale
-                yScale: notificationItem.scale - notificationItem.drawerScale
+        // capture taps were the notifications are grouping together to open the popup notification drawer
+        MouseArea {
+            id: drawerInteractionArea
+            anchors.left: notificationItem.left
+            anchors.right: notificationItem.right
+            anchors.top: notificationItem.bottom
+            height: Kirigami.Units.gridUnit * 1.5
+
+            enabled: !popupDelegate.popupDrawerOpened && popupDelegate.notificationCount > 1 && popupDelegate.isCurrentPopup
+
+            onReleased: {
+                popupDelegate.openPopupDrawer();
+                popupDelegate.updateTouchArea();
+                popupDelegate.setKeyboardFocus();
             }
-        ]
-    }
-
-    transform: [
-        Translate {
-            y: notificationItem.offset + notificationPopup.fullOffsetAn + notificationPopup.dragOffset + notificationPopup.currentDragOffset + notificationItem.drawerAddedOffset
         }
-    ]
+    }
 
     DragHandler {
         id: dragHandler
         xAxis.enabled: false
-        yAxis.enabled: popupNotifications.currentPopupIndex == notificationPopup.popupIndex && !notificationPopup.popupDrawerOpened
+        yAxis.enabled: popupDelegate.index == 0 && !popupDelegate.popupDrawerOpened
         target: null
 
         property real lastOffset: 0
-
         property real startDragOffset: 0
         property real startPosition: 0
-        property bool startActive: false
+        property bool isStartActive: false
 
         onTranslationChanged: {
-            if (notificationItem.state == "closeWithScale" || notificationItem.state == "closeWithMove") {
-                return;
-            }
-            if (startActive) {
+            if (popupDelegate.index < 0) return;
+            if (isStartActive) {
                 startDragOffset = notificationPopup.dragOffset;
                 startPosition = translation.y;
-                startActive = false;
+                isStartActive = false;
             }
             lastOffset = notificationPopup.dragOffset;
-            notificationPopup.dragOffset = calculateResistance(startDragOffset + (translation.y - startPosition), 0);
+            popupDelegate.dragOffset = calculateResistance(startDragOffset + (translation.y - startPosition), 0);
         }
 
         onActiveChanged: {
-            startActive = active;
-            notificationPopup.preventDismissTimeout = true;
-            if (!active && !(notificationItem.state == "closeWithScale" || notificationItem.state == "closeWithMove")) {
-                if ((lastOffset - notificationPopup.dragOffset > 1.0 && notificationPopup.dragOffset < 0) || (-(notificationPopup.openOffset - notificationPopup.closedOffset) / 4 > notificationPopup.dragOffset)) {
+            isStartActive = active;
+            popupDelegate.preventDismissTimeout = true;
+            if (!active && !(slideOutAnimation.running || fadeOutAnimation.running)) {
+                if ((lastOffset - popupDelegate.dragOffset > 1.0 && popupDelegate.dragOffset < 0) || (popupDelegate.closedOffset * 0.5 > popupDelegate.dragOffset)) {
                     // this code is called when the notification is swiped or dragged to the top.
-                    notificationPopup.closedWithSwipe = true;
-                    notificationPopup.closePopup(popupIndex);
+                    popupDelegate.closedWithSwipe = true;
+                    popupDelegate.closePopup();
                     return;
                 }
-                dragOffsetAn.running = true;
-                if (notificationPopup.dragOffset - lastOffset > 1.0 || Kirigami.Units.gridUnit * 3 < notificationPopup.dragOffset) {
+                dragOffsetAnimation.running = true;
+                if (popupDelegate.dragOffset - lastOffset > 1.0 || Kirigami.Units.gridUnit * 3 < popupDelegate.dragOffset) {
                     // this code is called when the notification is swiped or dragged down.
                 }
-                notificationPopup.preventDismissTimeout = (keyboardInteractivity == LayerShell.Window.KeyboardInteractivityOnDemand);
+                popupDelegate.preventDismissTimeout = (keyboardInteractivity == LayerShell.Window.KeyboardInteractivityOnDemand);
             } else {
-                dragOffsetAn.running = false;
+                dragOffsetAnimation.running = false;
             }
         }
     }
-
-    MouseArea {
-        // capture taps were the notifications are grouping together to open the popup notification drawer
-        id: item
-        anchors.left: parent.left
-        anchors.right: parent.right
-        anchors.top: notificationItem.bottom
-
-        height: Kirigami.Units.gridUnit * 2
-
-        enabled: !notificationPopup.popupDrawerOpened && (notificationPopup.popupCount - popupNotifications.currentPopupIndex > 1)
-
-        onReleased: {
-            notificationPopup.openPopupDrawer();
-            notificationPopup.updateTouchArea();
-            notificationPopup.setKeyboardFocus();
-        }
-    }
 }
+
diff --git a/components/mobileshell/qml/popups/notifications/NotificationPopupManager.qml b/components/mobileshell/qml/popups/notifications/NotificationPopupManager.qml
index f0fc8ca15..0e8f68efc 100644
--- a/components/mobileshell/qml/popups/notifications/NotificationPopupManager.qml
+++ b/components/mobileshell/qml/popups/notifications/NotificationPopupManager.qml
@@ -1,26 +1,20 @@
 /*
- *  SPDX-FileCopyrightText: 2024 Micah Stanley <[email protected]>
- *
- *  SPDX-License-Identifier: GPL-2.0-or-later
+    SPDX-FileCopyrightText: 2024 Micah Stanley <[email protected]>
+
+    SPDX-License-Identifier: GPL-2.0-or-later
  */
 
-import QtQuick 2.15
+import QtQuick
 import QtQuick.Layouts
 import QtQuick.Window
 
 import org.kde.kirigami as Kirigami
-import org.kde.plasma.private.mobileshell as MobileShell
-import org.kde.plasma.private.mobileshell.state as MobileShellState
-
-import org.kde.layershell 1.0 as LayerShell
 
+import org.kde.plasma.private.mobileshell.state as MobileShellState
 import org.kde.notificationmanager as NotificationManager
 import org.kde.plasma.clock
 
-import QtQuick.Controls as Controls
-import org.kde.plasma.components 3.0 as PlasmaComponents
-import org.kde.taskmanager 0.1 as TaskManager
-
+import org.kde.layershell 1.0 as LayerShell
 
 /**
  * This sets up and manages the notification popups
@@ -28,240 +22,287 @@ import org.kde.taskmanager 0.1 as TaskManager
 Window {
     id: notificationPopupManager
 
-    readonly property int popupWidth: Math.min(Kirigami.Units.gridUnit * 20, Screen.width - Kirigami.Units.gridUnit * 2)
-    readonly property real openOffset: Kirigami.Units.gridUnit + Kirigami.Units.smallSpacing * 3
-    readonly property int longestLength: Math.max(Screen.width, Screen.height)
-    property var keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
+    width: Screen.width
+    height: Screen.height
+    visible: false
+    color: isPopupDrawerOpen && visible ? backgroundColor : "transparent"
 
-    LayerShell.Window.scope: "notification"
     LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorHorizontalCenter
     LayerShell.Window.layer: LayerShell.Window.LayerOverlay
     LayerShell.Window.exclusionZone: -1
     LayerShell.Window.keyboardInteractivity: keyboardInteractivity
 
-    // This toggles whether to show all the active popup notifications at ones in a list
-    property bool popupDrawerOpened: false
+    Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
+    Kirigami.Theme.inherit: false
 
     property var notificationModelType
+    property var popupNotificationsModel
     property QtObject notificationSettings
-    property QtObject popupNotificationsModel
     property QtObject tasksModel
     property Clock clockSource
     property bool inhibited
 
-    Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
-    Kirigami.Theme.inherit: false
+    property int lastNotificationCount: 0
+    property int activeRemovalAnimations: 0
 
-    readonly property color backgroundColor: Qt.darker(Qt.rgba(Kirigami.Theme.backgroundColor.r, Kirigami.Theme.backgroundColor.g, Kirigami.Theme.backgroundColor.b, 0.95), 1.05)
-    color: popupDrawerOpened && visible ? backgroundColor : "transparent"
-    Behavior on color {
-        ColorAnimation {
-            duration: Kirigami.Units.veryLongDuration * 1.5
-            easing.type: Easing.OutExpo
+    // when the notification model reaches zero we wait for all the removal animations to complete first before hiding the window
+    onActiveRemovalAnimationsChanged: {
+        if (activeRemovalAnimations === 0 && lastNotificationCount === 0) {
+            notificationPopupManager.visible = false;
         }
     }
 
-    width: longestLength
-    height: longestLength
+    // due to the count property on the ListView not updating when the window is not visible,
+    // we need to get the count vaule from the model itself so we can update the window visibilty state
+    // NOTE: we call for the row count value directly as binding to the normal count vaule from the model seems to lags behide a number
+    Connections {
+        target: popupNotificationsModel
 
-    signal timeChanged
+        function onRowsInserted(parent, first, last) {
+            notificationPopupManager.handleModelChange();
+        }
 
-    Component.onCompleted: ShellUtil.setInputTransparent(notificationPopupManager, true)
+        function onRowsRemoved(parent, first, last) {
+            notificationPopupManager.handleModelChange();
+        }
 
-    Binding {
-        target: MobileShellState.ShellDBusClient
-        property: "isNotificationPopupDrawerOpen"
-        value: popupDrawerOpened
+        function onModelReset() {
+            notificationPopupManager.handleModelChange();
+        }
     }
 
-    // hide on timeout to give time to finish animations
-    Timer {
-        id: hideTimeout
-        interval: Kirigami.Units.veryLongDuration * 1.5
-        repeat: false
-        onTriggered: if (notifications.count == 0) notificationPopupManager.visible = false;
-    }
+    function handleModelChange() {
+        if (!popupNotificationsModel) return;
 
-    // Update the window touch region to encapsulate the notification area or the whole screen depending on the 'popupDrawerOpened' state
-    function updateTouchArea() {
-        ShellUtil.setInputTransparent(notificationPopupManager, false);
-        if (popupDrawerOpened) {
-            ShellUtil.setInputRegion(notificationPopupManager, Qt.rect(0, 0, 0, 0));
-        } else {
-            // get the height of the popup directly to ensure we get the latest version
-            let popupHeight = Kirigami.Units.gridUnit * 6;
-            let currentPopup = notifications.objectAt(notifications.currentPopupIndex);
-            if (currentPopup) {
-                popupHeight = currentPopup.popupHeight;
-            } else {
-                console.warn("popupNotification: could not retrieve current popup height - falling back to a default value")
-            }
+        let currentCount = popupNotificationsModel.rowCount();
 
-            ShellUtil.setInputRegion(notificationPopupManager, Qt.rect((notificationPopupManager.width - notificationPopupManager.popupWidth - Kirigami.Units.gridUnit) / 2, openOffset - Kirigami.Units.gridUnit / 2, notificationPopupManager.popupWidth + Kirigami.Units.gridUnit, popupHeight + Kirigami.Units.gridUnit * ((notifications.count - notifications.currentPopupIndex > 1) ? 4 : 1)));
+        if (currentCount === 0) {
+            ShellUtil.setInputTransparent(notificationPopupManager, true);
+            notificationPopupManager.isPopupDrawerOpen = false;
+        } else if (currentCount === 1 && lastNotificationCount === 0) {
+            notificationPopupManager.visible = true;
         }
+
+        notificationPopupManager.updateTouchArea();
+
+        lastNotificationCount = currentCount;
     }
 
-    // parent the popup notifications inside a Flickable so that they can be scrollable when the drawer state is active
-    Flickable {
-        id: flickable
-        width: notificationPopupManager.width
-        height: Screen.height
-        contentHeight: notifications.fullHeight + notificationPopupManager.openOffset
-        boundsBehavior: Flickable.DragAndOvershootBounds
-        bottomMargin: Kirigami.Units.gridUnit * 6
+    property var keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
+    property bool isPopupDrawerOpen: false // this toggles whether to show all the active popup notifications at ones in a list
+    property real popupDrawerAnimationValue: notificationPopupManager.isPopupDrawerOpen ? 1 : 0 // animate notifications entering and exiting the drawer
+    property var currentNotification: notifications.count > 0 ? notifications.itemAtIndex(0) : null
 
-        interactive: notificationPopupManager.popupDrawerOpened
+    readonly property int popupWidth: Math.min(Kirigami.Units.gridUnit * 20, Screen.width - Kirigami.Units.gridUnit * 2)
+    readonly property real openOffset: (Kirigami.Units.smallSpacing * 3) + Kirigami.Units.gridUnit
+    readonly property color backgroundColor: Qt.darker(Qt.rgba(Kirigami.Theme.backgroundColor.r, Kirigami.Theme.backgroundColor.g, Kirigami.Theme.backgroundColor.b, 0.95), 1.05)
 
-        onDragEnded: flickable.checkDismiss();
-        onFlickEnded: flickable.checkDismiss();
-        onDragStarted: {
-            notifications.recalculateHeight();
-            atBeginning = flickable.atYBeginning;
-            atEnd = flickable.atYEnd;
+    signal timeChanged
+
+    onWidthChanged: {
+        if (visible) {
+            notificationPopupManager.updateTouchArea();
         }
-        onFlickStarted: {
-            notifications.recalculateHeight();
-            atBeginning = flickable.atYBeginning;
-            atEnd = flickable.atYEnd;
+    }
+
+    onCurrentNotificationChanged: {
+        if (currentNotification) {
+            updateTouchArea();
         }
+    }
 
-        property bool atBeginning: false
-        property bool atEnd: false
+    Component.onCompleted: ShellUtil.setInputTransparent(notificationPopupManager, true)
 
-        function checkDismiss() {
-            let dismissFromTop = atBeginning && flickable.verticalOvershoot < -Kirigami.Units.gridUnit;
-            let dismissFromBottom = atEnd && flickable.verticalOvershoot > Kirigami.Units.gridUnit;
-            if (dismissFromTop || dismissFromBottom) {
-                flickable.dismiss();
-            }
-        }
+    Binding {
+        target: MobileShellState.ShellDBusClient
+        property: "isNotificationPopupDrawerOpen"
+        value: notificationPopupManager.isPopupDrawerOpen
+    }
 
-        function dismiss() {
-            notificationPopupManager.popupDrawerOpened = false;
-            notificationPopupManager.updateTouchArea();
-            resetContentY.running = true;
+    Behavior on popupDrawerAnimationValue {
+        NumberAnimation {
+            duration: Kirigami.Units.veryLongDuration * 1.5
+            easing.type: Easing.OutQuint
         }
+    }
 
-        NumberAnimation on contentY {
-            id: resetContentY
-            running: false
-            to: 0
+    Behavior on color {
+        ColorAnimation {
             duration: Kirigami.Units.veryLongDuration * 1.5
             easing.type: Easing.OutExpo
         }
+    }
 
-        MouseArea {
-            // capture taps behind the notifications to close the drawer
-            id: item
-            anchors.left: parent.left
-            anchors.right: parent.right
-            width: notificationPopupManager.width
-            height: Math.max(notifications.fullHeight, Screen.height)
-
-            onReleased: flickable.dismiss();
-
-            Instantiator {
-                id: notifications
-                model: popupNotificationsModel
-
-                // get the height, drag offset, and idx of the current popup notifition and make it easily accessible by all popup notifications
-                property int currentPopupHeight: (count > 0 && currentPopupIndex < count && objectAt(currentPopupIndex)) ? objectAt(currentPopupIndex).popupHeight : 0;
-                property int currentDragOffset: 0
-                property int currentPopupIndex: 0
-
-                // calculate the full height of all the notifications combine for scrolling purposes
-                property int fullHeight: 0
-                onCountChanged: {
-                    if (count == 0) {
-                        ShellUtil.setInputTransparent(notificationPopupManager, true);
-                        hideTimeout.restart();
-                        notificationPopupManager.popupDrawerOpened = false;
-                        fullHeight = 0;
-                        return;
-                    }
-                    notificationPopupManager.visible = true;
-                    notifications.recalculateHeight();
-                }
+    NumberAnimation {
+        id: popupDrawerCloseAnimation
+        target: notifications
+        property: "contentY"
+        to: notifications.originY - notifications.topMargin
+        duration: Kirigami.Units.veryLongDuration * 1
+        easing.type: Easing.OutExpo
+    }
 
-                function recalculateHeight() {
-                    let findHeight = 0
-                    for (var i = 0; i < count; i++)  {
-                        findHeight += notifications.objectAt(i).popupHeight + Kirigami.Units.gridUnit;
-                    }
-                    fullHeight = findHeight;
-                }
+    // Update the window touch region to encapsulate the notification area or the whole screen depending on the 'isPopupDrawerOpen' state
+    function updateTouchArea() {
+        notificationPopupManager.currentNotification = Qt.binding(() => notifications.count > 0 ? notifications.itemAtIndex(0) : null);
 
-                delegate: NotificationPopup {
-                    id: popup
+        if (!(notifications.count > 0)) {
+            ShellUtil.setInputTransparent(notificationPopupManager, true);
+            return;
+        }
 
-                    anchors.horizontalCenter: parent.horizontalCenter
-                    z: notifications.count - index
+        ShellUtil.setInputTransparent(notificationPopupManager, false);
+        if (!currentNotification) return;
 
-                    popupWidth: notificationPopupManager.popupWidth
-                    openOffset: notificationPopupManager.openOffset
+        if (isPopupDrawerOpen) {
+            // reset the touch layout to use the entire screen dimensions
+            ShellUtil.setInputRegion(notificationPopupManager, Qt.rect(0, 0, 0, 0));
+        } else {
+            let currentNotificationMappedToView = currentNotification.mapToItem(notifications, 0, 0);
+            ShellUtil.setInputRegion(
+                notificationPopupManager,
+                Qt.rect(
+                    currentNotificationMappedToView.x,
+                    currentNotificationMappedToView.y,
+                    currentNotification.width,
+                    currentNotification.height + Kirigami.Units.gridUnit * 1.5
+                )
+            );
+        }
+    }
 
-                    keyboardInteractivity: notificationPopupManager.keyboardInteractivity
-                    popupNotifications: notifications
-                    popupIndex: index
+    ListView {
+        id: notifications
+        anchors.fill: parent
+        topMargin: notificationPopupManager.openOffset
+        bottomMargin: Kirigami.Units.gridUnit * 4
+        spacing: 0
 
-                    popupDrawerOpened: notificationPopupManager.popupDrawerOpened
+        model: popupNotificationsModel
+        interactive: notificationPopupManager.isPopupDrawerOpen
 
-                    popupModel: model
-                    notificationsModel: popupNotificationsModel
-                    notificationsModelType: notificationModelType
-                    timeDataSource: clockSource
+        cacheBuffer: Math.max(notifications.height * 0.25, ((currentNotification ? currentNotification.height : Kirigami.Units.gridUnit * 3) * 3) - notifications.height)
+        displayMarginEnd: Math.max(notifications.height, ((currentNotification ? currentNotification.height : Kirigami.Units.gridUnit * 3) * 3) - notifications.height)
 
-                    timeout: model.timeout
+        // internal tracking states for gesture thresholds
+        property bool isAtBeginning: false
+        property bool isAtEnd: false
 
-                    onUpdateTouchArea: notificationPopupManager.updateTouchArea()
+        // event handlers
+        onDragStarted: {
+            isAtBeginning = notifications.atYBeginning;
+            isAtEnd = notifications.atYEnd;
+        }
+        onFlickStarted: {
+            isAtBeginning = notifications.atYBeginning;
+            isAtEnd = notifications.atYEnd;
+        }
+        onDragEnded: notifications.checkDismiss()
+        onFlickEnded: notifications.checkDismiss()
 
-                    onSetInputTransparent: ShellUtil.setInputTransparent(notificationPopupManager, true)
+        onOriginYChanged: {
+            resetContentVerticalAnimation.to = notifications.originY - notifications.topMargin;
+            if (resetContentVerticalAnimation.running) {
+                resetContentVerticalAnimation.restart();
+            }
+            updateTouchArea();
+        }
 
-                    onOpenPopupDrawer: notificationPopupManager.popupDrawerOpened = true
+        NumberAnimation on contentY {
+            id: resetContentVerticalAnimation
+            running: false
+            to: notifications.originY - notifications.topMargin
+            duration: Kirigami.Units.veryLongDuration * 1.5
+            easing.type: Easing.OutExpo
+            onFinished: updateTouchArea()
+        }
 
-                    onSetKeyboardFocus: notificationPopupManager.keyboardInteractivity = LayerShell.Window.KeyboardInteractivityOnDemand
+        // capture taps behind the notifications to close the drawer
+        TapHandler {
+            onTapped: notifications.dismiss()
+        }
 
-                    onRemoveKeyboardFocus: notificationPopupManager.keyboardInteractivity = LayerShell.Window.KeyboardInteractivityNone
+        // helper functions
+        function checkDismiss() {
+            let dismissFromTop = isAtBeginning && notifications.verticalOvershoot < -Kirigami.Units.gridUnit;
+            let dismissFromBottom = isAtEnd && notifications.verticalOvershoot > Kirigami.Units.gridUnit;
+            if (dismissFromTop || dismissFromBottom) {
+                notifications.dismiss();
+            }
+        }
 
-                    defaultTimeout: notificationSettings.popupTimeout + (model.urls && model.urls.length > 0 ? 5000 : 0)
+        function dismiss() {
+            if (!notificationPopupManager.isPopupDrawerOpen) return;
 
-                    dismissTimeout: !notificationSettings.permanentJobPopups
-                        && model.type === NotificationManager.Notifications.JobType
-                        && model.jobState !== NotificationManager.Notifications.JobStateStopped
-                        ? defaultTimeout : 0
+            notificationPopupManager.isPopupDrawerOpen = false;
+            notificationPopupManager.updateTouchArea();
+            resetContentVerticalAnimation.restart();
+        }
 
-                    onDismissClicked: model.dismissed = true
+        delegate: NotificationPopup {
+            id: notificationPopup
+            width: notificationPopupManager.popupWidth
+            z: notifications.count - index
 
-                    onExpired: {
-                        if (model.resident) {
-                            // When resident, only mark it as expired so the popup disappears
-                            // but don't actually invalidate the notification
-                            model.expired = true;
-                        } else {
-                            if (notificationModelType === NotificationsModelType.WatchedNotificationsModel) {
-                                popupNotificationsModel.expire(model.notificationId);
-                            } else if (notificationModelType === NotificationsModelType.NotificationsModel) {
-                                popupNotificationsModel.expire(popupNotificationsModel.index(index, 0));
-                            }
-                        }
+            transform: [
+                Translate {
+                    x: (notificationPopupManager.width - notificationPopupManager.popupWidth) * 0.5
+                }
+            ]
+
+            popupManager: notificationPopupManager
+            keyboardInteractivity: notificationPopupManager.keyboardInteractivity
+            popupDrawerOpened: notificationPopupManager.isPopupDrawerOpen
+            notificationsModel: popupNotificationsModel
+            notificationsModelType: notificationModelType
+            timeDataSource: clockSource
+            notificationCount: notifications.count
+            topPopupOffset: notificationPopupManager.openOffset
+            popupDrawerAnimationValue: notificationPopupManager.popupDrawerAnimationValue
+            timeout: model.timeout
+
+            currentPopupHeight: (notifications.count > 0 && notificationPopup.index > 0 && currentNotification) ? currentNotification.cardHeight : Kirigami.Units.gridUnit * 6
+            currentPopupDragOffset: (notifications.count > 0 && index > 0 && currentNotification) ? currentNotification.dragOffset : 0
+            offsetFromCurrentPopup: notifications.originY - notificationPopup.y
+
+            defaultTimeout: notificationSettings.popupTimeout + (model.urls && model.urls.length > 0 ? 5000 : 0)
+            dismissTimeout: !notificationSettings.permanentJobPopups
+                && model.type === NotificationManager.Notifications.JobType
+                && model.jobState !== NotificationManager.Notifications.JobStateStopped
+                ? defaultTimeout : 0
+
+            onUpdateTouchArea: notificationPopupManager.updateTouchArea()
+            onOpenPopupDrawer: notificationPopupManager.isPopupDrawerOpen = true
+            onSetKeyboardFocus: notificationPopupManager.keyboardInteractivity = LayerShell.Window.KeyboardInteractivityOnDemand
+            onRemoveKeyboardFocus: notificationPopupManager.keyboardInteractivity = LayerShell.Window.KeyboardInteractivityNone
+            onDismissClicked: model.dismissed = true
+
+            onExpired: {
+                if (model.resident) {
+                    // When resident, only mark it as expired so the popup disappears
+                    // but don't actually invalidate the notification
+                    model.expired = true;
+                } else {
+                    if (notificationModelType === NotificationsModelType.WatchedNotificationsModel) {
+                        popupNotificationsModel.expire(model.notificationId);
+                    } else if (notificationModelType === NotificationsModelType.NotificationsModel) {
+                        popupNotificationsModel.expire(popupNotificationsModel.index(index, 0));
                     }
+                }
+            }
 
-                    Component.onCompleted: {
-                        if (model.type === NotificationManager.Notifications.NotificationType && model.desktopEntry) {
-                            // Register apps that were seen spawning a popup so they can be configured later
-                            // Apps with notifyrc can already be configured anyway
-                            if (!model.notifyRcName) {
-                                notificationSettings.registerKnownApplication(model.desktopEntry);
-                                notificationSettings.save();
-                            }
-                        }
-
-                        // Tell the model that we're handling the timeout now
-                        popupNotificationsModel.stopTimeout(popupNotificationsModel.index(index, 0));
-
-                        item.children.push(this);
+            Component.onCompleted: {
+                if (model.type === NotificationManager.Notifications.NotificationType && model.desktopEntry) {
+                    // Register apps that were seen spawning a popup so they can be configured later
+                    // Apps with notifyrc can already be configured anyway
+                    if (!model.notifyRcName) {
+                        notificationSettings.registerKnownApplication(model.desktopEntry);
+                        notificationSettings.save();
                     }
                 }
+
+                // Tell the model that we're handling the timeout now
+                popupNotificationsModel.stopTimeout(popupNotificationsModel.index(index, 0));
             }
         }
     }
diff --git a/components/mobileshell/qml/popups/notifications/NotificationPopupProvider.qml b/components/mobileshell/qml/popups/notifications/NotificationPopupProvider.qml
index 1c32757a0..2515bfc36 100644
--- a/components/mobileshell/qml/popups/notifications/NotificationPopupProvider.qml
+++ b/components/mobileshell/qml/popups/notifications/NotificationPopupProvider.qml
@@ -1,14 +1,12 @@
 /*
- *  SPDX-FileCopyrightText: 2024 Micah Stanley <[email protected]>
- *
- *  SPDX-License-Identifier: GPL-2.0-or-later
+    SPDX-FileCopyrightText: 2024 Micah Stanley <[email protected]>
+
+    SPDX-License-Identifier: GPL-2.0-or-later
  */
 
 import QtQuick
 import QtQuick.Layouts
 
-import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
-import org.kde.plasma.private.mobileshell.state as MobileShellState
 import org.kde.plasma.private.mobileshell as MobileShell
 
 import org.kde.notificationmanager as NotificationManager
@@ -28,7 +26,7 @@ QtObject {
         onNotificationsInhibitedUntilChanged: notificationProvider.checkInhibition()
     }
 
-    property QtObject popupNotificationsModel: NotificationManager.Notifications {
+    property var popupNotificationsModel: NotificationManager.Notifications {
         showExpired: false
         showDismissed: false
         blacklistedDesktopEntries: notificationSettings.popupBlacklistedApplications
diff --git a/components/mobileshell/qml/widgets/notifications/NotificationCard.qml b/components/mobileshell/qml/widgets/notifications/NotificationCard.qml
index 6c0e4a39d..1a332560b 100644
--- a/components/mobileshell/qml/widgets/notifications/NotificationCard.qml
+++ b/components/mobileshell/qml/widgets/notifications/NotificationCard.qml
@@ -19,6 +19,11 @@ Item {
      */
     default property Item contentItem
 
+    /**
+     * The height of the main card. This value is synced to the animated height if 'animateHeight' is set to true.
+     */
+    readonly property real cardHeight: mainCard.implicitHeight
+
     /**
      * The panel background type for this notification.
      */
@@ -39,6 +44,11 @@ Item {
      */
     property bool inLockScreen: false
 
+    /**
+     * Whether to animate the changes in the notification card height.
+     */
+    property bool animateHeight: false
+
     /**
      * The current notification popup height.
      */
@@ -79,7 +89,6 @@ Item {
         contentItem.parent = contentParent;
         contentItem.anchors.fill = contentParent;
         contentItem.anchors.margins = Kirigami.Units.largeSpacing;
-        contentParent.children.push(contentItem);
     }
 
     implicitHeight: contentParent.implicitHeight
@@ -113,7 +122,7 @@ Item {
         implicitHeight: inPopupDrawer ? currentPopupHeight : contentParent.implicitHeight
         Behavior on implicitHeight {
             NumberAnimation {
-                duration: Kirigami.Units.veryLongDuration
+                duration: root.animateHeight ? Kirigami.Units.veryLongDuration : 0
                 easing.type: Easing.OutExpo
             }
         }
@@ -135,7 +144,7 @@ Item {
                 }
             }
 
-            background: Item
+            background: Item {}
 
             contentItem: Item {
                 implicitWidth: parent.width
diff --git a/components/mobileshell/qml/widgets/notifications/NotificationItem.qml b/components/mobileshell/qml/widgets/notifications/NotificationItem.qml
index 3c7078199..b2beb103b 100644
--- a/components/mobileshell/qml/widgets/notifications/NotificationItem.qml
+++ b/components/mobileshell/qml/widgets/notifications/NotificationItem.qml
@@ -24,7 +24,10 @@ BaseNotificationItem {
     id: notificationItem
     implicitHeight: mainCard.implicitHeight + mainCard.anchors.topMargin + notificationHeading.height
 
+    readonly property real cardHeight: mainCard.cardHeight
+
     property bool inLockScreen: false
+    property bool animateHeight: false
     property int panelType: MobileShell.PanelBackground.PanelType.Drawer
 
     property var cardColorScheme: Kirigami.Theme.View
@@ -62,6 +65,7 @@ BaseNotificationItem {
         onDismissRequested: notificationItem.close();
         inLockScreen: notificationItem.inLockScreen
         panelType: notificationItem.panelType
+        animateHeight: notificationItem.animateHeight
 
         onDragStart: notificationItem.dragStart()
         onDragEnd: notificationItem.dragEnd()
diff --git a/components/mobileshell/qml/widgets/notifications/NotificationPopupItem.qml b/components/mobileshell/qml/widgets/notifications/NotificationPopupItem.qml
index 0e769074f..52eebab09 100644
--- a/components/mobileshell/qml/widgets/notifications/NotificationPopupItem.qml
+++ b/components/mobileshell/qml/widgets/notifications/NotificationPopupItem.qml
@@ -24,7 +24,10 @@ BaseNotificationItem {
     id: notificationItem
     implicitHeight: mainCard.implicitHeight
 
+    readonly property real cardHeight: mainCard.cardHeight
+
     property bool inPopupDrawer: false
+    property bool animateHeight: false
     property int currentPopupHeight: 0
     property real remainingTimeProgress: 1
     property bool closeTimerRunning: false
@@ -46,6 +49,7 @@ BaseNotificationItem {
 
         popupNotification: true
         inPopupDrawer: notificationItem.inPopupDrawer
+        animateHeight: notificationItem.animateHeight
         currentPopupHeight: notificationItem.currentPopupHeight
         remainingTimeProgress: notificationItem.remainingTimeProgress
         closeTimerRunning: notificationItem.closeTimerRunning
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.