[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
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -31,7 +31,6 @@ import android.view.ViewGroup
|
||||
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 @@ open class MediaTttChipControllerReceiver @Inject constructor(
|
||||
private val viewUtil: ViewUtil,
|
||||
wakeLockBuilder: WakeLock.Builder,
|
||||
systemClock: SystemClock,
|
||||
private val rippleController: MediaTttReceiverRippleController,
|
||||
) : TemporaryViewDisplayController<ChipReceiverInfo, MediaTttLogger<ChipReceiverInfo>>(
|
||||
context,
|
||||
logger,
|
||||
@@ -114,9 +114,6 @@ open class MediaTttChipControllerReceiver @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private var maxRippleWidth: Float = 0f
|
||||
private var maxRippleHeight: Float = 0f
|
||||
|
||||
private fun updateMediaTapToTransferReceiverDisplay(
|
||||
@StatusBarManager.MediaTransferReceiverState displayState: Int,
|
||||
routeInfo: MediaRoute2Info,
|
||||
@@ -206,36 +203,40 @@ open class MediaTttChipControllerReceiver @Inject constructor(
|
||||
|
||||
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 @@ open class MediaTttChipControllerReceiver @Inject constructor(
|
||||
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?,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -33,14 +33,14 @@ class ReceiverChipRippleView(context: Context?, attrs: AttributeSet?) : RippleVi
|
||||
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 @@ class ReceiverChipRippleView(context: Context?, attrs: AttributeSet?) : RippleVi
|
||||
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 @@ class ReceiverChipRippleView(context: Context?, attrs: AttributeSet?) : RippleVi
|
||||
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 @@ class ReceiverChipRippleView(context: Context?, attrs: AttributeSet?) : RippleVi
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ class FakeMediaTttChipControllerReceiver(
|
||||
viewUtil: ViewUtil,
|
||||
wakeLockBuilder: WakeLock.Builder,
|
||||
systemClock: SystemClock,
|
||||
rippleController: MediaTttReceiverRippleController,
|
||||
) :
|
||||
MediaTttChipControllerReceiver(
|
||||
commandQueue,
|
||||
@@ -65,6 +66,7 @@ class FakeMediaTttChipControllerReceiver(
|
||||
viewUtil,
|
||||
wakeLockBuilder,
|
||||
systemClock,
|
||||
rippleController,
|
||||
) {
|
||||
override fun animateViewOut(view: ViewGroup, removalReason: String?, onAnimationEnd: Runnable) {
|
||||
// Just bypass the animation in tests
|
||||
|
||||
@@ -85,6 +85,8 @@ class MediaTttChipControllerReceiverTest : SysuiTestCase() {
|
||||
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 @@ class MediaTttChipControllerReceiverTest : SysuiTestCase() {
|
||||
viewUtil,
|
||||
fakeWakeLockBuilder,
|
||||
fakeClock,
|
||||
rippleController,
|
||||
)
|
||||
controllerReceiver.start()
|
||||
|
||||
@@ -163,6 +166,7 @@ class MediaTttChipControllerReceiverTest : SysuiTestCase() {
|
||||
viewUtil,
|
||||
fakeWakeLockBuilder,
|
||||
fakeClock,
|
||||
rippleController,
|
||||
)
|
||||
controllerReceiver.start()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user