diff options
7 files changed, 237 insertions, 83 deletions
diff --git a/packages/SystemUI/res/layout/media_ttt_chip_receiver.xml b/packages/SystemUI/res/layout/media_ttt_chip_receiver.xml index 21d12c278453..4483db8aeb6f 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 890d96444b04..a68e7591d4fb 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 889147b598b5..6884370c505c 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.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) } - /** 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) - } - }) + /** 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() } - 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) + /** Returns the amount that the chip will be translated by in its intro animation. */ + 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 000000000000..50138024e268 --- /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 87b2528f93d3..f8785fcf5de0 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 @@ 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 + } } 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 9c4e849df738..b3e621e32d08 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 @@ 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 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 cefc7424324b..5e40898030cf 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 @@ 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() |