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)