diff options
7 files changed, 261 insertions, 25 deletions
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 1e6e52a23658..d8184dbadf9a 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 @@ class VolumeDialogRingerDrawerViewModelTest : SysuiTestCase() { 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 @@ class VolumeDialogRingerDrawerViewModelTest : SysuiTestCase() { 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 @@ class VolumeDialogRingerDrawerViewModelTest : SysuiTestCase() { 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 @@ class VolumeDialogRingerDrawerViewModelTest : SysuiTestCase() { 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 877637e0b0d8..20c6ab7e01a8 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 1963ba22d444..82ac056fd2d4 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.annotation.LayoutRes 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.lifecycle.viewModel 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 @@ constructor(private val viewModelFactory: VolumeDialogRingerDrawerViewModel.Fact 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 @@ constructor(private val viewModelFactory: VolumeDialogRingerDrawerViewModel.Fact } } - 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 @@ constructor(private val viewModelFactory: VolumeDialogRingerDrawerViewModel.Fact } 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 @@ constructor(private val viewModelFactory: VolumeDialogRingerDrawerViewModel.Fact 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 000000000000..3c465674ebb5 --- /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 f3218370c4dd..afb3f68e519e 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 @@ sealed interface RingerDrawerState { 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 624dcc71e2a9..45338e4577a7 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 @@ constructor( 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 c7f5801a87c2..10cf615ce0ce 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.Animator 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 @@ suspend fun <T> ValueAnimator.awaitAnimation(onValueChanged: (T) -> Unit) { } } +/** + * 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) |