From bc1f67ec2315c1aec25fd01d94f9e905a8be4791 Mon Sep 17 00:00:00 2001 From: Yein Jo Date: Wed, 26 Oct 2022 01:19:59 +0000 Subject: [PATCH] Add MultiRippleView and its controller to support UMO touch ripple effects. The current RippleView is designed for a single ripple effect. For UMO touch ripple, multiple touch ripple effects should be supported, thus adding a new MultiRippleView. RippleView may be refactored such that it takes in RippleAnimation later. Design doc: go/surface-effects-umo Bug: 237282226 Test: MultiRippleControllerTest, RippleAnimationTest Change-Id: Ic199a5552b0c40e5c09c12f06c280ff054f462cb --- .../charging/WiredChargingRippleController.kt | 2 +- .../charging/WirelessChargingLayout.java | 4 +- .../MediaTttChipControllerReceiver.kt | 2 +- .../systemui/ripple/MultiRippleController.kt | 42 +++++++++ .../systemui/ripple/MultiRippleView.kt | 67 ++++++++++++++ .../systemui/ripple/RippleAnimation.kt | 64 +++++++++++++ .../systemui/ripple/RippleAnimationConfig.kt | 30 +++++++ .../com/android/systemui/ripple/RippleView.kt | 20 ++--- .../ripple/MultiRippleControllerTest.kt | 85 ++++++++++++++++++ .../systemui/ripple/RippleAnimationTest.kt | 89 +++++++++++++++++++ 10 files changed, 389 insertions(+), 16 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/ripple/MultiRippleController.kt create mode 100644 packages/SystemUI/src/com/android/systemui/ripple/MultiRippleView.kt create mode 100644 packages/SystemUI/src/com/android/systemui/ripple/RippleAnimation.kt create mode 100644 packages/SystemUI/src/com/android/systemui/ripple/RippleAnimationConfig.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/ripple/MultiRippleControllerTest.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/ripple/RippleAnimationTest.kt diff --git a/packages/SystemUI/src/com/android/systemui/charging/WiredChargingRippleController.kt b/packages/SystemUI/src/com/android/systemui/charging/WiredChargingRippleController.kt index dec3d6b679c23..616e49c0b7098 100644 --- a/packages/SystemUI/src/com/android/systemui/charging/WiredChargingRippleController.kt +++ b/packages/SystemUI/src/com/android/systemui/charging/WiredChargingRippleController.kt @@ -149,7 +149,7 @@ class WiredChargingRippleController @Inject constructor( } fun startRipple() { - if (rippleView.rippleInProgress || rippleView.parent != null) { + if (rippleView.rippleInProgress() || rippleView.parent != null) { // Skip if ripple is still playing, or not playing but already added the parent // (which might happen just before the animation starts or right after // the animation ends.) diff --git a/packages/SystemUI/src/com/android/systemui/charging/WirelessChargingLayout.java b/packages/SystemUI/src/com/android/systemui/charging/WirelessChargingLayout.java index c0cc6b41aafd8..145569919e8e6 100644 --- a/packages/SystemUI/src/com/android/systemui/charging/WirelessChargingLayout.java +++ b/packages/SystemUI/src/com/android/systemui/charging/WirelessChargingLayout.java @@ -33,9 +33,9 @@ import android.widget.TextView; import com.android.settingslib.Utils; import com.android.systemui.R; import com.android.systemui.animation.Interpolators; +import com.android.systemui.ripple.RippleAnimationConfig; import com.android.systemui.ripple.RippleShader.RippleShape; import com.android.systemui.ripple.RippleView; -import com.android.systemui.ripple.RippleViewKt; import java.text.NumberFormat; @@ -150,7 +150,7 @@ final class WirelessChargingLayout extends FrameLayout { mRippleView.setColor(color, 28); } else { mRippleView.setDuration(CIRCLE_RIPPLE_ANIMATION_DURATION); - mRippleView.setColor(color, RippleViewKt.RIPPLE_DEFAULT_ALPHA); + mRippleView.setColor(color, RippleAnimationConfig.RIPPLE_DEFAULT_ALPHA); } OnAttachStateChangeListener listener = new OnAttachStateChangeListener() { diff --git a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttChipControllerReceiver.kt b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttChipControllerReceiver.kt index 7dd9fb4b9cd51..662d059aedcd2 100644 --- a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttChipControllerReceiver.kt +++ b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttChipControllerReceiver.kt @@ -189,7 +189,7 @@ class MediaTttChipControllerReceiver @Inject constructor( } private fun startRipple(rippleView: ReceiverChipRippleView) { - if (rippleView.rippleInProgress) { + if (rippleView.rippleInProgress()) { // Skip if ripple is still playing return } diff --git a/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleController.kt b/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleController.kt new file mode 100644 index 0000000000000..74496b06c4047 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleController.kt @@ -0,0 +1,42 @@ +/* + * 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 androidx.annotation.VisibleForTesting + +/** Controller that handles playing [RippleAnimation]. */ +class MultiRippleController(private val multipleRippleView: MultiRippleView) { + + companion object { + /** Max number of ripple animations at a time. */ + @VisibleForTesting const val MAX_RIPPLE_NUMBER = 10 + } + + fun play(rippleAnimation: RippleAnimation) { + if (multipleRippleView.ripples.size >= MAX_RIPPLE_NUMBER) { + return + } + + multipleRippleView.ripples.add(rippleAnimation) + + // Remove ripple once the animation is done + rippleAnimation.play { multipleRippleView.ripples.remove(rippleAnimation) } + + // Trigger drawing + multipleRippleView.invalidate() + } +} diff --git a/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleView.kt b/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleView.kt new file mode 100644 index 0000000000000..c7f0b7e0056eb --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleView.kt @@ -0,0 +1,67 @@ +/* + * 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.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.util.AttributeSet +import android.util.Log +import android.view.View + +/** + * A view that allows multiple ripples to play. + * + * Use [MultiRippleController] to play ripple animations. + */ +class MultiRippleView(context: Context?, attrs: AttributeSet?) : View(context, attrs) { + + internal val ripples = ArrayList() + private val ripplePaint = Paint() + private var isWarningLogged = false + + companion object { + const val TAG = "MultiRippleView" + } + + override fun onDraw(canvas: Canvas?) { + if (canvas == null || !canvas.isHardwareAccelerated) { + // Drawing with the ripple shader requires hardware acceleration, so skip + // if it's unsupported. + if (!isWarningLogged) { + // Only log once to not spam. + Log.w( + TAG, + "Can't draw ripple shader. $canvas does not support hardware acceleration." + ) + isWarningLogged = true + } + return + } + + var shouldInvalidate = false + + ripples.forEach { anim -> + ripplePaint.shader = anim.rippleShader + canvas.drawPaint(ripplePaint) + + shouldInvalidate = shouldInvalidate || anim.isPlaying() + } + + if (shouldInvalidate) invalidate() + } +} diff --git a/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimation.kt b/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimation.kt new file mode 100644 index 0000000000000..b536038d60646 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimation.kt @@ -0,0 +1,64 @@ +/* + * 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.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import androidx.core.graphics.ColorUtils + +/** A single ripple animation. */ +class RippleAnimation(private val config: RippleAnimationConfig) { + internal val rippleShader: RippleShader = RippleShader(config.rippleShape) + private val animator: ValueAnimator = ValueAnimator.ofFloat(0f, 1f) + + init { + rippleShader.setCenter(config.centerX, config.centerY) + rippleShader.setMaxSize(config.maxWidth, config.maxHeight) + rippleShader.rippleFill = config.shouldFillRipple + rippleShader.pixelDensity = config.pixelDensity + rippleShader.color = ColorUtils.setAlphaComponent(config.color, config.opacity) + rippleShader.sparkleStrength = config.sparkleStrength + } + + @JvmOverloads + fun play(onAnimationEnd: Runnable? = null) { + if (animator.isRunning) { + return // Ignore if ripple effect is already playing + } + + animator.duration = config.duration + animator.addUpdateListener { updateListener -> + val now = updateListener.currentPlayTime + val progress = updateListener.animatedValue as Float + rippleShader.progress = progress + rippleShader.distortionStrength = 1 - progress + rippleShader.time = now.toFloat() + } + animator.addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator?) { + onAnimationEnd?.run() + } + } + ) + animator.start() + } + + /** Indicates whether the animation is playing. */ + fun isPlaying(): Boolean = animator.isRunning +} diff --git a/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimationConfig.kt b/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimationConfig.kt new file mode 100644 index 0000000000000..c43328cb433be --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimationConfig.kt @@ -0,0 +1,30 @@ +package com.android.systemui.ripple + +import android.graphics.Color + +/** + * A struct that holds the ripple animation configurations. + * + *

This is designed to be used only once. Create a new instance when the animation needs to + * change, instead of modifying each parameter. 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, + val color: Int = Color.WHITE, + val opacity: Int = RIPPLE_DEFAULT_ALPHA, + val shouldFillRipple: Boolean = false, + val sparkleStrength: Float = RIPPLE_SPARKLE_STRENGTH +) { + 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/ripple/MultiRippleControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/ripple/MultiRippleControllerTest.kt new file mode 100644 index 0000000000000..c821fdd574d30 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/ripple/MultiRippleControllerTest.kt @@ -0,0 +1,85 @@ +/* + * 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.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 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..cbc6f1731590e --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/ripple/RippleAnimationTest.kt @@ -0,0 +1,89 @@ +/* + * 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 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() + } + } +}