Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 08cf9c39 authored by Michael Mikhail's avatar Michael Mikhail
Browse files

[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
parent d7c56b24
Loading
Loading
Loading
Loading
+9 −0
Original line number Diff line number Diff line
@@ -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>
+1 −0
Original line number Diff line number Diff line
@@ -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>
+49 −81
Original line number Diff line number Diff line
@@ -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?,
+163 −0
Original line number Diff line number Diff line
/*
 * 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
    }
}
+9 −2
Original line number Diff line number Diff line
@@ -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
    }
}
Loading