Add unfold animation to launcher icons and widgets

Adds unfold animation to launcher which translates
icons and widgets from the center to the sides on
foldable devices.

Bug: 193794541
Test: manual
Change-Id: I35ca08d0fa45d09e9104f4324f9879206e806caf
diff --git a/packages/SystemUI/shared/src/com/android/systemui/shared/animation/UnfoldMoveFromCenterAnimator.kt b/packages/SystemUI/shared/src/com/android/systemui/shared/animation/UnfoldMoveFromCenterAnimator.kt
new file mode 100644
index 0000000..4bb4eb9
--- /dev/null
+++ b/packages/SystemUI/shared/src/com/android/systemui/shared/animation/UnfoldMoveFromCenterAnimator.kt
@@ -0,0 +1,146 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.systemui.shared.animation
+
+import android.graphics.Point
+import android.util.MathUtils.lerp
+import android.view.Surface
+import android.view.View
+import android.view.WindowManager
+import com.android.unfold.UnfoldTransitionProgressProvider
+import java.lang.ref.WeakReference
+
+/**
+ * Creates an animation where all registered views are moved into their final location
+ * by moving from the center of the screen to the sides
+ */
+class UnfoldMoveFromCenterAnimator(
+    private val windowManager: WindowManager,
+    /**
+     * Allows to set custom translation applier
+     * Could be useful when a view could be translated from
+     * several sources and we want to set the translation
+     * using custom methods instead of [View.setTranslationX] or
+     * [View.setTranslationY]
+     */
+    var translationApplier: TranslationApplier = object : TranslationApplier {}
+) : UnfoldTransitionProgressProvider.TransitionProgressListener {
+
+    private val screenSize = Point()
+    private var isVerticalFold = false
+
+    private val animatedViews: MutableList<AnimatedView> = arrayListOf()
+    private val tmpArray = IntArray(2)
+
+    /**
+     * Updates display properties in order to calculate the initial position for the views
+     * Must be called before [registerViewForAnimation]
+     */
+    fun updateDisplayProperties() {
+        windowManager.defaultDisplay.getSize(screenSize)
+
+        // Simple implementation to get current fold orientation,
+        // this might not be correct on all devices
+        // TODO: use JetPack WindowManager library to get the fold orientation
+        isVerticalFold = windowManager.defaultDisplay.rotation == Surface.ROTATION_0 ||
+            windowManager.defaultDisplay.rotation == Surface.ROTATION_180
+    }
+
+    /**
+     * Registers a view to be animated, the view should be measured and layouted
+     * After finishing the animation it is necessary to clear
+     * the views using [clearRegisteredViews]
+     */
+    fun registerViewForAnimation(view: View) {
+        val animatedView = createAnimatedView(view)
+        animatedViews.add(animatedView)
+    }
+
+    /**
+     * Unregisters all registered views and resets their translation
+     */
+    fun clearRegisteredViews() {
+        onTransitionProgress(1f)
+        animatedViews.clear()
+    }
+
+    override fun onTransitionProgress(progress: Float) {
+        animatedViews.forEach {
+            it.view.get()?.let { view ->
+                translationApplier.apply(
+                    view = view,
+                    x = lerp(it.startTranslationX, it.finishTranslationX, progress),
+                    y = lerp(it.startTranslationY, it.finishTranslationY, progress)
+                )
+            }
+        }
+    }
+
+    private fun createAnimatedView(view: View): AnimatedView {
+        val viewLocation = tmpArray
+        view.getLocationOnScreen(viewLocation)
+
+        val viewX = viewLocation[0].toFloat()
+        val viewY = viewLocation[1].toFloat()
+
+        val viewCenterX = viewX + view.width / 2
+        val viewCenterY = viewY + view.height / 2
+
+        val translationXDiff: Float
+        val translationYDiff: Float
+
+        if (isVerticalFold) {
+            val distanceFromScreenCenterToViewCenter = screenSize.x / 2 - viewCenterX
+            translationXDiff = distanceFromScreenCenterToViewCenter * TRANSLATION_PERCENTAGE
+            translationYDiff = 0f
+        } else {
+            val distanceFromScreenCenterToViewCenter = screenSize.y / 2 - viewCenterY
+            translationXDiff = 0f
+            translationYDiff = distanceFromScreenCenterToViewCenter * TRANSLATION_PERCENTAGE
+        }
+
+        return AnimatedView(
+            view = WeakReference(view),
+            startTranslationX = view.translationX + translationXDiff,
+            startTranslationY = view.translationY + translationYDiff,
+            finishTranslationX = view.translationX,
+            finishTranslationY = view.translationY
+        )
+    }
+
+    /**
+     * Interface that allows to use custom logic to apply translation to view
+     */
+    interface TranslationApplier {
+        /**
+         * Called when we need to apply [x] and [y] translation to [view]
+         */
+        fun apply(view: View, x: Float, y: Float) {
+            view.translationX = x
+            view.translationY = y
+        }
+    }
+
+    private class AnimatedView(
+        val view: WeakReference<View>,
+        val startTranslationX: Float,
+        val startTranslationY: Float,
+        val finishTranslationX: Float,
+        val finishTranslationY: Float
+    )
+}
+
+private const val TRANSLATION_PERCENTAGE = 0.3f
diff --git a/packages/SystemUI/shared/src/com/android/systemui/shared/recents/IOverviewProxy.aidl b/packages/SystemUI/shared/src/com/android/systemui/shared/recents/IOverviewProxy.aidl
index de9558e..8bd0f91 100644
--- a/packages/SystemUI/shared/src/com/android/systemui/shared/recents/IOverviewProxy.aidl
+++ b/packages/SystemUI/shared/src/com/android/systemui/shared/recents/IOverviewProxy.aidl
@@ -90,4 +90,9 @@
      * Sent when behavior changes. See WindowInsetsController#@Behavior
      */
     void onSystemBarAttributesChanged(int displayId, int behavior) = 20;
