Merge "Add ringer buttons animation" into main
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModelTest.kt
index 1e6e52a..d8184db 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModelTest.kt
@@ -83,7 +83,7 @@
 
             assertThat(ringerViewModel).isInstanceOf(RingerViewModelState.Available::class.java)
             assertThat((ringerViewModel as RingerViewModelState.Available).uiModel.drawerState)
-                .isEqualTo(RingerDrawerState.Closed(normalRingerMode))
+                .isEqualTo(RingerDrawerState.Closed(normalRingerMode, normalRingerMode))
         }
 
     @Test
@@ -91,8 +91,9 @@
         testScope.runTest {
             val ringerViewModel by collectLastValue(underTest.ringerViewModel)
             val vibrateRingerMode = RingerMode(RINGER_MODE_VIBRATE)
+            val normalRingerMode = RingerMode(RINGER_MODE_NORMAL)
 
-            setUpRingerModeAndOpenDrawer(RingerMode(RINGER_MODE_NORMAL))
+            setUpRingerModeAndOpenDrawer(normalRingerMode)
             // Select vibrate ringer mode.
             underTest.onRingerButtonClicked(vibrateRingerMode)
             controller.getState()
@@ -103,7 +104,8 @@
             var uiModel = (ringerViewModel as RingerViewModelState.Available).uiModel
             assertThat(uiModel.availableButtons[uiModel.currentButtonIndex]?.ringerMode)
                 .isEqualTo(vibrateRingerMode)
-            assertThat(uiModel.drawerState).isEqualTo(RingerDrawerState.Closed(vibrateRingerMode))
+            assertThat(uiModel.drawerState)
+                .isEqualTo(RingerDrawerState.Closed(vibrateRingerMode, normalRingerMode))
 
             val silentRingerMode = RingerMode(RINGER_MODE_SILENT)
             // Open drawer
@@ -120,7 +122,8 @@
             uiModel = (ringerViewModel as RingerViewModelState.Available).uiModel
             assertThat(uiModel.availableButtons[uiModel.currentButtonIndex]?.ringerMode)
                 .isEqualTo(silentRingerMode)
-            assertThat(uiModel.drawerState).isEqualTo(RingerDrawerState.Closed(silentRingerMode))
+            assertThat(uiModel.drawerState)
+                .isEqualTo(RingerDrawerState.Closed(silentRingerMode, vibrateRingerMode))
             assertThat(controller.hasScheduledTouchFeedback).isFalse()
             assertThat(vibratorHelper.totalVibrations).isEqualTo(2)
         }
diff --git a/packages/SystemUI/res/xml/volume_dialog_ringer_drawer_motion_scene.xml b/packages/SystemUI/res/xml/volume_dialog_ringer_drawer_motion_scene.xml
index 877637e..20c6ab7 100644
--- a/packages/SystemUI/res/xml/volume_dialog_ringer_drawer_motion_scene.xml
+++ b/packages/SystemUI/res/xml/volume_dialog_ringer_drawer_motion_scene.xml
@@ -17,7 +17,7 @@
 <MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto">
     <Transition
-        android:id="@+id/transition"
+        android:id="@+id/close_to_open_transition"
         app:constraintSetEnd="@+id/volume_dialog_ringer_drawer_open"
         app:constraintSetStart="@+id/volume_dialog_ringer_drawer_close"
         app:transitionEasing="path(0.05f, 0.7f, 0.1f, 1f)"
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/binder/VolumeDialogRingerViewBinder.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/binder/VolumeDialogRingerViewBinder.kt
index 1963ba2..82ac056 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/binder/VolumeDialogRingerViewBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/binder/VolumeDialogRingerViewBinder.kt
@@ -16,6 +16,8 @@
 
 package com.android.systemui.volume.dialog.ringer.ui.binder
 
+import android.animation.ArgbEvaluator
+import android.graphics.drawable.GradientDrawable
 import android.view.LayoutInflater
 import android.view.View
 import android.widget.ImageButton
