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
This commit is contained in:
Yein Jo
2022-10-26 01:19:59 +00:00
parent 1e50de8949
commit bc1f67ec23
10 changed files with 389 additions and 16 deletions

View File

@@ -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.)

View File

@@ -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() {

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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<RippleAnimation>()
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()
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,30 @@
package com.android.systemui.ripple
import android.graphics.Color
/**
* A struct that holds the ripple animation configurations.
*
* <p>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.
}
}

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}
}