diff options
5 files changed, 298 insertions, 36 deletions
diff --git a/libs/WindowManager/Shell/shared/res/values/dimen.xml b/libs/WindowManager/Shell/shared/res/values/dimen.xml index 5f013c52d70d..11a6f32d7454 100644 --- a/libs/WindowManager/Shell/shared/res/values/dimen.xml +++ b/libs/WindowManager/Shell/shared/res/values/dimen.xml @@ -38,6 +38,7 @@ <dimen name="drag_zone_v_split_from_expanded_view_height_fold_short">100dp</dimen> <!-- Bubble drop target dimensions --> + <dimen name="drop_target_elevation">1dp</dimen> <dimen name="drop_target_full_screen_padding">20dp</dimen> <dimen name="drop_target_desktop_window_padding_small">100dp</dimen> <dimen name="drop_target_desktop_window_padding_large">130dp</dimen> diff --git a/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DragZone.kt b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DragZone.kt index 5d346c047123..6eff75c9a479 100644 --- a/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DragZone.kt +++ b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DragZone.kt @@ -31,29 +31,41 @@ sealed interface DragZone { /** The bounds of this drag zone. */ val bounds: Rect + /** The bounds of the drop target associated with this drag zone. */ + val dropTarget: Rect? fun contains(x: Int, y: Int) = bounds.contains(x, y) /** Represents the bubble drag area on the screen. */ - sealed class Bubble(override val bounds: Rect) : DragZone { - data class Left(override val bounds: Rect, val dropTarget: Rect) : Bubble(bounds) - data class Right(override val bounds: Rect, val dropTarget: Rect) : Bubble(bounds) + sealed class Bubble(override val bounds: Rect, override val dropTarget: Rect) : DragZone { + data class Left(override val bounds: Rect, override val dropTarget: Rect) : + Bubble(bounds, dropTarget) + + data class Right(override val bounds: Rect, override val dropTarget: Rect) : + Bubble(bounds, dropTarget) } /** Represents dragging to Desktop Window. */ - data class DesktopWindow(override val bounds: Rect, val dropTarget: Rect) : DragZone + data class DesktopWindow(override val bounds: Rect, override val dropTarget: Rect) : DragZone /** Represents dragging to Full Screen. */ - data class FullScreen(override val bounds: Rect, val dropTarget: Rect) : DragZone + data class FullScreen(override val bounds: Rect, override val dropTarget: Rect) : DragZone /** Represents dragging to dismiss. */ - data class Dismiss(override val bounds: Rect) : DragZone + data class Dismiss(override val bounds: Rect) : DragZone { + override val dropTarget: Rect? = null + } /** Represents dragging to enter Split or replace a Split app. */ sealed class Split(override val bounds: Rect) : DragZone { + override val dropTarget: Rect? = null + data class Left(override val bounds: Rect) : Split(bounds) + data class Right(override val bounds: Rect) : Split(bounds) + data class Top(override val bounds: Rect) : Split(bounds) + data class Bottom(override val bounds: Rect) : Split(bounds) } } diff --git a/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DropTargetManager.kt b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DropTargetManager.kt index 29ce8d90e66f..2dc183f3f707 100644 --- a/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DropTargetManager.kt +++ b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DropTargetManager.kt @@ -16,22 +16,54 @@ package com.android.wm.shell.shared.bubbles +import android.content.Context +import android.graphics.Rect +import android.view.View +import android.widget.FrameLayout +import androidx.core.animation.Animator +import androidx.core.animation.AnimatorListenerAdapter +import androidx.core.animation.ValueAnimator + /** * Manages animating drop targets in response to dragging bubble icons or bubble expanded views * across different drag zones. */ class DropTargetManager( + context: Context, + private val container: FrameLayout, private val isLayoutRtl: Boolean, - private val dragZoneChangedListener: DragZoneChangedListener + private val dragZoneChangedListener: DragZoneChangedListener, ) { private var state: DragState? = null + private val dropTargetView = View(context) + private var animator: ValueAnimator? = null + + private companion object { + const val ANIMATION_DURATION_MS = 250L + } /** Must be called when a drag gesture is starting. */ fun onDragStarted(draggedObject: DraggedObject, dragZones: List<DragZone>) { val state = DragState(dragZones, draggedObject) dragZoneChangedListener.onInitialDragZoneSet(state.initialDragZone) this.state = state + animator?.cancel() + setupDropTarget() + } + + private fun setupDropTarget() { + if (dropTargetView.parent != null) container.removeView(dropTargetView) + container.addView(dropTargetView, 0) + // TODO b/393173014: set elevation and background + dropTargetView.alpha = 0f + dropTargetView.scaleX = 1f + dropTargetView.scaleY = 1f + dropTargetView.translationX = 0f + dropTargetView.translationY = 0f + // the drop target is added with a width and height of 1 pixel. when it gets resized, we use + // set its scale to the width and height of the bounds it should have to avoid layout passes + dropTargetView.layoutParams = FrameLayout.LayoutParams(/* width= */ 1, /* height= */ 1) } /** Called when the user drags to a new location. */ @@ -42,14 +74,67 @@ class DropTargetManager( state.currentDragZone = newDragZone if (oldDragZone != newDragZone) { dragZoneChangedListener.onDragZoneChanged(from = oldDragZone, to = newDragZone) + updateDropTarget() } } /** Called when the drag ended. */ fun onDragEnded() { + startFadeAnimation(from = dropTargetView.alpha, to = 0f) { + container.removeView(dropTargetView) + } state = null } + private fun updateDropTarget() { + val currentDragZone = state?.currentDragZone ?: return + val dropTargetBounds = currentDragZone.dropTarget + when { + dropTargetBounds == null -> startFadeAnimation(from = dropTargetView.alpha, to = 0f) + dropTargetView.alpha == 0f -> { + dropTargetView.translationX = dropTargetBounds.exactCenterX() + dropTargetView.translationY = dropTargetBounds.exactCenterY() + dropTargetView.scaleX = dropTargetBounds.width().toFloat() + dropTargetView.scaleY = dropTargetBounds.height().toFloat() + startFadeAnimation(from = 0f, to = 1f) + } + else -> startMorphAnimation(dropTargetBounds) + } + } + + private fun startFadeAnimation(from: Float, to: Float, onEnd: (() -> Unit)? = null) { + animator?.cancel() + val animator = ValueAnimator.ofFloat(from, to).setDuration(ANIMATION_DURATION_MS) + animator.addUpdateListener { _ -> dropTargetView.alpha = animator.animatedValue as Float } + if (onEnd != null) { + animator.doOnEnd(onEnd) + } + this.animator = animator + animator.start() + } + + private fun startMorphAnimation(bounds: Rect) { + animator?.cancel() + val startAlpha = dropTargetView.alpha + val startTx = dropTargetView.translationX + val startTy = dropTargetView.translationY + val startScaleX = dropTargetView.scaleX + val startScaleY = dropTargetView.scaleY + val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(ANIMATION_DURATION_MS) + animator.addUpdateListener { _ -> + val fraction = animator.animatedValue as Float + dropTargetView.alpha = startAlpha + (1 - startAlpha) * fraction + dropTargetView.translationX = startTx + (bounds.exactCenterX() - startTx) * fraction + dropTargetView.translationY = startTy + (bounds.exactCenterY() - startTy) * fraction + dropTargetView.scaleX = + startScaleX + (bounds.width().toFloat() - startScaleX) * fraction + dropTargetView.scaleY = + startScaleY + (bounds.height().toFloat() - startScaleY) * fraction + } + this.animator = animator + animator.start() + } + /** Stores the current drag state. */ private inner class DragState( private val dragZones: List<DragZone>, @@ -72,7 +157,18 @@ class DropTargetManager( interface DragZoneChangedListener { /** An initial drag zone was set. Called when a drag starts. */ fun onInitialDragZoneSet(dragZone: DragZone) + /** Called when the object was dragged to a different drag zone. */ fun onDragZoneChanged(from: DragZone, to: DragZone) } + + private fun Animator.doOnEnd(onEnd: () -> Unit) { + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + onEnd() + } + } + ) + } } diff --git a/libs/WindowManager/Shell/tests/unittest/Android.bp b/libs/WindowManager/Shell/tests/unittest/Android.bp index bf5e374c7607..bff12d026b93 100644 --- a/libs/WindowManager/Shell/tests/unittest/Android.bp +++ b/libs/WindowManager/Shell/tests/unittest/Android.bp @@ -45,6 +45,7 @@ android_test { "androidx.test.rules", "androidx.test.ext.junit", "androidx.datastore_datastore", + "androidx.core_core-animation-testing", "kotlinx_coroutines_test", "androidx.dynamicanimation_dynamicanimation", "dagger2", diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/shared/bubbles/DropTargetManagerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/shared/bubbles/DropTargetManagerTest.kt index efb91c5fbfda..180a6915b45f 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/shared/bubbles/DropTargetManagerTest.kt +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/shared/bubbles/DropTargetManagerTest.kt @@ -16,23 +16,33 @@ package com.android.wm.shell.shared.bubbles +import android.content.Context import android.graphics.Rect +import android.view.View +import android.widget.FrameLayout +import androidx.core.animation.AnimatorTestRule +import androidx.test.core.app.ApplicationProvider.getApplicationContext import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFails import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import kotlin.test.assertFails /** Unit tests for [DropTargetManager]. */ @SmallTest @RunWith(AndroidJUnit4::class) class DropTargetManagerTest { + @get:Rule val animatorTestRule = AnimatorTestRule() + + private val context = getApplicationContext<Context>() private lateinit var dropTargetManager: DropTargetManager private lateinit var dragZoneChangedListener: FakeDragZoneChangedListener - private val dropTarget = Rect(0, 0, 0, 0) + private lateinit var container: FrameLayout // create 3 drop zones that are horizontally next to each other // ------------------------------------------------- @@ -43,15 +53,20 @@ class DropTargetManagerTest { // | | | | // ------------------------------------------------- private val bubbleLeftDragZone = - DragZone.Bubble.Left(bounds = Rect(0, 0, 100, 100), dropTarget = dropTarget) + DragZone.Bubble.Left(bounds = Rect(0, 0, 100, 100), dropTarget = Rect(0, 0, 50, 200)) private val dismissDragZone = DragZone.Dismiss(bounds = Rect(100, 0, 200, 100)) private val bubbleRightDragZone = - DragZone.Bubble.Right(bounds = Rect(200, 0, 300, 100), dropTarget = dropTarget) + DragZone.Bubble.Right(bounds = Rect(200, 0, 300, 100), dropTarget = Rect(200, 0, 280, 150)) + + private val dropTargetView: View + get() = container.getChildAt(0) @Before fun setUp() { + container = FrameLayout(context) dragZoneChangedListener = FakeDragZoneChangedListener() - dropTargetManager = DropTargetManager(isLayoutRtl = false, dragZoneChangedListener) + dropTargetManager = + DropTargetManager(context, container, isLayoutRtl = false, dragZoneChangedListener) } @Test @@ -79,17 +94,21 @@ class DropTargetManagerTest { DraggedObject.Bubble(BubbleBarLocation.LEFT), listOf(bubbleLeftDragZone, bubbleRightDragZone, dismissDragZone) ) - dropTargetManager.onDragUpdated( - bubbleRightDragZone.bounds.centerX(), - bubbleRightDragZone.bounds.centerY() - ) + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragUpdated( + bubbleRightDragZone.bounds.centerX(), + bubbleRightDragZone.bounds.centerY() + ) + } assertThat(dragZoneChangedListener.fromDragZone).isEqualTo(bubbleLeftDragZone) assertThat(dragZoneChangedListener.toDragZone).isEqualTo(bubbleRightDragZone) - dropTargetManager.onDragUpdated( - dismissDragZone.bounds.centerX(), - dismissDragZone.bounds.centerY() - ) + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragUpdated( + dismissDragZone.bounds.centerX(), + dismissDragZone.bounds.centerY() + ) + } assertThat(dragZoneChangedListener.fromDragZone).isEqualTo(bubbleRightDragZone) assertThat(dragZoneChangedListener.toDragZone).isEqualTo(dismissDragZone) } @@ -100,10 +119,12 @@ class DropTargetManagerTest { DraggedObject.Bubble(BubbleBarLocation.LEFT), listOf(bubbleLeftDragZone, bubbleRightDragZone, dismissDragZone) ) - dropTargetManager.onDragUpdated( - bubbleLeftDragZone.bounds.centerX(), - bubbleLeftDragZone.bounds.centerY() - ) + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragUpdated( + bubbleLeftDragZone.bounds.centerX(), + bubbleLeftDragZone.bounds.centerY() + ) + } assertThat(dragZoneChangedListener.fromDragZone).isNull() assertThat(dragZoneChangedListener.toDragZone).isNull() } @@ -118,7 +139,9 @@ class DropTargetManagerTest { val pointY = 200 assertThat(bubbleLeftDragZone.contains(pointX, pointY)).isFalse() assertThat(bubbleRightDragZone.contains(pointX, pointY)).isFalse() - dropTargetManager.onDragUpdated(pointX, pointY) + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragUpdated(pointX, pointY) + } assertThat(dragZoneChangedListener.fromDragZone).isNull() assertThat(dragZoneChangedListener.toDragZone).isNull() } @@ -135,27 +158,30 @@ class DropTargetManagerTest { // drag to a point that is within both the bubble right zone and split zone val (pointX, pointY) = - Pair( - bubbleRightDragZone.bounds.centerX(), - bubbleRightDragZone.bounds.centerY() - ) + Pair(bubbleRightDragZone.bounds.centerX(), bubbleRightDragZone.bounds.centerY()) assertThat(splitDragZone.contains(pointX, pointY)).isTrue() - dropTargetManager.onDragUpdated(pointX, pointY) + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragUpdated(pointX, pointY) + } // verify we dragged to the bubble right zone because that has higher priority than split assertThat(dragZoneChangedListener.fromDragZone).isEqualTo(bubbleLeftDragZone) assertThat(dragZoneChangedListener.toDragZone).isEqualTo(bubbleRightDragZone) - dropTargetManager.onDragUpdated( - bubbleRightDragZone.bounds.centerX(), - 150 // below the bubble and dismiss drag zones but within split - ) + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragUpdated( + bubbleRightDragZone.bounds.centerX(), + 150 // below the bubble and dismiss drag zones but within split + ) + } assertThat(dragZoneChangedListener.fromDragZone).isEqualTo(bubbleRightDragZone) assertThat(dragZoneChangedListener.toDragZone).isEqualTo(splitDragZone) val (dismissPointX, dismissPointY) = Pair(dismissDragZone.bounds.centerX(), dismissDragZone.bounds.centerY()) assertThat(splitDragZone.contains(dismissPointX, dismissPointY)).isTrue() - dropTargetManager.onDragUpdated(dismissPointX, dismissPointY) + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragUpdated(dismissPointX, dismissPointY) + } assertThat(dragZoneChangedListener.fromDragZone).isEqualTo(splitDragZone) assertThat(dragZoneChangedListener.toDragZone).isEqualTo(dismissDragZone) } @@ -166,7 +192,9 @@ class DropTargetManagerTest { DraggedObject.Bubble(BubbleBarLocation.LEFT), listOf(bubbleLeftDragZone, bubbleRightDragZone, dismissDragZone) ) - dropTargetManager.onDragEnded() + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragEnded() + } dropTargetManager.onDragUpdated( bubbleRightDragZone.bounds.centerX(), bubbleRightDragZone.bounds.centerY() @@ -175,6 +203,129 @@ class DropTargetManagerTest { assertThat(dragZoneChangedListener.toDragZone).isNull() } + @Test + fun onDragStarted_dropTargetAddedToContainer() { + dropTargetManager.onDragStarted( + DraggedObject.Bubble(BubbleBarLocation.LEFT), + listOf(bubbleLeftDragZone, bubbleRightDragZone) + ) + assertThat(container.childCount).isEqualTo(1) + assertThat(dropTargetView.alpha).isEqualTo(0) + } + + @Test + fun onDragEnded_dropTargetRemovedFromContainer() { + dropTargetManager.onDragStarted( + DraggedObject.Bubble(BubbleBarLocation.LEFT), + listOf(bubbleLeftDragZone, bubbleRightDragZone) + ) + assertThat(container.childCount).isEqualTo(1) + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragEnded() + animatorTestRule.advanceTimeBy(250) + } + assertThat(container.childCount).isEqualTo(0) + } + + @Test + fun startNewDrag_beforeDropTargetRemoved() { + dropTargetManager.onDragStarted( + DraggedObject.Bubble(BubbleBarLocation.LEFT), + listOf(bubbleLeftDragZone, bubbleRightDragZone) + ) + assertThat(container.childCount).isEqualTo(1) + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragEnded() + // advance the timer by 100ms so the animation doesn't complete + animatorTestRule.advanceTimeBy(100) + } + assertThat(container.childCount).isEqualTo(1) + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragStarted( + DraggedObject.Bubble(BubbleBarLocation.LEFT), + listOf(bubbleLeftDragZone, bubbleRightDragZone) + ) + } + assertThat(container.childCount).isEqualTo(1) + } + + @Test + fun updateDragZone_withDropTarget_dropTargetUpdated() { + dropTargetManager.onDragStarted( + DraggedObject.Bubble(BubbleBarLocation.LEFT), + listOf(dismissDragZone, bubbleLeftDragZone, bubbleRightDragZone) + ) + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragUpdated( + bubbleRightDragZone.bounds.centerX(), + bubbleRightDragZone.bounds.centerY() + ) + animatorTestRule.advanceTimeBy(250) + } + + assertThat(dropTargetView.alpha).isEqualTo(1) + verifyDropTargetPosition(bubbleRightDragZone.dropTarget) + } + + @Test + fun updateDragZone_withoutDropTarget_dropTargetHidden() { + dropTargetManager.onDragStarted( + DraggedObject.Bubble(BubbleBarLocation.LEFT), + listOf(dismissDragZone, bubbleLeftDragZone, bubbleRightDragZone) + ) + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragUpdated( + dismissDragZone.bounds.centerX(), + dismissDragZone.bounds.centerY() + ) + animatorTestRule.advanceTimeBy(250) + } + + assertThat(dropTargetView.alpha).isEqualTo(0) + } + + @Test + fun updateDragZone_betweenZonesWithDropTarget_dropTargetUpdated() { + dropTargetManager.onDragStarted( + DraggedObject.Bubble(BubbleBarLocation.LEFT), + listOf(dismissDragZone, bubbleLeftDragZone, bubbleRightDragZone) + ) + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragUpdated( + bubbleRightDragZone.bounds.centerX(), + bubbleRightDragZone.bounds.centerY() + ) + animatorTestRule.advanceTimeBy(250) + } + + assertThat(dropTargetView.alpha).isEqualTo(1) + verifyDropTargetPosition(bubbleRightDragZone.dropTarget) + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dropTargetManager.onDragUpdated( + bubbleLeftDragZone.bounds.centerX(), + bubbleLeftDragZone.bounds.centerY() + ) + animatorTestRule.advanceTimeBy(250) + } + + assertThat(dropTargetView.alpha).isEqualTo(1) + verifyDropTargetPosition(bubbleLeftDragZone.dropTarget) + } + + private fun verifyDropTargetPosition(rect: Rect) { + assertThat(dropTargetView.scaleX).isEqualTo(rect.width()) + assertThat(dropTargetView.scaleY).isEqualTo(rect.height()) + assertThat(dropTargetView.translationX).isEqualTo(rect.exactCenterX()) + assertThat(dropTargetView.translationY).isEqualTo(rect.exactCenterY()) + } + private class FakeDragZoneChangedListener : DropTargetManager.DragZoneChangedListener { var initialDragZone: DragZone? = null var fromDragZone: DragZone? = null @@ -183,6 +334,7 @@ class DropTargetManagerTest { override fun onInitialDragZoneSet(dragZone: DragZone) { initialDragZone = dragZone } + override fun onDragZoneChanged(from: DragZone, to: DragZone) { fromDragZone = from toDragZone = to |