@@ -23,6 +25,10 @@
 import androidx.compose.ui.util.fastForEachIndexed
 import androidx.constraintlayout.motion.widget.MotionLayout
 import androidx.constraintlayout.widget.ConstraintSet
+import androidx.dynamicanimation.animation.DynamicAnimation
+import androidx.dynamicanimation.animation.FloatValueHolder
+import androidx.dynamicanimation.animation.SpringAnimation
+import androidx.dynamicanimation.animation.SpringForce
 import com.android.internal.R as internalR
 import com.android.settingslib.Utils
 import com.android.systemui.lifecycle.WindowLifecycleState
@@ -31,24 +37,44 @@
 import com.android.systemui.res.R
 import com.android.systemui.util.children
 import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogScope
+import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerButtonUiModel
 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerButtonViewModel
 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerDrawerState
 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerViewModel
 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerViewModelState
 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.VolumeDialogRingerDrawerViewModel
+import com.android.systemui.volume.dialog.ui.utils.suspendAnimate
 import javax.inject.Inject
+import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+private const val CLOSE_DRAWER_DELAY = 300L
 
 @VolumeDialogScope
 class VolumeDialogRingerViewBinder
 @Inject
 constructor(private val viewModelFactory: VolumeDialogRingerDrawerViewModel.Factory) {
+    private val roundnessSpringForce =
+        SpringForce(0F).apply {
+            stiffness = 800F
+            dampingRatio = 0.6F
+        }
+    private val colorSpringForce =
+        SpringForce(0F).apply {
+            stiffness = 3800F
+            dampingRatio = 1F
+        }
+    private val rgbEvaluator = ArgbEvaluator()
 
     fun bind(view: View) {
         with(view) {
             val volumeDialogBackgroundView = requireViewById<View>(R.id.volume_dialog_background)
             val drawerContainer = requireViewById<MotionLayout>(R.id.volume_ringer_drawer)
+            val unselectedButtonUiModel = RingerButtonUiModel.getUnselectedButton(context)
+            val selectedButtonUiModel = RingerButtonUiModel.getSelectedButton(context)
+
             repeatWhenAttached {
                 viewModel(
                     traceName = "VolumeDialogRingerViewBinder",
@@ -61,26 +87,53 @@
                                 is RingerViewModelState.Available -> {
                                     val uiModel = ringerState.uiModel
 
-                                    bindDrawerButtons(viewModel, uiModel)
-
                                     // Set up view background and visibility
                                     drawerContainer.visibility = View.VISIBLE
                                     when (uiModel.drawerState) {
                                         is RingerDrawerState.Initial -> {
+                                            drawerContainer.animateAndBindDrawerButtons(
+                                                viewModel,
+                                                uiModel,
+                                                selectedButtonUiModel,
+                                                unselectedButtonUiModel,
+                                            )
                                             drawerContainer.closeDrawer(uiModel.currentButtonIndex)
                                             volumeDialogBackgroundView.setBackgroundResource(
                                                 R.drawable.volume_dialog_background
                                             )
                                         }
                                         is RingerDrawerState.Closed -> {
-                                            drawerContainer.closeDrawer(uiModel.currentButtonIndex)
-                                            volumeDialogBackgroundView.setBackgroundResource(
-                                                R.drawable.volume_dialog_background
-                                            )
+                                            if (
+                                                uiModel.selectedButton.ringerMode ==
+                                                    uiModel.drawerState.currentMode
+                                            ) {
+                                                drawerContainer.animateAndBindDrawerButtons(
+                                                    viewModel,
+                                                    uiModel,
+                                                    selectedButtonUiModel,
+                                                    unselectedButtonUiModel,
+                                                ) {
+                                                    drawerContainer.closeDrawer(
+                                                        uiModel.currentButtonIndex
+                                                    )
+                                                    volumeDialogBackgroundView
+                                                        .setBackgroundResource(
+                                                            R.drawable.volume_dialog_background
+                                                        )
+                                                }
+                                            }
                                         }
                                         is RingerDrawerState.Open -> {
+                                            drawerContainer.animateAndBindDrawerButtons(
+                                                viewModel,
+                                                uiModel,
+                                                selectedButtonUiModel,
+                                                unselectedButtonUiModel,
+                                            )
                                             // Open drawer
-                                            drawerContainer.transitionToEnd()
+                                            drawerContainer.transitionToState(
+                                                R.id.volume_dialog_ringer_drawer_open
+                                            )
                                             if (
                                                 uiModel.currentButtonIndex !=
                                                     uiModel.availableButtons.size - 1
@@ -106,45 +159,93 @@
         }
     }
 
-    private fun View.bindDrawerButtons(
+    private suspend fun MotionLayout.animateAndBindDrawerButtons(
         viewModel: VolumeDialogRingerDrawerViewModel,
         uiModel: RingerViewModel,
+        selectedButtonUiModel: RingerButtonUiModel,
+        unselectedButtonUiModel: RingerButtonUiModel,
+        onAnimationEnd: Runnable? = null,
     ) {
-        val drawerContainer = requireViewById<MotionLayout>(R.id.volume_ringer_drawer)
-        val count = uiModel.availableButtons.size
-        drawerContainer.ensureChildCount(R.layout.volume_ringer_button, count)
+        ensureChildCount(R.layout.volume_ringer_button, uiModel.availableButtons.size)
+        if (
+            uiModel.drawerState is RingerDrawerState.Closed &&
+                uiModel.drawerState.currentMode != uiModel.drawerState.previousMode
+        ) {
+            val count = uiModel.availableButtons.size
+            val selectedButton =
+                getChildAt(count - uiModel.currentButtonIndex - 1)
+                    .requireViewById<ImageButton>(R.id.volume_drawer_button)
+            val previousIndex =
+                uiModel.availableButtons.indexOfFirst {
+                    it?.ringerMode == uiModel.drawerState.previousMode
+                }
+            val unselectedButton =
+                getChildAt(count - previousIndex - 1)
+                    .requireViewById<ImageButton>(R.id.volume_drawer_button)
 
+            // On roundness animation end.
+            val roundnessAnimationEndListener =
+                DynamicAnimation.OnAnimationEndListener { _, _, _, _ ->
+                    postDelayed(
+                        { bindButtons(viewModel, uiModel, onAnimationEnd, isAnimated = true) },
+                        CLOSE_DRAWER_DELAY,
+                    )
+                }
+
+            // We only need to execute on roundness animation end once.
+            selectedButton.animateTo(selectedButtonUiModel, roundnessAnimationEndListener)
+            unselectedButton.animateTo(unselectedButtonUiModel)
+        } else {
+            bindButtons(viewModel, uiModel, onAnimationEnd)
+        }
+    }
+
+    private fun MotionLayout.bindButtons(
+        viewModel: VolumeDialogRingerDrawerViewModel,
+        uiModel: RingerViewModel,
+        onAnimationEnd: Runnable? = null,
+        isAnimated: Boolean = false,
+    ) {
+        val count = uiModel.availableButtons.size
         uiModel.availableButtons.fastForEachIndexed { index, ringerButton ->
             ringerButton?.let {
-                val view = drawerContainer.getChildAt(count - index - 1)
-                // TODO (b/369995871): object animator for button switch ( active <-> inactive )
+                val view = getChildAt(count - index - 1)
                 if (index == uiModel.currentButtonIndex) {
-                    view.bindDrawerButton(uiModel.selectedButton, viewModel, isSelected = true)
+                    view.bindDrawerButton(
+                        uiModel.selectedButton,
+                        viewModel,
+                        isSelected = true,
+                        isAnimated = isAnimated,
+                    )
                 } else {
-                    view.bindDrawerButton(it, viewModel)
+                    view.bindDrawerButton(it, viewModel, isAnimated)
                 }
             }
         }
+        onAnimationEnd?.run()
     }
 
     private fun View.bindDrawerButton(
         buttonViewModel: RingerButtonViewModel,
         viewModel: VolumeDialogRingerDrawerViewModel,
         isSelected: Boolean = false,
+        isAnimated: Boolean = false,
     ) {
         with(requireViewById<ImageButton>(R.id.volume_drawer_button)) {
             setImageResource(buttonViewModel.imageResId)
             contentDescription = context.getString(buttonViewModel.contentDescriptionResId)
-            if (isSelected) {
+            if (isSelected && !isAnimated) {
                 setBackgroundResource(R.drawable.volume_drawer_selection_bg)
                 setColorFilter(
                     Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorOnPrimary)
                 )
-            } else {
+                background = background.mutate()
+            } else if (!isAnimated) {
                 setBackgroundResource(R.drawable.volume_ringer_item_bg)
                 setColorFilter(
                     Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorOnSurface)
                 )
+                background = background.mutate()
             }
             setOnClickListener {
                 viewModel.onRingerButtonClicked(buttonViewModel.ringerMode, isSelected)
@@ -171,9 +272,10 @@
     }
 
     private fun MotionLayout.closeDrawer(selectedIndex: Int) {
+        setTransition(R.id.close_to_open_transition)
         cloneConstraintSet(R.id.volume_dialog_ringer_drawer_close)
             .adjustClosedConstraintsForDrawer(selectedIndex, this)
-        transitionToStart()
+        transitionToState(R.id.volume_dialog_ringer_drawer_close)
     }
 
     private fun ConstraintSet.adjustOpenConstraintsForDrawer(motionLayout: MotionLayout) {
@@ -263,4 +365,47 @@
         connect(button.id, ConstraintSet.START, motionLayout.id, ConstraintSet.START)
         connect(button.id, ConstraintSet.END, motionLayout.id, ConstraintSet.END)
     }
+
+    private suspend fun ImageButton.animateTo(
+        ringerButtonUiModel: RingerButtonUiModel,
+        roundnessAnimationEndListener: DynamicAnimation.OnAnimationEndListener? = null,
+    ) {
+        val roundnessAnimation =
+            SpringAnimation(FloatValueHolder(0F)).setSpring(roundnessSpringForce)
+        val colorAnimation = SpringAnimation(FloatValueHolder(0F)).setSpring(colorSpringForce)
+        val radius = (background as GradientDrawable).cornerRadius
+        val cornerRadiusDiff =
+            ringerButtonUiModel.cornerRadius - (background as GradientDrawable).cornerRadius
+        val roundnessAnimationUpdateListener =
+            DynamicAnimation.OnAnimationUpdateListener { _, value, _ ->
+                (background as GradientDrawable).cornerRadius = radius + value * cornerRadiusDiff
+                background.invalidateSelf()
+            }
+        val colorAnimationUpdateListener =
+            DynamicAnimation.OnAnimationUpdateListener { _, value, _ ->
+                val currentIconColor =
+                    rgbEvaluator.evaluate(
+                        value.coerceIn(0F, 1F),
+                        imageTintList?.colors?.first(),
+                        ringerButtonUiModel.tintColor,
+                    ) as Int
+                val currentBgColor =
+                    rgbEvaluator.evaluate(
+                        value.coerceIn(0F, 1F),
+                        (background as GradientDrawable).color?.colors?.get(0),
+                        ringerButtonUiModel.backgroundColor,
+                    ) as Int
+
+                (background as GradientDrawable).setColor(currentBgColor)
+                background.invalidateSelf()
+                setColorFilter(currentIconColor)
+            }
+        coroutineScope {
+            launch { colorAnimation.suspendAnimate(colorAnimationUpdateListener) }
+            roundnessAnimation.suspendAnimate(
+                roundnessAnimationUpdateListener,
+                roundnessAnimationEndListener,
+            )
+        }
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerButtonUiModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerButtonUiModel.kt
new file mode 100644
index 0000000..3c46567
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerButtonUiModel.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2024 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.volume.dialog.ringer.ui.viewmodel
+
+import android.content.Context
+import com.android.internal.R as internalR
+import com.android.settingslib.Utils
+import com.android.systemui.res.R
+
+/** Models the UI state of ringer button */
+data class RingerButtonUiModel(
+    /** Icon color. */
+    val tintColor: Int,
+    val backgroundColor: Int,
+    val cornerRadius: Int,
+) {
+    companion object {
+        fun getUnselectedButton(context: Context): RingerButtonUiModel {
+            return RingerButtonUiModel(
+                tintColor =
+                    Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorOnSurface),
+                backgroundColor =
+                    Utils.getColorAttrDefaultColor(
+                        context,
+                        internalR.attr.materialColorSurfaceContainerHighest,
+                    ),
+                cornerRadius =
+                    context.resources.getDimensionPixelSize(
+                        R.dimen.volume_dialog_background_square_corner_radius
+                    ),
+            )
+        }
+
+        fun getSelectedButton(context: Context): RingerButtonUiModel {
+            return RingerButtonUiModel(
+                tintColor =
+                    Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorOnPrimary),
+                backgroundColor =
+                    Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorPrimary),
+                cornerRadius =
+                    context.resources.getDimensionPixelSize(
+                        R.dimen.volume_dialog_ringer_selected_button_background_radius
+                    ),
+            )
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerDrawerState.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerDrawerState.kt
index f321837..afb3f68 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerDrawerState.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerDrawerState.kt
@@ -25,7 +25,8 @@
     data class Open(val mode: RingerMode) : RingerDrawerState
 
     /** When clicked to close drawer */
-    data class Closed(val mode: RingerMode) : RingerDrawerState
+    data class Closed(val currentMode: RingerMode, val previousMode: RingerMode) :
+        RingerDrawerState
 
     /** Initial state when volume dialog is shown with a closed drawer. */
     interface Initial : RingerDrawerState {
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModel.kt
index 624dcc7..45338e4 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModel.kt
@@ -98,7 +98,10 @@
                     RingerDrawerState.Open(ringerMode)
                 }
                 is RingerDrawerState.Open -> {
-                    RingerDrawerState.Closed(ringerMode)
+                    RingerDrawerState.Closed(
+                        ringerMode,
+                        (drawerState.value as RingerDrawerState.Open).mode,
+                    )
                 }
                 is RingerDrawerState.Closed -> {
                     RingerDrawerState.Open(ringerMode)
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt
index c7f5801..10cf615 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt
@@ -20,6 +20,8 @@
 import android.animation.AnimatorListenerAdapter
 import android.animation.ValueAnimator
 import android.view.ViewPropertyAnimator
+import androidx.dynamicanimation.animation.DynamicAnimation
+import androidx.dynamicanimation.animation.SpringAnimation
 import kotlin.coroutines.resume
 import kotlinx.coroutines.CancellableContinuation
 import kotlinx.coroutines.suspendCancellableCoroutine
@@ -80,6 +82,27 @@
     }
 }
 
+/**
+ * Starts spring animation and suspends until it's finished. Cancels the animation if the running
+ * coroutine is cancelled.
+ */
+suspend fun SpringAnimation.suspendAnimate(
+    animationUpdateListener: DynamicAnimation.OnAnimationUpdateListener? = null,
+    animationEndListener: DynamicAnimation.OnAnimationEndListener? = null,
+) = suspendCancellableCoroutine { continuation ->
+    animationUpdateListener?.let(::addUpdateListener)
+    addEndListener { animation, canceled, value, velocity ->
+        continuation.resumeIfCan(Unit)
+        animationEndListener?.onAnimationEnd(animation, canceled, value, velocity)
+    }
+    animateToFinalPosition(1F)
+    continuation.invokeOnCancellation {
+        animationUpdateListener?.let(::removeUpdateListener)
+        animationEndListener?.let(::removeEndListener)
+        cancel()
+    }
+}
+
 private fun <T> CancellableContinuation<T>.resumeIfCan(value: T) {
     if (!isCancelled && !isCompleted) {
         resume(value)