From afe8c7e9f0b7f771680a8ea33d359425a81096d3 Mon Sep 17 00:00:00 2001 From: Yein Jo Date: Thu, 27 Oct 2022 18:02:59 +0000 Subject: [PATCH] Add touch ripple in the media control panel, with flag protected. Recordings are attached in the b/237282226 (tested with the flag enabled) Bug: 237282226 Test: MediaControlPanelTest, ColorSchemeTransitionTest, RippleAnimationTest, MultiRippleControllerTest, Manual Change-Id: Ib7409c946421950f3857dbd442bab10d0481fcf3 --- .../res/layout/media_session_view.xml | 9 +++ .../res/xml/media_session_collapsed.xml | 10 ++++ .../res/xml/media_session_expanded.xml | 10 ++++ .../src/com/android/systemui/flags/Flags.kt | 2 +- .../controls/models/player/MediaViewHolder.kt | 2 + .../controls/ui/ColorSchemeTransition.kt | 8 ++- .../media/controls/ui/MediaControlPanel.java | 41 ++++++++++++- .../systemui/ripple/MultiRippleController.kt | 5 ++ .../systemui/ripple/RippleAnimation.kt | 26 ++++++--- .../systemui/ripple/RippleAnimationConfig.kt | 12 ++-- .../controls/ui/ColorSchemeTransitionTest.kt | 9 ++- .../controls/ui/MediaControlPanelTest.kt | 57 ++++++++++++++++++- .../ripple/MultiRippleControllerTest.kt | 19 +++++++ .../systemui/ripple/RippleAnimationTest.kt | 17 ++++++ 14 files changed, 207 insertions(+), 20 deletions(-) diff --git a/packages/SystemUI/res/layout/media_session_view.xml b/packages/SystemUI/res/layout/media_session_view.xml index c526d9cc8dd38..9b8b611558fec 100644 --- a/packages/SystemUI/res/layout/media_session_view.xml +++ b/packages/SystemUI/res/layout/media_session_view.xml @@ -44,6 +44,15 @@ android:background="@drawable/qs_media_outline_album_bg" /> + + + + + + + + (R.id.album_art) + val multiRippleView = itemView.requireViewById(R.id.touch_ripple_view) val appIcon = itemView.requireViewById(R.id.icon) val titleText = itemView.requireViewById(R.id.header_title) val artistText = itemView.requireViewById(R.id.header_artist) diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/ui/ColorSchemeTransition.kt b/packages/SystemUI/src/com/android/systemui/media/controls/ui/ColorSchemeTransition.kt index 61ef2f1838e7d..918417fcd9a90 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/ui/ColorSchemeTransition.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/ui/ColorSchemeTransition.kt @@ -29,6 +29,7 @@ import com.android.internal.annotations.VisibleForTesting import com.android.settingslib.Utils import com.android.systemui.media.controls.models.player.MediaViewHolder import com.android.systemui.monet.ColorScheme +import com.android.systemui.ripple.MultiRippleController /** * A [ColorTransition] is an object that updates the colors of views each time [updateColorScheme] @@ -100,12 +101,14 @@ class ColorSchemeTransition internal constructor( private val context: Context, private val mediaViewHolder: MediaViewHolder, + private val multiRippleController: MultiRippleController, animatingColorTransitionFactory: AnimatingColorTransitionFactory ) { constructor( context: Context, - mediaViewHolder: MediaViewHolder - ) : this(context, mediaViewHolder, ::AnimatingColorTransition) + mediaViewHolder: MediaViewHolder, + multiRippleController: MultiRippleController, + ) : this(context, mediaViewHolder, multiRippleController, ::AnimatingColorTransition) val bgColor = context.getColor(com.android.systemui.R.color.material_dynamic_secondary95) val surfaceColor = @@ -125,6 +128,7 @@ internal constructor( val accentColorList = ColorStateList.valueOf(accentPrimary) mediaViewHolder.actionPlayPause.backgroundTintList = accentColorList mediaViewHolder.gutsViewHolder.setAccentPrimaryColor(accentPrimary) + multiRippleController.updateColor(accentPrimary) } val accentSecondary = diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaControlPanel.java index 18ecadb28cf39..5b14cf34827a6 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaControlPanel.java +++ b/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaControlPanel.java @@ -76,6 +76,8 @@ import com.android.systemui.bluetooth.BroadcastDialogController; import com.android.systemui.broadcast.BroadcastSender; import com.android.systemui.dagger.qualifiers.Background; import com.android.systemui.dagger.qualifiers.Main; +import com.android.systemui.flags.FeatureFlags; +import com.android.systemui.flags.Flags; import com.android.systemui.media.controls.models.GutsViewHolder; import com.android.systemui.media.controls.models.player.MediaAction; import com.android.systemui.media.controls.models.player.MediaButton; @@ -95,6 +97,10 @@ import com.android.systemui.monet.ColorScheme; import com.android.systemui.monet.Style; import com.android.systemui.plugins.ActivityStarter; import com.android.systemui.plugins.FalsingManager; +import com.android.systemui.ripple.MultiRippleController; +import com.android.systemui.ripple.RippleAnimation; +import com.android.systemui.ripple.RippleAnimationConfig; +import com.android.systemui.ripple.RippleShader; import com.android.systemui.shared.system.SysUiStatsLog; import com.android.systemui.statusbar.NotificationLockscreenUserManager; import com.android.systemui.statusbar.policy.KeyguardStateController; @@ -209,6 +215,8 @@ public class MediaControlPanel { private boolean mIsCurrentBroadcastedApp = false; private boolean mShowBroadcastDialogButton = false; private String mSwitchBroadcastApp; + private MultiRippleController mMultiRippleController; + private FeatureFlags mFeatureFlags; /** * Initialize a new control panel @@ -236,7 +244,9 @@ public class MediaControlPanel { KeyguardStateController keyguardStateController, ActivityIntentHelper activityIntentHelper, NotificationLockscreenUserManager lockscreenUserManager, - BroadcastDialogController broadcastDialogController) { + BroadcastDialogController broadcastDialogController, + FeatureFlags featureFlags + ) { mContext = context; mBackgroundExecutor = backgroundExecutor; mMainExecutor = mainExecutor; @@ -262,6 +272,8 @@ public class MediaControlPanel { logSmartspaceCardReported(SMARTSPACE_CARD_CLICK_EVENT); return Unit.INSTANCE; }); + + mFeatureFlags = featureFlags; } /** @@ -381,7 +393,9 @@ public class MediaControlPanel { AnimatorSet exit = loadAnimator(R.anim.media_metadata_exit, Interpolators.EMPHASIZED_ACCELERATE, titleText, artistText); - mColorSchemeTransition = new ColorSchemeTransition(mContext, mMediaViewHolder); + mMultiRippleController = new MultiRippleController(vh.getMultiRippleView()); + mColorSchemeTransition = new ColorSchemeTransition( + mContext, mMediaViewHolder, mMultiRippleController); mMetadataAnimationHandler = new MetadataAnimationHandler(exit, enter); } @@ -982,6 +996,9 @@ public class MediaControlPanel { mLogger.logTapAction(button.getId(), mUid, mPackageName, mInstanceId); logSmartspaceCardReported(SMARTSPACE_CARD_CLICK_EVENT); action.run(); + if (mFeatureFlags.isEnabled(Flags.UMO_SURFACE_RIPPLE)) { + mMultiRippleController.play(createTouchRippleAnimation(button)); + } if (icon instanceof Animatable) { ((Animatable) icon).start(); @@ -997,6 +1014,26 @@ public class MediaControlPanel { } } + private RippleAnimation createTouchRippleAnimation(ImageButton button) { + float maxSize = mMediaViewHolder.getMultiRippleView().getWidth() * 2; + return new RippleAnimation( + new RippleAnimationConfig( + RippleShader.RippleShape.CIRCLE, + /* duration= */ 1500L, + /* centerX= */ button.getX() + button.getWidth() * 0.5f, + /* centerY= */ button.getY() + button.getHeight() * 0.5f, + /* maxWidth= */ maxSize, + /* maxHeight= */ maxSize, + /* pixelDensity= */ getContext().getResources().getDisplayMetrics().density, + mColorSchemeTransition.getAccentPrimary().getTargetColor(), + /* opacity= */ 100, + /* shouldFillRipple= */ false, + /* sparkleStrength= */ 0f, + /* shouldDistort= */ false + ) + ); + } + private void clearButton(final ImageButton button) { button.setImageDrawable(null); button.setContentDescription(null); diff --git a/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleController.kt b/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleController.kt index 74496b06c4047..48df15c78ea5a 100644 --- a/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleController.kt +++ b/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleController.kt @@ -26,6 +26,11 @@ class MultiRippleController(private val multipleRippleView: MultiRippleView) { @VisibleForTesting const val MAX_RIPPLE_NUMBER = 10 } + /** Updates all the ripple colors during the animation. */ + fun updateColor(color: Int) { + multipleRippleView.ripples.forEach { anim -> anim.updateColor(color) } + } + fun play(rippleAnimation: RippleAnimation) { if (multipleRippleView.ripples.size >= MAX_RIPPLE_NUMBER) { return diff --git a/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimation.kt b/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimation.kt index b536038d60646..aca9e254e4c3f 100644 --- a/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimation.kt +++ b/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimation.kt @@ -27,17 +27,18 @@ class RippleAnimation(private val config: RippleAnimationConfig) { 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 + applyConfigToShader() + } + + /** Updates the ripple color during the animation. */ + fun updateColor(color: Int) { + config.apply { config.color = color } + applyConfigToShader() } @JvmOverloads fun play(onAnimationEnd: Runnable? = null) { - if (animator.isRunning) { + if (isPlaying()) { return // Ignore if ripple effect is already playing } @@ -46,7 +47,7 @@ class RippleAnimation(private val config: RippleAnimationConfig) { val now = updateListener.currentPlayTime val progress = updateListener.animatedValue as Float rippleShader.progress = progress - rippleShader.distortionStrength = 1 - progress + rippleShader.distortionStrength = if (config.shouldDistort) 1 - progress else 0f rippleShader.time = now.toFloat() } animator.addListener( @@ -61,4 +62,13 @@ class RippleAnimation(private val config: RippleAnimationConfig) { /** Indicates whether the animation is playing. */ fun isPlaying(): Boolean = animator.isRunning + + private fun applyConfigToShader() { + 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 + } } diff --git a/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimationConfig.kt b/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimationConfig.kt index c43328cb433be..88122544c7cd5 100644 --- a/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimationConfig.kt +++ b/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimationConfig.kt @@ -5,9 +5,10 @@ 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. + *

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, @@ -17,10 +18,11 @@ data class RippleAnimationConfig( val maxWidth: Float = 0f, val maxHeight: Float = 0f, val pixelDensity: Float = 1f, - val color: Int = Color.WHITE, + var color: Int = Color.WHITE, val opacity: Int = RIPPLE_DEFAULT_ALPHA, val shouldFillRipple: Boolean = false, - val sparkleStrength: Float = RIPPLE_SPARKLE_STRENGTH + val sparkleStrength: Float = RIPPLE_SPARKLE_STRENGTH, + val shouldDistort: Boolean = true ) { companion object { const val RIPPLE_SPARKLE_STRENGTH: Float = 0.3f 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 index c821fdd574d30..05512e5bf1ceb 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/ripple/MultiRippleControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/ripple/MultiRippleControllerTest.kt @@ -16,6 +16,7 @@ package com.android.systemui.ripple +import android.graphics.Color import android.testing.AndroidTestingRunner import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase @@ -45,6 +46,24 @@ class MultiRippleControllerTest : SysuiTestCase() { 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 { diff --git a/packages/SystemUI/tests/src/com/android/systemui/ripple/RippleAnimationTest.kt b/packages/SystemUI/tests/src/com/android/systemui/ripple/RippleAnimationTest.kt index cbc6f1731590e..7662282a04f44 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/ripple/RippleAnimationTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/ripple/RippleAnimationTest.kt @@ -55,6 +55,23 @@ class RippleAnimationTest : SysuiTestCase() { } } + @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)