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:
@@ -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.)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user