+
+    /**
+     * Sent when screen turned on and ready to use (blocker scrim is hidden)
+     */
+    void onScreenTurnedOn() = 21;
 }
diff --git a/packages/SystemUI/shared/src/com/android/unfold/UnfoldTransitionProgressProvider.kt b/packages/SystemUI/shared/src/com/android/unfold/UnfoldTransitionProgressProvider.kt
index 2ddb49c..4a6a9ac 100644
--- a/packages/SystemUI/shared/src/com/android/unfold/UnfoldTransitionProgressProvider.kt
+++ b/packages/SystemUI/shared/src/com/android/unfold/UnfoldTransitionProgressProvider.kt
@@ -31,8 +31,8 @@
     fun destroy()
 
     interface TransitionProgressListener {
-        fun onTransitionStarted()
-        fun onTransitionFinished()
-        fun onTransitionProgress(@FloatRange(from = 0.0, to = 1.0) progress: Float)
+        fun onTransitionStarted() {}
+        fun onTransitionFinished() {}
+        fun onTransitionProgress(@FloatRange(from = 0.0, to = 1.0) progress: Float) {}
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/recents/OverviewProxyService.java b/packages/SystemUI/src/com/android/systemui/recents/OverviewProxyService.java
index eb72296..a5fc5ab 100644
--- a/packages/SystemUI/src/com/android/systemui/recents/OverviewProxyService.java
+++ b/packages/SystemUI/src/com/android/systemui/recents/OverviewProxyService.java
@@ -78,6 +78,7 @@
 import com.android.systemui.Dumpable;
 import com.android.systemui.broadcast.BroadcastDispatcher;
 import com.android.systemui.dagger.SysUISingleton;
+import com.android.systemui.keyguard.ScreenLifecycle;
 import com.android.systemui.model.SysUiState;
 import com.android.systemui.navigationbar.NavigationBar;
 import com.android.systemui.navigationbar.NavigationBarController;
@@ -530,6 +531,7 @@
             Optional<OneHanded> oneHandedOptional,
             BroadcastDispatcher broadcastDispatcher,
             ShellTransitions shellTransitions,
+            ScreenLifecycle screenLifecycle,
             Optional<StartingSurface> startingSurface,
             SmartspaceTransitionController smartspaceTransitionController) {
         super(broadcastDispatcher);
@@ -588,6 +590,13 @@
         // Listen for user setup
         startTracking();
 
+        screenLifecycle.addObserver(new ScreenLifecycle.Observer() {
+            @Override
+            public void onScreenTurnedOn() {
+                notifyScreenTurnedOn();
+            }
+        });
+
         // Connect to the service
         updateEnabledState();
         startConnectionToCurrentUser();
@@ -880,6 +889,21 @@
         }
     }
 
