Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit a34b08cc authored by Liran Binyamin's avatar Liran Binyamin
Browse files

Create a drop target view in DropTargetManager

This change adds a drop target view in DropTargetManager and updates
it based on changes to drag zones.

Bug: 393173014
Test: atest DropTargetManagerTest
Flag: EXEMPT not wired
Change-Id: I201a801a22690882124466103586e84d62b26cc5
parent 10631bff
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -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>
+18 −6
Original line number Diff line number Diff line
@@ -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)
    }
}
+97 −1
Original line number Diff line number Diff line
@@ -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()
                }
            }
        )
    }
}
+1 −0
Original line number Diff line number Diff line
@@ -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",
+181 −29
Original line number Diff line number Diff line
@@ -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)
        )
        InstrumentationRegistry.getInstrumentation().runOnMainSync {
            dropTargetManager.onDragUpdated(
                bubbleRightDragZone.bounds.centerX(),
                bubbleRightDragZone.bounds.centerY()
            )
        }
        assertThat(dragZoneChangedListener.fromDragZone).isEqualTo(bubbleLeftDragZone)
        assertThat(dragZoneChangedListener.toDragZone).isEqualTo(bubbleRightDragZone)

        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)
        )
        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()
        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()
        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)

        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()
        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)
        )
        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