Merge "Reattaching and dismissing notifications when detached." into main
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/row/NotificationMenuRowTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/row/NotificationMenuRowTest.java
index ce120c5..9536656 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/row/NotificationMenuRowTest.java
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/row/NotificationMenuRowTest.java
@@ -28,6 +28,7 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import android.platform.test.annotations.DisableFlags;
 import android.provider.Settings;
 import android.testing.TestableLooper;
 import android.testing.TestableLooper.RunWithLooper;
@@ -38,6 +39,7 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
+import com.android.systemui.Flags;
 import com.android.systemui.kosmos.KosmosJavaAdapter;
 import com.android.systemui.plugins.statusbar.NotificationMenuRowPlugin;
 import com.android.systemui.statusbar.notification.collection.EntryAdapter;
@@ -418,6 +420,7 @@
         assertTrue("when alpha is .5, menu is visible", row.isMenuVisible());
     }
 
+    @DisableFlags(Flags.FLAG_MAGNETIC_NOTIFICATION_SWIPES)
     @Test
     public void testOnTouchMove() {
         NotificationMenuRow row = Mockito.spy(
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/MagneticNotificationRowManagerImplTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/MagneticNotificationRowManagerImplTest.kt
index ccc8be7..6c6ba93 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/MagneticNotificationRowManagerImplTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/MagneticNotificationRowManagerImplTest.kt
@@ -130,7 +130,9 @@
         kosmos.testScope.runTest {
             // GIVEN a threshold of 100 px
             val threshold = 100f
-            underTest.setSwipeThresholdPx(threshold)
+            underTest.onDensityChange(
+                threshold / MagneticNotificationRowManager.MAGNETIC_DETACH_THRESHOLD_DP
+            )
 
             // GIVEN that targets are set and the rows are being pulled
             setTargets()
@@ -150,7 +152,9 @@
         kosmos.testScope.runTest {
             // GIVEN a threshold of 100 px
             val threshold = 100f
-            underTest.setSwipeThresholdPx(threshold)
+            underTest.onDensityChange(
+                threshold / MagneticNotificationRowManager.MAGNETIC_DETACH_THRESHOLD_DP
+            )
 
             // GIVEN that targets are set and the rows are being pulled
             canRowBeDismissed = false
@@ -172,7 +176,9 @@
         kosmos.testScope.runTest {
             // GIVEN a threshold of 100 px
             val threshold = 100f
-            underTest.setSwipeThresholdPx(threshold)
+            underTest.onDensityChange(
+                threshold / MagneticNotificationRowManager.MAGNETIC_DETACH_THRESHOLD_DP
+            )
 
             // GIVEN that targets are set and the rows are being pulled
             setTargets()
@@ -192,7 +198,9 @@
         kosmos.testScope.runTest {
             // GIVEN a threshold of 100 px
             val threshold = 100f
-            underTest.setSwipeThresholdPx(threshold)
+            underTest.onDensityChange(
+                threshold / MagneticNotificationRowManager.MAGNETIC_DETACH_THRESHOLD_DP
+            )
 
             // GIVEN that targets are set and the rows are being pulled
             canRowBeDismissed = false
@@ -294,6 +302,29 @@
             assertThat(underTest.isSwipedViewRoundableSet).isFalse()
         }
 
+    @Test
+    fun isMagneticRowDismissible_isDismissibleWhenDetached() =
+        kosmos.testScope.runTest {
+            setDetachedState()
+
+            val isDismissible = underTest.isMagneticRowSwipeDetached(swipedRow)
+            assertThat(isDismissible).isTrue()
+        }
+
+    @Test
+    fun setMagneticRowTranslation_whenDetached_belowAttachThreshold_reattaches() =
+        kosmos.testScope.runTest {
+            // GIVEN that the swiped view has been detached
+            setDetachedState()
+
+            // WHEN setting a new translation above the attach threshold
+            val translation = 50f
+            underTest.setMagneticRowTranslation(swipedRow, translation)
+
+            // THEN the swiped view reattaches magnetically and the state becomes PULLING
+            assertThat(underTest.currentState).isEqualTo(State.PULLING)
+        }
+
     @After
     fun tearDown() {
         // We reset the manager so that all MagneticRowListener can cancel all animations
@@ -302,7 +333,9 @@
 
     private fun setDetachedState() {
         val threshold = 100f
-        underTest.setSwipeThresholdPx(threshold)
+        underTest.onDensityChange(
+            threshold / MagneticNotificationRowManager.MAGNETIC_DETACH_THRESHOLD_DP
+        )
 
         // Set the pulling state
         setTargets()
@@ -327,8 +360,8 @@
     private fun MagneticRowListener.asTestableListener(rowIndex: Int): MagneticRowListener {
         val delegate = this
         return object : MagneticRowListener {
-            override fun setMagneticTranslation(translation: Float) {
-                delegate.setMagneticTranslation(translation)
+            override fun setMagneticTranslation(translation: Float, trackEagerly: Boolean) {
+                delegate.setMagneticTranslation(translation, trackEagerly)
             }
 
             override fun triggerMagneticForce(
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/NotificationSwipeHelperTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/NotificationSwipeHelperTest.java
index 789701f5..de48f40 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/NotificationSwipeHelperTest.java
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/NotificationSwipeHelperTest.java
@@ -49,6 +49,7 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
+import com.android.systemui.Flags;
 import com.android.systemui.SysuiTestCase;
 import com.android.systemui.classifier.FalsingManagerFake;
 import com.android.systemui.flags.FakeFeatureFlags;
@@ -362,6 +363,7 @@
         verify(mSwipeHelper, times(1)).isFalseGesture();
     }
 
+    @DisableFlags(Flags.FLAG_MAGNETIC_NOTIFICATION_SWIPES)
     @Test
     public void testIsDismissGesture_farEnough() {
         doReturn(false).when(mSwipeHelper).isFalseGesture();
@@ -374,6 +376,20 @@
         verify(mSwipeHelper, times(1)).isFalseGesture();
     }
 
+    @EnableFlags(Flags.FLAG_MAGNETIC_NOTIFICATION_SWIPES)
+    @Test
+    public void testIsDismissGesture_magneticSwipeIsDismissible() {
+        doReturn(false).when(mSwipeHelper).isFalseGesture();
+        doReturn(false).when(mSwipeHelper).swipedFarEnough();
+        doReturn(false).when(mSwipeHelper).swipedFastEnough();
+        doReturn(true).when(mCallback).isMagneticViewDetached(any());
+        when(mCallback.canChildBeDismissedInDirection(any(), anyBoolean())).thenReturn(true);
+        when(mEvent.getActionMasked()).thenReturn(MotionEvent.ACTION_UP);
+
+        assertTrue("Should be a dismissal", mSwipeHelper.isDismissGesture(mEvent));
+        verify(mSwipeHelper, times(1)).isFalseGesture();
+    }
+
     @Test
     public void testIsDismissGesture_notFarOrFastEnough() {
         doReturn(false).when(mSwipeHelper).isFalseGesture();
diff --git a/packages/SystemUI/src/com/android/systemui/SwipeHelper.java b/packages/SystemUI/src/com/android/systemui/SwipeHelper.java
index 0894667..d017754 100644
--- a/packages/SystemUI/src/com/android/systemui/SwipeHelper.java
+++ b/packages/SystemUI/src/com/android/systemui/SwipeHelper.java
@@ -778,18 +778,26 @@
 
     protected boolean swipedFarEnough() {
         float translation = getTranslation(mTouchedView);
-        return Math.abs(translation) > SWIPED_FAR_ENOUGH_SIZE_FRACTION * getSize(
-                mTouchedView);
+        return Math.abs(translation) > SWIPED_FAR_ENOUGH_SIZE_FRACTION * getSize(mTouchedView);
     }
 
     public boolean isDismissGesture(MotionEvent ev) {
         float translation = getTranslation(mTouchedView);
         return ev.getActionMasked() == MotionEvent.ACTION_UP
                 && !mFalsingManager.isUnlockingDisabled()
-                && !isFalseGesture() && (swipedFastEnough() || swipedFarEnough())
+                && !isFalseGesture() && isSwipeDismissible()
                 && mCallback.canChildBeDismissedInDirection(mTouchedView, translation > 0);
     }
 
+    /** Can the swipe gesture on the touched view be considered as a dismiss intention */
+    public boolean isSwipeDismissible() {
+        if (magneticNotificationSwipes()) {
+            return mCallback.isMagneticViewDetached(mTouchedView) || swipedFastEnough();
+        } else {
+            return swipedFastEnough() || swipedFarEnough();
+        }
+    }
+
     /** Returns true if the gesture should be rejected. */
     public boolean isFalseGesture() {
         boolean falsingDetected = mCallback.isAntiFalsingNeeded();
@@ -970,6 +978,13 @@
         void onMagneticInteractionEnd(View view, float velocity);
 
         /**
+         * Determine if a view managed by magnetic interactions is magnetically detached
+         * @param view The magnetic view
+         * @return if the view is detached according to its magnetic state.
+         */
+        boolean isMagneticViewDetached(View view);
+
+        /**
          * Called when the child is long pressed and available to start drag and drop.
          *
          * @param v the view that was long pressed.
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableView.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableView.java
index 292f74a..f36a0cf 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableView.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableView.java
@@ -19,6 +19,8 @@
 import static com.android.systemui.Flags.notificationColorUpdateLogger;
 import static com.android.systemui.Flags.physicalNotificationMovement;
 
+import static java.lang.Math.abs;
+
 import android.animation.AnimatorListenerAdapter;
 import android.content.Context;
 import android.content.res.Configuration;
@@ -29,6 +31,7 @@
 import android.util.IndentingPrintWriter;
 import android.util.Log;
 import android.view.View;
+import android.view.ViewConfiguration;
 import android.view.ViewGroup;
 import android.view.ViewParent;
 import android.widget.FrameLayout;
@@ -110,14 +113,27 @@
     protected SpringAnimation mMagneticAnimator = new SpringAnimation(
             this /* object */, DynamicAnimation.TRANSLATION_X);
 
+    private int mTouchSlop;
+
     protected MagneticRowListener mMagneticRowListener = new MagneticRowListener() {
 
         @Override
-        public void setMagneticTranslation(float translation) {
-            if (mMagneticAnimator.isRunning()) {
-                mMagneticAnimator.animateToFinalPosition(translation);
-            } else {
+        public void setMagneticTranslation(float translation, boolean trackEagerly) {
+            if (!mMagneticAnimator.isRunning()) {
                 setTranslation(translation);
+                return;
+            }
+
+            if (trackEagerly) {
+                float delta = abs(getTranslation() - translation);
+                if (delta > mTouchSlop) {
+                    mMagneticAnimator.animateToFinalPosition(translation);
+                } else {
+                    mMagneticAnimator.cancel();
+                    setTranslation(translation);
+                }
+            } else {
+                mMagneticAnimator.animateToFinalPosition(translation);
             }
         }
 
@@ -183,6 +199,7 @@
     private void initDimens() {
         mContentShift = getResources().getDimensionPixelSize(
                 R.dimen.shelf_transform_content_shift);
+        mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
     }
 
     @Override
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationMenuRow.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationMenuRow.java
index 977936f..c03dc27 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationMenuRow.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationMenuRow.java
@@ -46,7 +46,6 @@
 import com.android.systemui.plugins.statusbar.NotificationMenuRowPlugin;
 import com.android.systemui.res.R;
 import com.android.systemui.statusbar.AlphaOptimizedImageView;
-import com.android.systemui.statusbar.notification.collection.NotificationEntry;
 import com.android.systemui.statusbar.notification.people.PeopleNotificationIdentifier;
 import com.android.systemui.statusbar.notification.row.NotificationGuts.GutsContent;
 import com.android.systemui.statusbar.notification.shared.NotificationBundleUi;
@@ -363,7 +362,9 @@
             final float dismissThreshold = getDismissThreshold();
             final boolean snappingToDismiss = delta < -dismissThreshold || delta > dismissThreshold;
             if (mSnappingToDismiss != snappingToDismiss) {
-                getMenuView().performHapticFeedback(CLOCK_TICK);
+                if (!Flags.magneticNotificationSwipes()) {
+                    getMenuView().performHapticFeedback(CLOCK_TICK);
+                }
             }
             mSnappingToDismiss = snappingToDismiss;
         }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MagneticNotificationRowManager.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MagneticNotificationRowManager.kt
index aa69517..48cff74 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MagneticNotificationRowManager.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MagneticNotificationRowManager.kt
@@ -33,12 +33,12 @@
 interface MagneticNotificationRowManager {
 
     /**
-     * Set the swipe threshold in pixels. After crossing the threshold, the magnetic target detaches
-     * and the magnetic neighbors snap back.
+     * Notifies a change in the device density. The density can be used to compute the values of
+     * thresholds in pixels.
      *
-     * @param[threshold] Swipe threshold in pixels.
+     * @param[density] The device density.
      */
-    fun setSwipeThresholdPx(thresholdPx: Float)
+    fun onDensityChange(density: Float)
 
     /**
      * Set the magnetic and roundable targets of a magnetic swipe interaction.
@@ -87,6 +87,9 @@
      */
     fun onMagneticInteractionEnd(row: ExpandableNotificationRow, velocity: Float? = null)
 
+    /** Determine if the given [ExpandableNotificationRow] has been magnetically detached. */
+    fun isMagneticRowSwipeDetached(row: ExpandableNotificationRow): Boolean
+
     /* Reset any roundness that magnetic targets may have */
     fun resetRoundness()
 
@@ -104,12 +107,15 @@
         /** Detaching threshold in dp */
         const val MAGNETIC_DETACH_THRESHOLD_DP = 56
 
+        /** Re-attaching threshold in dp */
+        const val MAGNETIC_ATTACH_THRESHOLD_DP = 40
+
         /* An empty implementation of a manager */
         @JvmStatic
         val Empty: MagneticNotificationRowManager
             get() =
                 object : MagneticNotificationRowManager {
-                    override fun setSwipeThresholdPx(thresholdPx: Float) {}
+                    override fun onDensityChange(density: Float) {}
 
                     override fun setMagneticAndRoundableTargets(
                         swipingRow: ExpandableNotificationRow,
@@ -127,6 +133,10 @@
                         velocity: Float?,
                     ) {}
 
+                    override fun isMagneticRowSwipeDetached(
+                        row: ExpandableNotificationRow
+                    ): Boolean = false
+
                     override fun resetRoundness() {}
 
                     override fun reset() {}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MagneticNotificationRowManagerImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MagneticNotificationRowManagerImpl.kt
index 5a23f7c..6e8b222 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MagneticNotificationRowManagerImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MagneticNotificationRowManagerImpl.kt
@@ -47,6 +47,7 @@
         private set
 
     private var magneticDetachThreshold = Float.POSITIVE_INFINITY
+    private var magneticAttachThreshold = 0f
 
     // Has the roundable target been set for the magnetic view that is being swiped.
     val isSwipedViewRoundableSet: Boolean
@@ -57,13 +58,25 @@
         SpringForce().setStiffness(DETACH_STIFFNESS).setDampingRatio(DETACH_DAMPING_RATIO)
     private val snapForce =
         SpringForce().setStiffness(SNAP_BACK_STIFFNESS).setDampingRatio(SNAP_BACK_DAMPING_RATIO)
+    private val attachForce =
+        SpringForce().setStiffness(ATTACH_STIFFNESS).setDampingRatio(ATTACH_DAMPING_RATIO)
 
     // Multiplier applied to the translation of a row while swiped
     val swipedRowMultiplier =
         MAGNETIC_TRANSLATION_MULTIPLIERS[MAGNETIC_TRANSLATION_MULTIPLIERS.size / 2]
 
-    override fun setSwipeThresholdPx(thresholdPx: Float) {
-        magneticDetachThreshold = thresholdPx
+    /**
+     * An offset applied to input translation that increases on subsequent re-attachments of a
+     * detached magnetic view. This helps keep computations consistent when the drag gesture input
+     * and the swiped notification don't share the same origin point after a re-attaching animation.
+     */
+    private var translationOffset = 0f
+
+    override fun onDensityChange(density: Float) {
+        magneticDetachThreshold =
+            density * MagneticNotificationRowManager.MAGNETIC_DETACH_THRESHOLD_DP
+        magneticAttachThreshold =
+            density * MagneticNotificationRowManager.MAGNETIC_ATTACH_THRESHOLD_DP
     }
 
     override fun setMagneticAndRoundableTargets(
@@ -72,6 +85,7 @@
         sectionsManager: NotificationSectionsManager,
     ) {
         if (currentState == State.IDLE) {
+            translationOffset = 0f
             updateMagneticAndRoundableTargets(swipingRow, stackScrollLayout, sectionsManager)
             currentState = State.TARGETS_SET
         } else {
@@ -121,36 +135,36 @@
 
         val canTargetBeDismissed =
             currentMagneticListeners.swipedListener()?.canRowBeDismissed() ?: false
+        val correctedTranslation = translation - translationOffset
         when (currentState) {
             State.IDLE -> {
                 logger.logMagneticRowTranslationNotSet(currentState, row.getLoggingKey())
                 return false
             }
             State.TARGETS_SET -> {
-                pullTargets(translation, canTargetBeDismissed)
+                pullTargets(correctedTranslation, canTargetBeDismissed)
                 currentState = State.PULLING
             }
             State.PULLING -> {
-                updateRoundness(translation)
+                updateRoundness(correctedTranslation)
                 if (canTargetBeDismissed) {
-                    pullDismissibleRow(translation)
+                    pullDismissibleRow(correctedTranslation)
                 } else {
-                    pullTargets(translation, canSwipedBeDismissed = false)
+                    pullTargets(correctedTranslation, canSwipedBeDismissed = false)
                 }
             }
             State.DETACHED -> {
-                val swiped = currentMagneticListeners.swipedListener()
-                swiped?.setMagneticTranslation(translation)
+                translateDetachedRow(correctedTranslation)
             }
         }
         return true
     }
 
-    private fun updateRoundness(translation: Float) {
+    private fun updateRoundness(translation: Float, animate: Boolean = false) {
         val normalizedTranslation = abs(swipedRowMultiplier * translation) / magneticDetachThreshold
         notificationRoundnessManager.setRoundnessForAffectedViews(
             /* roundness */ normalizedTranslation.coerceIn(0f, MAX_PRE_DETACH_ROUNDNESS),
-            /* animate */ false,
+            animate,
         )
     }
 
@@ -232,7 +246,28 @@
         )
     }
 
+    private fun translateDetachedRow(translation: Float) {
+        val targetTranslation = swipedRowMultiplier * translation
+        val crossedThreshold = abs(targetTranslation) <= magneticAttachThreshold
+        if (crossedThreshold) {
+            translationOffset += translation
+            updateRoundness(translation = 0f, animate = true)
+            currentMagneticListeners.swipedListener()?.let { attach(it) }
+            currentState = State.PULLING
+        } else {
+            val swiped = currentMagneticListeners.swipedListener()
+            swiped?.setMagneticTranslation(translation, trackEagerly = false)
+        }
+    }
+
+    private fun attach(listener: MagneticRowListener) {
+        listener.cancelMagneticAnimations()
+        listener.triggerMagneticForce(endTranslation = 0f, attachForce)
+        msdlPlayer.playToken(MSDLToken.SWIPE_THRESHOLD_INDICATOR)
+    }
+
     override fun onMagneticInteractionEnd(row: ExpandableNotificationRow, velocity: Float?) {
+        translationOffset = 0f
         if (row.isSwipedTarget()) {
             when (currentState) {
                 State.PULLING -> {
@@ -254,9 +289,13 @@
         }
     }
 
+    override fun isMagneticRowSwipeDetached(row: ExpandableNotificationRow): Boolean =
+        row.isSwipedTarget() && currentState == State.DETACHED
+
     override fun resetRoundness() = notificationRoundnessManager.clear()
 
     override fun reset() {
+        translationOffset = 0f
         currentMagneticListeners.forEach {
             it?.cancelMagneticAnimations()
             it?.cancelTranslationAnimations()
@@ -300,6 +339,8 @@
         private const val DETACH_DAMPING_RATIO = 0.95f
         private const val SNAP_BACK_STIFFNESS = 550f
         private const val SNAP_BACK_DAMPING_RATIO = 0.6f
+        private const val ATTACH_STIFFNESS = 800f
+        private const val ATTACH_DAMPING_RATIO = 0.95f
 
         // Maximum value of corner roundness that gets applied during the pre-detach dragging
         private const val MAX_PRE_DETACH_ROUNDNESS = 0.8f
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MagneticRowListener.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MagneticRowListener.kt
index 5959ef1..344dab4 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MagneticRowListener.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MagneticRowListener.kt
@@ -21,8 +21,17 @@
 /** A listener that responds to magnetic forces applied to an [ExpandableNotificationRow] */
 interface MagneticRowListener {
 
-    /** Set a translation due to a magnetic attachment. */
-    fun setMagneticTranslation(translation: Float)
+    /**
+     * Set a translation due to a magnetic attachment.
+     *
+     * If a magnetic animation is running, [trackEagerly] decides if the new translation is applied
+     * immediately or if the animation finishes first. When applying the translation immediately,
+     * the change in translation must be greater than a touch slop threshold.
+     *
+     * @param[translation] Incoming gesture translation.
+     * @param[trackEagerly] Whether we eagerly track the incoming translation or not.
+     */
+    fun setMagneticTranslation(translation: Float, trackEagerly: Boolean = true)
 
     /**
      * Trigger the magnetic behavior when the row detaches or snaps back from its magnetic
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutController.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutController.java
index f3d8ee2..612c19f 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutController.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutController.java
@@ -486,15 +486,22 @@
                 }
 
                 @Override
+                public boolean isMagneticViewDetached(View view) {
+                    if (view instanceof ExpandableNotificationRow row) {
+                        return mMagneticNotificationRowManager.isMagneticRowSwipeDetached(row);
+                    } else {
+                        return false;
+                    }
+                }
+
+                @Override
                 public float getTotalTranslationLength(View animView) {
                     return mView.getTotalTranslationLength(animView);
                 }
 
                 @Override
                 public void onDensityScaleChange(float density) {
-                    mMagneticNotificationRowManager.setSwipeThresholdPx(
-                            density * MagneticNotificationRowManager.MAGNETIC_DETACH_THRESHOLD_DP
-                    );
+                    mMagneticNotificationRowManager.onDensityChange(density);
                 }
 
                 @Override
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationSwipeHelper.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationSwipeHelper.java
index c5a846e..5105e55 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationSwipeHelper.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationSwipeHelper.java
@@ -255,12 +255,13 @@
         int menuSnapTarget = menuRow.getMenuSnapTarget();
         boolean isNonFalseMenuRevealingGesture =
                 isMenuRevealingGestureAwayFromMenu && !isFalseGesture();
+        boolean isMagneticViewDetached = mCallback.isMagneticViewDetached(animView);
         if ((isNonDismissGestureTowardsMenu || isNonFalseMenuRevealingGesture)
                 && menuSnapTarget != 0) {
             // Menu has not been snapped to previously and this is menu revealing gesture
             snapOpen(animView, menuSnapTarget, velocity);
             menuRow.onSnapOpen();
-        } else if (isDismissGesture && !gestureTowardsMenu) {
+        } else if (isDismissGesture && (!gestureTowardsMenu || isMagneticViewDetached)) {
             dismiss(animView, velocity);
             menuRow.onDismiss();
         } else {
@@ -272,6 +273,7 @@
     private void handleSwipeFromOpenState(MotionEvent ev, View animView, float velocity,
             NotificationMenuRowPlugin menuRow) {
         boolean isDismissGesture = isDismissGesture(ev);
+        boolean isMagneticViewDetached = mCallback.isMagneticViewDetached(animView);
 
         final boolean withinSnapMenuThreshold =
                 menuRow.isWithinSnapMenuThreshold();
@@ -280,7 +282,7 @@
             // Haven't moved enough to unsnap from the menu
             menuRow.onSnapOpen();
             snapOpen(animView, menuRow.getMenuSnapTarget(), velocity);
-        } else if (isDismissGesture && !menuRow.shouldSnapBack()) {
+        } else if (isDismissGesture && (!menuRow.shouldSnapBack() || isMagneticViewDetached)) {
             // Only dismiss if we're not moving towards the menu
             dismiss(animView, velocity);
             menuRow.onDismiss();