+    /**
+     * Notifies the Launcher that screen turned on and ready to use
+     */
+    public void notifyScreenTurnedOn() {
+        try {
+            if (mOverviewProxy != null) {
+                mOverviewProxy.onScreenTurnedOn();
+            } else {
+                Log.e(TAG_OPS, "Failed to get overview proxy for screen turned on event.");
+            }
+        } catch (RemoteException e) {
+            Log.e(TAG_OPS, "Failed to call notifyScreenTurnedOn()", e);
+        }
+    }
+
     void notifyToggleRecentApps() {
         for (int i = mConnectionCallbacks.size() - 1; i >= 0; --i) {
             mConnectionCallbacks.get(i).onToggleRecentApps();
diff --git a/packages/SystemUI/tests/src/com/android/systemui/shared/animation/UnfoldMoveFromCenterAnimatorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shared/animation/UnfoldMoveFromCenterAnimatorTest.kt
new file mode 100644
index 0000000..a9c6a53
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/shared/animation/UnfoldMoveFromCenterAnimatorTest.kt
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package com.android.systemui.shared.animation
+
+import android.graphics.Point
+import android.test.suitebuilder.annotation.SmallTest
+import android.testing.AndroidTestingRunner
+import android.view.Display
+import android.view.Surface.ROTATION_0
+import android.view.Surface.ROTATION_90
+import android.view.View
+import android.view.WindowManager
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.util.mockito.any
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.spy
+import org.mockito.junit.MockitoJUnit
+import org.mockito.Mockito.`when` as whenever
+
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+class UnfoldMoveFromCenterAnimatorTest : SysuiTestCase() {
+
+    @Mock
+    private lateinit var windowManager: WindowManager
+
+    @get:Rule
+    val mockito = MockitoJUnit.rule()
+
+    private lateinit var animator: UnfoldMoveFromCenterAnimator
+
+    @Before
+    fun before() {
+        animator = UnfoldMoveFromCenterAnimator(windowManager)
+    }
+
+    @Test
+    fun testRegisterViewOnTheLeftOfVerticalFold_halfProgress_viewTranslatedToTheRight() {
+        givenScreen(width = 100, height = 100, rotation = ROTATION_0)
+        val view = createView(x = 20)
+        animator.registerViewForAnimation(view)
+        animator.onTransitionStarted()
+
+        animator.onTransitionProgress(0.5f)
+
+        // Positive translationX -> translated to the right
+        assertThat(view.translationX).isWithin(0.1f).of(3.75f)
+    }
+
+    @Test
+    fun testRegisterViewOnTheLeftOfVerticalFold_zeroProgress_viewTranslatedToTheRight() {
+        givenScreen(width = 100, height = 100, rotation = ROTATION_0)
+        val view = createView(x = 20)
+        animator.registerViewForAnimation(view)
+        animator.onTransitionStarted()
+
+        animator.onTransitionProgress(0f)
+
+        // Positive translationX -> translated to the right
+        assertThat(view.translationX).isWithin(0.1f).of(7.5f)
+    }
+
+    @Test
+    fun testRegisterViewOnTheLeftOfVerticalFold_fullProgress_viewTranslatedToTheOriginalPosition() {
+        givenScreen(width = 100, height = 100, rotation = ROTATION_0)
+        val view = createView(x = 20)
+        animator.registerViewForAnimation(view)
+        animator.onTransitionStarted()
+
+        animator.onTransitionProgress(1f)
+
+        assertThat(view.translationX).isEqualTo(0f)
+    }
+
+    @Test
+    fun testRegisterViewAndUnregister_halfProgress_viewIsNotUpdated() {
+        givenScreen(width = 100, height = 100, rotation = ROTATION_0)
+        val view = createView(x = 20)
+        animator.registerViewForAnimation(view)
+        animator.onTransitionStarted()
+        animator.clearRegisteredViews()
+
+        animator.onTransitionProgress(0.5f)
+
+        assertThat(view.translationX).isEqualTo(0f)
+    }
+
+    @Test
+    fun testRegisterViewUpdateProgressAndUnregister_halfProgress_viewIsNotUpdated() {
+        givenScreen(width = 100, height = 100, rotation = ROTATION_0)
+        val view = createView(x = 20)
+        animator.registerViewForAnimation(view)
+        animator.onTransitionStarted()
+        animator.onTransitionProgress(0.2f)
+        animator.clearRegisteredViews()
+
+        animator.onTransitionProgress(0.5f)
+
+        assertThat(view.translationX).isEqualTo(0f)
+    }
+
+    @Test
+    fun testRegisterViewOnTheTopOfHorizontalFold_halfProgress_viewTranslatedToTheBottom() {
+        givenScreen(width = 100, height = 100, rotation = ROTATION_90)
+        val view = createView(y = 20)
+        animator.registerViewForAnimation(view)
+        animator.onTransitionStarted()
+
+        animator.onTransitionProgress(0.5f)
+
+        // Positive translationY -> translated to the bottom
+        assertThat(view.translationY).isWithin(0.1f).of(3.75f)
+    }
+
+    private fun createView(
+        x: Int = 0,
+        y: Int = 0,
+        width: Int = 10,
+        height: Int = 10,
+        translationX: Float = 0f,
+        translationY: Float = 0f
+    ): View {
+        val view = spy(View(context))
+        doAnswer {
+            val location = (it.arguments[0] as IntArray)
+            location[0] = x
+            location[1] = y
+            Unit
+        }.`when`(view).getLocationOnScreen(any())
+
+        whenever(view.width).thenReturn(width)
+        whenever(view.height).thenReturn(height)
+
+        return view.apply {
+            setTranslationX(translationX)
+            setTranslationY(translationY)
+        }
+    }
+
+    private fun givenScreen(width: Int = 100,
+                            height: Int = 100,
+                            rotation: Int = ROTATION_0) {
+        val display = mock(Display::class.java)
+        whenever(display.getSize(any())).thenAnswer {
+            val size = (it.arguments[0] as Point)
+            size.set(width, height)
+            Unit
+        }
+        whenever(display.rotation).thenReturn(rotation)
+        whenever(windowManager.defaultDisplay).thenReturn(display)
+
+        animator.updateDisplayProperties()
+    }
+}