[Media TTT] Polish the tablet ripple
Change the ripple shape to be circular and make the icon translate to
the top of the circle. Adds an icon ripple behind the receiver icon to
match the new specs. And also change the way icon animate out when the
transfer is succeeded.
Bug: 254265058
Test: Checked the UI changes ( demo in bug link )
Change-Id: Ic039a05089c89016ff7503b572830ef8707acced
diff --git a/packages/SystemUI/res/layout/media_ttt_chip_receiver.xml b/packages/SystemUI/res/layout/media_ttt_chip_receiver.xml
index 21d12c2..4483db8 100644
--- a/packages/SystemUI/res/layout/media_ttt_chip_receiver.xml
+++ b/packages/SystemUI/res/layout/media_ttt_chip_receiver.xml
@@ -27,6 +27,14 @@
android:layout_height="wrap_content"
/>
+ <com.android.systemui.media.taptotransfer.receiver.ReceiverChipRippleView
+ android:id="@+id/icon_glow_ripple"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ />
+
+ <!-- Add a bottom margin to avoid the glow of the icon ripple from being cropped by screen
+ bounds while animating with the icon -->
<com.android.internal.widget.CachingIconView
android:id="@+id/app_icon"
android:background="@drawable/media_ttt_chip_background_receiver"
@@ -34,6 +42,7 @@
android:layout_height="@dimen/media_ttt_icon_size_receiver"
android:layout_gravity="center|bottom"
android:alpha="0.0"
+ android:layout_marginBottom="@dimen/media_ttt_receiver_icon_bottom_margin"
/>
</FrameLayout>
diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml
index 890d964..a68e759 100644
--- a/packages/SystemUI/res/values/dimens.xml
+++ b/packages/SystemUI/res/values/dimens.xml
@@ -1089,6 +1089,7 @@
(112 - 40) / 2 = 36dp -->
<dimen name="media_ttt_generic_icon_padding">36dp</dimen>
<dimen name="media_ttt_receiver_vert_translation">40dp</dimen>
+ <dimen name="media_ttt_receiver_icon_bottom_margin">10dp</dimen>
<!-- Window magnification -->
<dimen name="magnification_border_drag_size">35dp</dimen>
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 889147b..6884370 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
@@ -31,7 +31,6 @@
import android.view.WindowManager
import android.view.accessibility.AccessibilityManager
import com.android.internal.widget.CachingIconView
-import com.android.settingslib.Utils
import com.android.systemui.R
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.ui.binder.TintedIconViewBinder
@@ -78,6 +77,7 @@
private val viewUtil: ViewUtil,
wakeLockBuilder: WakeLock.Builder,
systemClock: SystemClock,
+ private val rippleController: MediaTttReceiverRippleController,
) : TemporaryViewDisplayController<ChipReceiverInfo, MediaTttLogger<ChipReceiverInfo>>(
context,
logger,
@@ -114,9 +114,6 @@
}
}
- private var maxRippleWidth: Float = 0f
- private var maxRippleHeight: Float = 0f
-
private fun updateMediaTapToTransferReceiverDisplay(
@StatusBarManager.MediaTransferReceiverState displayState: Int,
routeInfo: MediaRoute2Info,
@@ -206,36 +203,40 @@
override fun animateViewIn(view: ViewGroup) {
val appIconView = view.getAppIconView()
- appIconView.animate()
- .translationYBy(-1 * getTranslationAmount().toFloat())
- .setDuration(ICON_TRANSLATION_ANIM_DURATION)
- .start()
- appIconView.animate()
- .alpha(1f)
- .setDuration(ICON_ALPHA_ANIM_DURATION)
- .start()
+ val iconRippleView: ReceiverChipRippleView = view.requireViewById(R.id.icon_glow_ripple)
+ val rippleView: ReceiverChipRippleView = view.requireViewById(R.id.ripple)
+ animateViewTranslationAndFade(appIconView, -1 * getTranslationAmount(), 1f)
+ animateViewTranslationAndFade(iconRippleView, -1 * getTranslationAmount(), 1f)
// Using withEndAction{} doesn't apply a11y focus when screen is unlocked.
appIconView.postOnAnimation { view.requestAccessibilityFocus() }
- expandRipple(view.requireViewById(R.id.ripple))
+ rippleController.expandToInProgressState(rippleView, iconRippleView)
}
override fun animateViewOut(view: ViewGroup, removalReason: String?, onAnimationEnd: Runnable) {
val appIconView = view.getAppIconView()
- appIconView.animate()
- .translationYBy(getTranslationAmount().toFloat())
- .setDuration(ICON_TRANSLATION_ANIM_DURATION)
- .start()
- appIconView.animate()
- .alpha(0f)
- .setDuration(ICON_ALPHA_ANIM_DURATION)
- .start()
-
+ val iconRippleView: ReceiverChipRippleView = view.requireViewById(R.id.icon_glow_ripple)
val rippleView: ReceiverChipRippleView = view.requireViewById(R.id.ripple)
if (removalReason == ChipStateReceiver.TRANSFER_TO_RECEIVER_SUCCEEDED.name &&
mediaTttFlags.isMediaTttReceiverSuccessRippleEnabled()) {
- expandRippleToFull(rippleView, onAnimationEnd)
+ rippleController.expandToSuccessState(rippleView, onAnimationEnd)
+ animateViewTranslationAndFade(
+ iconRippleView,
+ -1 * getTranslationAmount(),
+ 0f,
+ translationDuration = ICON_TRANSLATION_SUCCEEDED_DURATION,
+ alphaDuration = ICON_TRANSLATION_SUCCEEDED_DURATION,
+ )
+ animateViewTranslationAndFade(
+ appIconView,
+ -1 * getTranslationAmount(),
+ 0f,
+ translationDuration = ICON_TRANSLATION_SUCCEEDED_DURATION,
+ alphaDuration = ICON_TRANSLATION_SUCCEEDED_DURATION,
+ )
} else {
- rippleView.collapseRipple(onAnimationEnd)
+ rippleController.collapseRipple(rippleView, onAnimationEnd)
+ animateViewTranslationAndFade(iconRippleView, getTranslationAmount(), 0f)
+ animateViewTranslationAndFade(appIconView, getTranslationAmount(), 0f)
}
}
@@ -245,74 +246,41 @@
viewUtil.setRectToViewWindowLocation(view.getAppIconView(), outRect)
}
+ /** Animation of view translation and fading. */
+ private fun animateViewTranslationAndFade(
+ view: View,
+ translationYBy: Float,
+ alphaEndValue: Float,
+ translationDuration: Long = ICON_TRANSLATION_ANIM_DURATION,
+ alphaDuration: Long = ICON_ALPHA_ANIM_DURATION,
+ ) {
+ view.animate()
+ .translationYBy(translationYBy)
+ .setDuration(translationDuration)
+ .start()
+ view.animate()
+ .alpha(alphaEndValue)
+ .setDuration(alphaDuration)
+ .start()
+ }
+
/** Returns the amount that the chip will be translated by in its intro animation. */
- private fun getTranslationAmount(): Int {
- return context.resources.getDimensionPixelSize(R.dimen.media_ttt_receiver_vert_translation)
- }
-
- private fun expandRipple(rippleView: ReceiverChipRippleView) {
- if (rippleView.rippleInProgress()) {
- // Skip if ripple is still playing
- return
- }
-
- // In case the device orientation changes, we need to reset the layout.
- rippleView.addOnLayoutChangeListener (
- View.OnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
- if (v == null) return@OnLayoutChangeListener
-
- val layoutChangedRippleView = v as ReceiverChipRippleView
- layoutRipple(layoutChangedRippleView)
- layoutChangedRippleView.invalidate()
- }
- )
- rippleView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
- override fun onViewDetachedFromWindow(view: View?) {}
-
- override fun onViewAttachedToWindow(view: View?) {
- if (view == null) {
- return
- }
- val attachedRippleView = view as ReceiverChipRippleView
- layoutRipple(attachedRippleView)
- attachedRippleView.expandRipple()
- attachedRippleView.removeOnAttachStateChangeListener(this)
- }
- })
- }
-
- private fun layoutRipple(rippleView: ReceiverChipRippleView, isFullScreen: Boolean = false) {
- val windowBounds = windowManager.currentWindowMetrics.bounds
- val height = windowBounds.height().toFloat()
- val width = windowBounds.width().toFloat()
-
- if (isFullScreen) {
- maxRippleHeight = height * 2f
- maxRippleWidth = width * 2f
- } else {
- maxRippleHeight = height / 2f
- maxRippleWidth = width / 2f
- }
- rippleView.setMaxSize(maxRippleWidth, maxRippleHeight)
- // Center the ripple on the bottom of the screen in the middle.
- rippleView.setCenter(width * 0.5f, height)
- val color = Utils.getColorAttrDefaultColor(context, R.attr.wallpaperTextColorAccent)
- rippleView.setColor(color, 70)
+ private fun getTranslationAmount(): Float {
+ return rippleController.getRippleSize() * 0.5f -
+ rippleController.getReceiverIconSize()
}
private fun View.getAppIconView(): CachingIconView {
return this.requireViewById(R.id.app_icon)
}
- private fun expandRippleToFull(rippleView: ReceiverChipRippleView, onAnimationEnd: Runnable?) {
- layoutRipple(rippleView, true)
- rippleView.expandToFull(maxRippleHeight, onAnimationEnd)
+ companion object {
+ private const val ICON_TRANSLATION_ANIM_DURATION = 500L
+ private const val ICON_TRANSLATION_SUCCEEDED_DURATION = 167L
+ private val ICON_ALPHA_ANIM_DURATION = 5.frames
}
}
-val ICON_TRANSLATION_ANIM_DURATION = 30.frames
-val ICON_ALPHA_ANIM_DURATION = 5.frames
-
data class ChipReceiverInfo(
val routeInfo: MediaRoute2Info,
val appIconDrawableOverride: Drawable?,
diff --git a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverRippleController.kt b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverRippleController.kt
new file mode 100644
index 0000000..5013802
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverRippleController.kt
@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2023 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.media.taptotransfer.receiver
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.view.View
+import android.view.WindowManager
+import com.android.settingslib.Utils
+import com.android.systemui.R
+import javax.inject.Inject
+
+/**
+ * A controller responsible for the animation of the ripples shown in media tap-to-transfer on the
+ * receiving device.
+ */
+class MediaTttReceiverRippleController
+@Inject
+constructor(
+ private val context: Context,
+ private val windowManager: WindowManager,
+) {
+
+ private var maxRippleWidth: Float = 0f
+ private var maxRippleHeight: Float = 0f
+
+ /** Expands the icon and main ripple to in-progress state */
+ fun expandToInProgressState(
+ mainRippleView: ReceiverChipRippleView,
+ iconRippleView: ReceiverChipRippleView,
+ ) {
+ expandRipple(mainRippleView, isIconRipple = false)
+ expandRipple(iconRippleView, isIconRipple = true)
+ }
+
+ private fun expandRipple(rippleView: ReceiverChipRippleView, isIconRipple: Boolean) {
+ if (rippleView.rippleInProgress()) {
+ // Skip if ripple is still playing
+ return
+ }
+
+ // In case the device orientation changes, we need to reset the layout.
+ rippleView.addOnLayoutChangeListener(
+ View.OnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
+ if (v == null) return@OnLayoutChangeListener
+
+ val layoutChangedRippleView = v as ReceiverChipRippleView
+ if (isIconRipple) {
+ layoutIconRipple(layoutChangedRippleView)
+ } else {
+ layoutRipple(layoutChangedRippleView)
+ }
+ layoutChangedRippleView.invalidate()
+ }
+ )
+ rippleView.addOnAttachStateChangeListener(
+ object : View.OnAttachStateChangeListener {
+ override fun onViewDetachedFromWindow(view: View?) {}
+
+ override fun onViewAttachedToWindow(view: View?) {
+ if (view == null) {
+ return
+ }
+ val attachedRippleView = view as ReceiverChipRippleView
+ if (isIconRipple) {
+ layoutIconRipple(attachedRippleView)
+ } else {
+ layoutRipple(attachedRippleView)
+ }
+ attachedRippleView.expandRipple()
+ attachedRippleView.removeOnAttachStateChangeListener(this)
+ }
+ }
+ )
+ }
+
+ /** Expands the ripple to cover the screen. */
+ fun expandToSuccessState(rippleView: ReceiverChipRippleView, onAnimationEnd: Runnable?) {
+ layoutRipple(rippleView, isFullScreen = true)
+ rippleView.expandToFull(maxRippleHeight, onAnimationEnd)
+ }
+
+ /** Collapses the ripple. */
+ fun collapseRipple(rippleView: ReceiverChipRippleView, onAnimationEnd: Runnable? = null) {
+ rippleView.collapseRipple(onAnimationEnd)
+ }
+
+ private fun layoutRipple(rippleView: ReceiverChipRippleView, isFullScreen: Boolean = false) {
+ val windowBounds = windowManager.currentWindowMetrics.bounds
+ val height = windowBounds.height().toFloat()
+ val width = windowBounds.width().toFloat()
+
+ if (isFullScreen) {
+ maxRippleHeight = height * 2f
+ maxRippleWidth = width * 2f
+ } else {
+ maxRippleHeight = getRippleSize()
+ maxRippleWidth = getRippleSize()
+ }
+ rippleView.setMaxSize(maxRippleWidth, maxRippleHeight)
+ // Center the ripple on the bottom of the screen in the middle.
+ rippleView.setCenter(width * 0.5f, height)
+ rippleView.setColor(getRippleColor(), RIPPLE_OPACITY)
+ }
+
+ private fun layoutIconRipple(iconRippleView: ReceiverChipRippleView) {
+ val windowBounds = windowManager.currentWindowMetrics.bounds
+ val height = windowBounds.height().toFloat()
+ val width = windowBounds.width().toFloat()
+ val radius = getReceiverIconSize().toFloat()
+
+ iconRippleView.setMaxSize(radius * 0.8f, radius * 0.8f)
+ iconRippleView.setCenter(
+ width * 0.5f,
+ height - getReceiverIconSize() * 0.5f - getReceiverIconBottomMargin()
+ )
+ iconRippleView.setColor(getRippleColor(), RIPPLE_OPACITY)
+ }
+
+ private fun getRippleColor(): Int {
+ var colorStateList =
+ ColorStateList.valueOf(
+ Utils.getColorAttrDefaultColor(context, R.attr.wallpaperTextColorAccent)
+ )
+ return colorStateList.withLStar(TONE_PERCENT).defaultColor
+ }
+
+ /** Returns the size of the ripple. */
+ internal fun getRippleSize(): Float {
+ return getReceiverIconSize() * 4f
+ }
+
+ /** Returns the size of the icon of the receiver. */
+ internal fun getReceiverIconSize(): Int {
+ return context.resources.getDimensionPixelSize(R.dimen.media_ttt_icon_size_receiver)
+ }
+
+ /** Return the bottom margin of the icon of the receiver. */
+ internal fun getReceiverIconBottomMargin(): Int {
+ // Adding a margin to make sure ripple behind the icon is not cut by the screen bounds.
+ return context.resources.getDimensionPixelSize(
+ R.dimen.media_ttt_receiver_icon_bottom_margin
+ )
+ }
+
+ companion object {
+ const val RIPPLE_OPACITY = 70
+ const val TONE_PERCENT = 95f
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/ReceiverChipRippleView.kt b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/ReceiverChipRippleView.kt
index 87b2528..f8785fc 100644
--- a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/ReceiverChipRippleView.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/ReceiverChipRippleView.kt
@@ -33,14 +33,14 @@
private var isStarted: Boolean
init {
- setupShader(RippleShader.RippleShape.ELLIPSE)
+ setupShader(RippleShader.RippleShape.CIRCLE)
setRippleFill(true)
setSparkleStrength(0f)
- duration = 3000L
isStarted = false
}
fun expandRipple(onAnimationEnd: Runnable? = null) {
+ duration = DEFAULT_DURATION
isStarted = true
super.startRipple(onAnimationEnd)
}
@@ -50,6 +50,7 @@
if (!isStarted) {
return // Ignore if ripple is not started yet.
}
+ duration = DEFAULT_DURATION
// Reset all listeners to animator.
animator.removeAllListeners()
animator.addListener(object : AnimatorListenerAdapter() {
@@ -74,6 +75,7 @@
setRippleFill(false)
val startingPercentage = calculateStartingPercentage(newHeight)
+ animator.duration = EXPAND_TO_FULL_DURATION
animator.addUpdateListener { updateListener ->
val now = updateListener.currentPlayTime
val progress = updateListener.animatedValue as Float
@@ -100,4 +102,9 @@
val remainingPercentage = (1 - ratio).toDouble().pow(1 / 3.toDouble()).toFloat()
return 1 - remainingPercentage
}
+
+ companion object {
+ const val DEFAULT_DURATION = 333L
+ const val EXPAND_TO_FULL_DURATION = 1000L
+ }
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/taptotransfer/receiver/FakeMediaTttChipControllerReceiver.kt b/packages/SystemUI/tests/src/com/android/systemui/media/taptotransfer/receiver/FakeMediaTttChipControllerReceiver.kt
index 9c4e849..b3e621e 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/taptotransfer/receiver/FakeMediaTttChipControllerReceiver.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/taptotransfer/receiver/FakeMediaTttChipControllerReceiver.kt
@@ -48,6 +48,7 @@
viewUtil: ViewUtil,
wakeLockBuilder: WakeLock.Builder,
systemClock: SystemClock,
+ rippleController: MediaTttReceiverRippleController,
) :
MediaTttChipControllerReceiver(
commandQueue,
@@ -65,6 +66,7 @@
viewUtil,
wakeLockBuilder,
systemClock,
+ rippleController,
) {
override fun animateViewOut(view: ViewGroup, removalReason: String?, onAnimationEnd: Runnable) {
// Just bypass the animation in tests
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/taptotransfer/receiver/MediaTttChipControllerReceiverTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/taptotransfer/receiver/MediaTttChipControllerReceiverTest.kt
index cefc742..5e40898 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/taptotransfer/receiver/MediaTttChipControllerReceiverTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/taptotransfer/receiver/MediaTttChipControllerReceiverTest.kt
@@ -85,6 +85,8 @@
private lateinit var windowManager: WindowManager
@Mock
private lateinit var commandQueue: CommandQueue
+ @Mock
+ private lateinit var rippleController: MediaTttReceiverRippleController
private lateinit var commandQueueCallback: CommandQueue.Callbacks
private lateinit var fakeAppIconDrawable: Drawable
private lateinit var uiEventLoggerFake: UiEventLoggerFake
@@ -134,6 +136,7 @@
viewUtil,
fakeWakeLockBuilder,
fakeClock,
+ rippleController,
)
controllerReceiver.start()
@@ -163,6 +166,7 @@
viewUtil,
fakeWakeLockBuilder,
fakeClock,
+ rippleController,
)
controllerReceiver.start()