This configuration is designed to play a SINGLE animation. Do not reuse or modify the + * configuration parameters to play different animations, unless the value has to change within the + * single animation (e.g. Change color or opacity during the animation). Note that this data class + * is pulled out to make the [RippleAnimation] constructor succinct. + */ +data class RippleAnimationConfig( + val rippleShape: RippleShader.RippleShape = RippleShader.RippleShape.CIRCLE, + val duration: Long = 0L, + val centerX: Float = 0f, + val centerY: Float = 0f, + val maxWidth: Float = 0f, + val maxHeight: Float = 0f, + val pixelDensity: Float = 1f, + var color: Int = Color.WHITE, + val opacity: Int = RIPPLE_DEFAULT_ALPHA, + val shouldFillRipple: Boolean = false, + val sparkleStrength: Float = RIPPLE_SPARKLE_STRENGTH, + val shouldDistort: Boolean = true +) { + companion object { + const val RIPPLE_SPARKLE_STRENGTH: Float = 0.3f + const val RIPPLE_DEFAULT_COLOR: Int = 0xffffffff.toInt() + const val RIPPLE_DEFAULT_ALPHA: Int = 45 // full opacity is 255. + } +} diff --git a/packages/SystemUI/src/com/android/systemui/ripple/RippleView.kt b/packages/SystemUI/src/com/android/systemui/ripple/RippleView.kt index 1e51ffa292b7b..a6d79303962f6 100644 --- a/packages/SystemUI/src/com/android/systemui/ripple/RippleView.kt +++ b/packages/SystemUI/src/com/android/systemui/ripple/RippleView.kt @@ -28,10 +28,6 @@ import android.view.View import androidx.core.graphics.ColorUtils import com.android.systemui.ripple.RippleShader.RippleShape -private const val RIPPLE_SPARKLE_STRENGTH: Float = 0.3f -private const val RIPPLE_DEFAULT_COLOR: Int = 0xffffffff.toInt() -const val RIPPLE_DEFAULT_ALPHA: Int = 45 - /** * A generic expanding ripple effect. * @@ -45,8 +41,8 @@ open class RippleView(context: Context?, attrs: AttributeSet?) : View(context, a private set private val ripplePaint = Paint() + private val animator = ValueAnimator.ofFloat(0f, 1f) - var rippleInProgress: Boolean = false var duration: Long = 1750 private var maxWidth: Float = 0.0f @@ -80,9 +76,9 @@ open class RippleView(context: Context?, attrs: AttributeSet?) : View(context, a this.rippleShape = rippleShape rippleShader = RippleShader(rippleShape) - rippleShader.color = RIPPLE_DEFAULT_COLOR + rippleShader.color = RippleAnimationConfig.RIPPLE_DEFAULT_COLOR rippleShader.progress = 0f - rippleShader.sparkleStrength = RIPPLE_SPARKLE_STRENGTH + rippleShader.sparkleStrength = RippleAnimationConfig.RIPPLE_SPARKLE_STRENGTH rippleShader.pixelDensity = resources.displayMetrics.density ripplePaint.shader = rippleShader @@ -90,10 +86,9 @@ open class RippleView(context: Context?, attrs: AttributeSet?) : View(context, a @JvmOverloads fun startRipple(onAnimationEnd: Runnable? = null) { - if (rippleInProgress) { + if (animator.isRunning) { return // Ignore if ripple effect is already playing } - val animator = ValueAnimator.ofFloat(0f, 1f) animator.duration = duration animator.addUpdateListener { updateListener -> val now = updateListener.currentPlayTime @@ -105,19 +100,17 @@ open class RippleView(context: Context?, attrs: AttributeSet?) : View(context, a } animator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator?) { - rippleInProgress = false onAnimationEnd?.run() } }) animator.start() - rippleInProgress = true } /** Set the color to be used for the ripple. * * The alpha value of the color will be applied to the ripple. The alpha range is [0-100]. */ - fun setColor(color: Int, alpha: Int = RIPPLE_DEFAULT_ALPHA) { + fun setColor(color: Int, alpha: Int = RippleAnimationConfig.RIPPLE_DEFAULT_ALPHA) { rippleShader.color = ColorUtils.setAlphaComponent(color, alpha) } @@ -137,6 +130,9 @@ open class RippleView(context: Context?, attrs: AttributeSet?) : View(context, a rippleShader.sparkleStrength = strength } + /** Indicates whether the ripple animation is playing. */ + fun rippleInProgress(): Boolean = animator.isRunning + override fun onDraw(canvas: Canvas?) { if (canvas == null || !canvas.isHardwareAccelerated) { // Drawing with the ripple shader requires hardware acceleration, so skip diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/ColorSchemeTransitionTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/ColorSchemeTransitionTest.kt index 5bb74e5a31f1f..a8f413848009e 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/ColorSchemeTransitionTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/ColorSchemeTransitionTest.kt @@ -25,6 +25,7 @@ import com.android.systemui.SysuiTestCase import com.android.systemui.media.controls.models.GutsViewHolder import com.android.systemui.media.controls.models.player.MediaViewHolder import com.android.systemui.monet.ColorScheme +import com.android.systemui.ripple.MultiRippleController import junit.framework.Assert.assertEquals import org.junit.After import org.junit.Before @@ -60,6 +61,7 @@ class ColorSchemeTransitionTest : SysuiTestCase() { private lateinit var animatingColorTransitionFactory: AnimatingColorTransitionFactory @Mock private lateinit var mediaViewHolder: MediaViewHolder @Mock private lateinit var gutsViewHolder: GutsViewHolder + @Mock private lateinit var multiRippleController: MultiRippleController @JvmField @Rule val mockitoRule = MockitoJUnit.rule() @@ -70,7 +72,12 @@ class ColorSchemeTransitionTest : SysuiTestCase() { whenever(extractColor.invoke(colorScheme)).thenReturn(TARGET_COLOR) colorSchemeTransition = - ColorSchemeTransition(context, mediaViewHolder, animatingColorTransitionFactory) + ColorSchemeTransition( + context, + mediaViewHolder, + multiRippleController, + animatingColorTransitionFactory + ) colorTransition = object : AnimatingColorTransition(DEFAULT_COLOR, extractColor, applyColor) { diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/MediaControlPanelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/MediaControlPanelTest.kt index 584305334b6fd..81901569bde87 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/MediaControlPanelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/MediaControlPanelTest.kt @@ -59,6 +59,8 @@ import com.android.systemui.R import com.android.systemui.SysuiTestCase import com.android.systemui.bluetooth.BroadcastDialogController import com.android.systemui.broadcast.BroadcastSender +import com.android.systemui.flags.FakeFeatureFlags +import com.android.systemui.flags.Flags import com.android.systemui.media.controls.MediaTestUtils import com.android.systemui.media.controls.models.GutsViewHolder import com.android.systemui.media.controls.models.player.MediaAction @@ -76,6 +78,7 @@ import com.android.systemui.media.controls.util.MediaUiEventLogger import com.android.systemui.media.dialog.MediaOutputDialogFactory import com.android.systemui.plugins.ActivityStarter import com.android.systemui.plugins.FalsingManager +import com.android.systemui.ripple.MultiRippleView import com.android.systemui.statusbar.NotificationLockscreenUserManager import com.android.systemui.statusbar.policy.KeyguardStateController import com.android.systemui.util.animation.TransitionLayout @@ -174,6 +177,7 @@ public class MediaControlPanelTest : SysuiTestCase() { private lateinit var cancelText: TextView private lateinit var dismiss: FrameLayout private lateinit var dismissText: TextView + private lateinit var multiRippleView: MultiRippleView private lateinit var session: MediaSession private lateinit var device: MediaDeviceData @@ -205,6 +209,8 @@ public class MediaControlPanelTest : SysuiTestCase() { private lateinit var recSubtitle2: TextView private lateinit var recSubtitle3: TextView private var shouldShowBroadcastButton: Boolean = false + private val fakeFeatureFlag = + FakeFeatureFlags().apply { this.set(Flags.UMO_SURFACE_RIPPLE, false) } @JvmField @Rule val mockito = MockitoJUnit.rule() @@ -244,7 +250,8 @@ public class MediaControlPanelTest : SysuiTestCase() { keyguardStateController, activityIntentHelper, lockscreenUserManager, - broadcastDialogController + broadcastDialogController, + fakeFeatureFlag ) { override fun loadAnimator( animId: Int, @@ -374,6 +381,8 @@ public class MediaControlPanelTest : SysuiTestCase() { ) } + multiRippleView = MultiRippleView(context, null) + whenever(viewHolder.player).thenReturn(view) whenever(viewHolder.appIcon).thenReturn(appIcon) whenever(viewHolder.albumView).thenReturn(albumView) @@ -414,6 +423,8 @@ public class MediaControlPanelTest : SysuiTestCase() { whenever(viewHolder.getAction(R.id.action4)).thenReturn(action4) whenever(viewHolder.actionsTopBarrier).thenReturn(actionsTopBarrier) + + whenever(viewHolder.multiRippleView).thenReturn(multiRippleView) } /** Initialize elements for the recommendation view holder */ @@ -1973,6 +1984,50 @@ public class MediaControlPanelTest : SysuiTestCase() { assertThat(expandedSet.getVisibility(recSubtitle3.id)).isEqualTo(ConstraintSet.GONE) } + @Test + fun onButtonClick_touchRippleFlagEnabled_playsTouchRipple() { + fakeFeatureFlag.set(Flags.UMO_SURFACE_RIPPLE, true) + val semanticActions = + MediaButton( + playOrPause = + MediaAction( + icon = null, + action = {}, + contentDescription = "play", + background = null + ) + ) + val data = mediaData.copy(semanticActions = semanticActions) + player.attachPlayer(viewHolder) + player.bindPlayer(data, KEY) + + viewHolder.actionPlayPause.callOnClick() + + assertThat(viewHolder.multiRippleView.ripples.size).isEqualTo(1) + } + + @Test + fun onButtonClick_touchRippleFlagDisabled_doesNotPlayTouchRipple() { + fakeFeatureFlag.set(Flags.UMO_SURFACE_RIPPLE, false) + val semanticActions = + MediaButton( + playOrPause = + MediaAction( + icon = null, + action = {}, + contentDescription = "play", + background = null + ) + ) + val data = mediaData.copy(semanticActions = semanticActions) + player.attachPlayer(viewHolder) + player.bindPlayer(data, KEY) + + viewHolder.actionPlayPause.callOnClick() + + assertThat(viewHolder.multiRippleView.ripples.size).isEqualTo(0) + } + private fun getScrubbingChangeListener(): SeekBarViewModel.ScrubbingChangeListener = withArgCaptor { verify(seekBarViewModel).setScrubbingChangeListener(capture()) diff --git a/packages/SystemUI/tests/src/com/android/systemui/ripple/MultiRippleControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/ripple/MultiRippleControllerTest.kt new file mode 100644 index 0000000000000..05512e5bf1ceb --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/ripple/MultiRippleControllerTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2022 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.ripple + +import android.graphics.Color +import android.testing.AndroidTestingRunner +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.ripple.MultiRippleController.Companion.MAX_RIPPLE_NUMBER +import com.android.systemui.util.concurrency.FakeExecutor +import com.android.systemui.util.time.FakeSystemClock +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class MultiRippleControllerTest : SysuiTestCase() { + private lateinit var multiRippleController: MultiRippleController + private lateinit var multiRippleView: MultiRippleView + private lateinit var rippleAnimationConfig: RippleAnimationConfig + private val fakeSystemClock = FakeSystemClock() + + // FakeExecutor is needed to run animator. + private val fakeExecutor = FakeExecutor(fakeSystemClock) + + @Before + fun setup() { + rippleAnimationConfig = RippleAnimationConfig(duration = 1000L) + multiRippleView = MultiRippleView(context, null) + multiRippleController = MultiRippleController(multiRippleView) + } + + @Test + fun updateColor_updatesColor() { + val initialColor = Color.WHITE + val expectedColor = Color.RED + + fakeExecutor.execute { + val rippleAnimation = + RippleAnimation(rippleAnimationConfig.apply { this.color = initialColor }) + + with(multiRippleController) { + play(rippleAnimation) + updateColor(expectedColor) + } + + assertThat(rippleAnimationConfig.color).isEqualTo(expectedColor) + } + } + + @Test + fun play_playsRipple() { + fakeExecutor.execute { + val rippleAnimation = RippleAnimation(rippleAnimationConfig) + + multiRippleController.play(rippleAnimation) + + assertThat(multiRippleView.ripples.size).isEqualTo(1) + assertThat(multiRippleView.ripples[0]).isEqualTo(rippleAnimation) + } + } + + @Test + fun play_doesNotExceedMaxRipple() { + fakeExecutor.execute { + for (i in 0..MAX_RIPPLE_NUMBER + 10) { + multiRippleController.play(RippleAnimation(rippleAnimationConfig)) + } + + assertThat(multiRippleView.ripples.size).isEqualTo(MAX_RIPPLE_NUMBER) + } + } + + @Test + fun play_onEnd_removesAnimation() { + fakeExecutor.execute { + val rippleAnimation = RippleAnimation(rippleAnimationConfig) + multiRippleController.play(rippleAnimation) + + assertThat(multiRippleView.ripples.size).isEqualTo(1) + assertThat(multiRippleView.ripples[0]).isEqualTo(rippleAnimation) + + fakeSystemClock.advanceTime(rippleAnimationConfig.duration) + + assertThat(multiRippleView.ripples.size).isEqualTo(0) + } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/ripple/RippleAnimationTest.kt b/packages/SystemUI/tests/src/com/android/systemui/ripple/RippleAnimationTest.kt new file mode 100644 index 0000000000000..7662282a04f44 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/ripple/RippleAnimationTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2022 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.ripple + +import android.graphics.Color +import android.testing.AndroidTestingRunner +import androidx.core.graphics.ColorUtils +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.util.concurrency.FakeExecutor +import com.android.systemui.util.time.FakeSystemClock +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class RippleAnimationTest : SysuiTestCase() { + + private val fakeSystemClock = FakeSystemClock() + private val fakeExecutor = FakeExecutor(fakeSystemClock) + + @Test + fun init_shaderHasCorrectConfig() { + val config = + RippleAnimationConfig( + duration = 3000L, + pixelDensity = 2f, + color = Color.RED, + opacity = 30, + shouldFillRipple = true, + sparkleStrength = 0.3f + ) + val rippleAnimation = RippleAnimation(config) + + with(rippleAnimation.rippleShader) { + assertThat(rippleFill).isEqualTo(config.shouldFillRipple) + assertThat(pixelDensity).isEqualTo(config.pixelDensity) + assertThat(color).isEqualTo(ColorUtils.setAlphaComponent(config.color, config.opacity)) + assertThat(sparkleStrength).isEqualTo(config.sparkleStrength) + } + } + + @Test + fun updateColor_updatesColorCorrectly() { + val initialColor = Color.WHITE + val expectedColor = Color.RED + val config = RippleAnimationConfig(color = initialColor) + val rippleAnimation = RippleAnimation(config) + + fakeExecutor.execute { + with(rippleAnimation) { + play() + updateColor(expectedColor) + } + + assertThat(config.color).isEqualTo(expectedColor) + } + } + + @Test + fun play_updatesIsPlaying() { + val config = RippleAnimationConfig(duration = 1000L) + val rippleAnimation = RippleAnimation(config) + + fakeExecutor.execute { + rippleAnimation.play() + + assertThat(rippleAnimation.isPlaying()).isTrue() + + // move time to finish the animation + fakeSystemClock.advanceTime(config.duration) + + assertThat(rippleAnimation.isPlaying()).isFalse() + } + } + + @Test + fun play_onEnd_triggersOnAnimationEnd() { + val config = RippleAnimationConfig(duration = 1000L) + val rippleAnimation = RippleAnimation(config) + var animationEnd = false + + fakeExecutor.execute { + rippleAnimation.play(onAnimationEnd = { animationEnd = true }) + + fakeSystemClock.advanceTime(config.duration) + + assertThat(animationEnd).isTrue() + } + } +}