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

Commit 5bf40cec authored by Michael Mikhail's avatar Michael Mikhail
Browse files

Add ringer buttons animation

Flag: com.android.systemui.volume_redesign
Bug: 369995871
Test: checked UI.

Change-Id: Ic990c36c656dc34179a98061c702a8e51bf7ab05
parent 08116c57
Loading
Loading
Loading
Loading
+7 −4
Original line number Diff line number Diff line
@@ -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)
        }
+1 −1
Original line number Diff line number Diff line
@@ -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)"
+163 −18
Original line number Diff line number Diff line
@@ -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(
                                            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,
    ) {
        ensureChildCount(R.layout.volume_ringer_button, uiModel.availableButtons.size)
        if (
            uiModel.drawerState is RingerDrawerState.Closed &&
                uiModel.drawerState.currentMode != uiModel.drawerState.previousMode
        ) {
        val drawerContainer = requireViewById<MotionLayout>(R.id.volume_ringer_drawer)
            val count = uiModel.availableButtons.size
        drawerContainer.ensureChildCount(R.layout.volume_ringer_button, count)
            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,
            )
        }
    }
}
+61 −0
Original line number Diff line number Diff line
/*
 * 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
                    ),
            )
        }
    }
}
+2 −1
Original line number Diff line number Diff line
@@ -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 {
Loading