package com.android.systemui.statusbar.notification import android.util.FloatProperty import android.view.View import androidx.annotation.FloatRange import com.android.systemui.res.R import com.android.systemui.statusbar.notification.shared.NotificationsImprovedHunAnimation import com.android.systemui.statusbar.notification.stack.AnimationProperties import com.android.systemui.statusbar.notification.stack.StackStateAnimator import kotlin.math.abs /** * Interface that allows to request/retrieve top and bottom roundness (a value between 0f and 1f). * * To request a roundness value, an [SourceType] must be specified. In case more origins require * different roundness, for the same property, the maximum value will always be chosen. * * It also returns the current radius for all corners ([updatedRadii]). */ interface Roundable { /** Properties required for a Roundable */ val roundableState: RoundableState val clipHeight: Int /** Current top roundness */ @get:FloatRange(from = 0.0, to = 1.0) val topRoundness: Float get() = roundableState.topRoundness /** Current bottom roundness */ @get:FloatRange(from = 0.0, to = 1.0) val bottomRoundness: Float get() = roundableState.bottomRoundness /** Max radius in pixel */ val maxRadius: Float get() = roundableState.maxRadius /** Current top corner in pixel, based on [topRoundness] and [maxRadius] */ val topCornerRadius: Float get() = if (NotificationsImprovedHunAnimation.isEnabled) roundableState.topCornerRadius else topRoundness * maxRadius /** Current bottom corner in pixel, based on [bottomRoundness] and [maxRadius] */ val bottomCornerRadius: Float get() = if (NotificationsImprovedHunAnimation.isEnabled) roundableState.bottomCornerRadius else bottomRoundness * maxRadius /** Get and update the current radii */ val updatedRadii: FloatArray get() = roundableState.radiiBuffer.also { radii -> updateRadii( topCornerRadius = topCornerRadius, bottomCornerRadius = bottomCornerRadius, radii = radii, ) } /** * Request the top roundness [value] for a specific [sourceType]. * * The top roundness of a [Roundable] can be defined by different [sourceType]. In case more * origins require different roundness, for the same property, the maximum value will always be * chosen. * * @param value a value between 0f and 1f. * @param animate true if it should animate to that value. * @param sourceType the source from which the request for roundness comes. * @return Whether the roundness was changed. */ fun requestTopRoundness( @FloatRange(from = 0.0, to = 1.0) value: Float, sourceType: SourceType, animate: Boolean, ): Boolean { val roundnessMap = roundableState.topRoundnessMap val lastValue = roundnessMap.values.maxOrNull() ?: 0f if (value == 0f) { // we should only take the largest value, and since the smallest value is 0f, we can // remove this value from the list. In the worst case, the list is empty and the // default value is 0f. roundnessMap.remove(sourceType) } else { roundnessMap[sourceType] = value } val newValue = roundnessMap.values.maxOrNull() ?: 0f if (lastValue != newValue) { val wasAnimating = roundableState.isTopAnimating() // Fail safe: // when we've been animating previously and we're now getting an update in the // other direction, make sure to animate it too, otherwise, the localized updating // may make the start larger than 1.0. val shouldAnimate = wasAnimating && abs(newValue - lastValue) > 0.5f roundableState.setTopRoundness(value = newValue, animated = shouldAnimate || animate) return true } return false } /** * Request the top roundness [value] for a specific [sourceType]. Animate the roundness if the * view is shown. * * The top roundness of a [Roundable] can be defined by different [sourceType]. In case more * origins require different roundness, for the same property, the maximum value will always be * chosen. * * @param value a value between 0f and 1f. * @param sourceType the source from which the request for roundness comes. * @return Whether the roundness was changed. */ fun requestTopRoundness( @FloatRange(from = 0.0, to = 1.0) value: Float, sourceType: SourceType, ): Boolean { return requestTopRoundness( value = value, sourceType = sourceType, animate = roundableState.targetView.isShown ) } /** * Request the bottom roundness [value] for a specific [sourceType]. * * The bottom roundness of a [Roundable] can be defined by different [sourceType]. In case more * origins require different roundness, for the same property, the maximum value will always be * chosen. * * @param value value between 0f and 1f. * @param animate true if it should animate to that value. * @param sourceType the source from which the request for roundness comes. * @return Whether the roundness was changed. */ fun requestBottomRoundness( @FloatRange(from = 0.0, to = 1.0) value: Float, sourceType: SourceType, animate: Boolean, ): Boolean { val roundnessMap = roundableState.bottomRoundnessMap val lastValue = roundnessMap.values.maxOrNull() ?: 0f if (value == 0f) { // we should only take the largest value, and since the smallest value is 0f, we can // remove this value from the list. In the worst case, the list is empty and the // default value is 0f. roundnessMap.remove(sourceType) } else { roundnessMap[sourceType] = value } val newValue = roundnessMap.values.maxOrNull() ?: 0f if (lastValue != newValue) { val wasAnimating = roundableState.isBottomAnimating() // Fail safe: // when we've been animating previously and we're now getting an update in the // other direction, make sure to animate it too, otherwise, the localized updating // may make the start larger than 1.0. val shouldAnimate = wasAnimating && abs(newValue - lastValue) > 0.5f roundableState.setBottomRoundness(value = newValue, animated = shouldAnimate || animate) return true } return false } /** * Request the bottom roundness [value] for a specific [sourceType]. Animate the roundness if * the view is shown. * * The bottom roundness of a [Roundable] can be defined by different [sourceType]. In case more * origins require different roundness, for the same property, the maximum value will always be * chosen. * * @param value value between 0f and 1f. * @param sourceType the source from which the request for roundness comes. * @return Whether the roundness was changed. */ fun requestBottomRoundness( @FloatRange(from = 0.0, to = 1.0) value: Float, sourceType: SourceType, ): Boolean { return requestBottomRoundness( value = value, sourceType = sourceType, animate = roundableState.targetView.isShown ) } /** * Request the roundness [value] for a specific [sourceType]. * * The top/bottom roundness of a [Roundable] can be defined by different [sourceType]. In case * more origins require different roundness, for the same property, the maximum value will * always be chosen. * * @param top top value between 0f and 1f. * @param bottom bottom value between 0f and 1f. * @param sourceType the source from which the request for roundness comes. * @param animate true if it should animate to that value. * @return Whether the roundness was changed. */ fun requestRoundness( @FloatRange(from = 0.0, to = 1.0) top: Float, @FloatRange(from = 0.0, to = 1.0) bottom: Float, sourceType: SourceType, animate: Boolean, ): Boolean { val hasTopChanged = requestTopRoundness(value = top, sourceType = sourceType, animate = animate) val hasBottomChanged = requestBottomRoundness(value = bottom, sourceType = sourceType, animate = animate) return hasTopChanged || hasBottomChanged } /** * Request the roundness [value] for a specific [sourceType]. Animate the roundness if the view * is shown. * * The top/bottom roundness of a [Roundable] can be defined by different [sourceType]. In case * more origins require different roundness, for the same property, the maximum value will * always be chosen. * * @param top top value between 0f and 1f. * @param bottom bottom value between 0f and 1f. * @param sourceType the source from which the request for roundness comes. * @return Whether the roundness was changed. */ fun requestRoundness( @FloatRange(from = 0.0, to = 1.0) top: Float, @FloatRange(from = 0.0, to = 1.0) bottom: Float, sourceType: SourceType, ): Boolean { return requestRoundness( top = top, bottom = bottom, sourceType = sourceType, animate = roundableState.targetView.isShown, ) } /** * Request the roundness 0f for a [SourceType]. * * The top/bottom roundness of a [Roundable] can be defined by different [sourceType]. In case * more origins require different roundness, for the same property, the maximum value will * always be chosen. * * @param sourceType the source from which the request for roundness comes. * @param animate true if it should animate to that value. */ fun requestRoundnessReset(sourceType: SourceType, animate: Boolean) { requestRoundness(top = 0f, bottom = 0f, sourceType = sourceType, animate = animate) } /** * Request the roundness 0f for a [SourceType]. Animate the roundness if the view is shown. * * The top/bottom roundness of a [Roundable] can be defined by different [sourceType]. In case * more origins require different roundness, for the same property, the maximum value will * always be chosen. * * @param sourceType the source from which the request for roundness comes. */ fun requestRoundnessReset(sourceType: SourceType) { requestRoundnessReset(sourceType = sourceType, animate = roundableState.targetView.isShown) } /** Apply the roundness changes, usually means invalidate the [RoundableState.targetView]. */ fun applyRoundnessAndInvalidate() { roundableState.targetView.invalidate() } /** @return true if top or bottom roundness is not zero. */ fun hasRoundedCorner(): Boolean { return topRoundness != 0f || bottomRoundness != 0f } /** * Update an Array of 8 values, 4 pairs of [X,Y] radii. As expected by param radii of * [android.graphics.Path.addRoundRect]. * * This method reuses the previous [radii] for performance reasons. */ fun updateRadii( topCornerRadius: Float, bottomCornerRadius: Float, radii: FloatArray, ) { if (radii.size != 8) error("Unexpected radiiBuffer size ${radii.size}") if (radii[0] != topCornerRadius || radii[4] != bottomCornerRadius) { (0..3).forEach { radii[it] = topCornerRadius } (4..7).forEach { radii[it] = bottomCornerRadius } } } } /** * State object for a `Roundable` class. * * @param targetView Will handle the [AnimatableProperty] * @param roundable Target of the radius animation * @param maxRadius Max corner radius in pixels */ class RoundableState @JvmOverloads constructor( internal val targetView: View, private val roundable: Roundable, maxRadius: Float, ) { internal var maxRadius = maxRadius private set /** Animatable for top roundness */ private val topAnimatable = topAnimatable(roundable) /** Animatable for bottom roundness */ private val bottomAnimatable = bottomAnimatable(roundable) /** Current top roundness. Use [setTopRoundness] to update this value */ @set:FloatRange(from = 0.0, to = 1.0) internal var topRoundness = 0f private set /** Current bottom roundness. Use [setBottomRoundness] to update this value */ @set:FloatRange(from = 0.0, to = 1.0) internal var bottomRoundness = 0f private set internal val topCornerRadius: Float get() { val height = roundable.clipHeight val topRadius = topRoundness * maxRadius val bottomRadius = bottomRoundness * maxRadius if (height == 0) { return 0f } else if (topRadius + bottomRadius > height) { // The sum of top and bottom corner radii should be at max the clipped height val overShoot = topRadius + bottomRadius - height return topRadius - (overShoot * topRoundness / (topRoundness + bottomRoundness)) } return topRadius } internal val bottomCornerRadius: Float get() { val height = roundable.clipHeight val topRadius = topRoundness * maxRadius val bottomRadius = bottomRoundness * maxRadius if (height == 0) { return 0f } else if (topRadius + bottomRadius > height) { // The sum of top and bottom corner radii should be at max the clipped height val overShoot = topRadius + bottomRadius - height return bottomRadius - (overShoot * bottomRoundness / (topRoundness + bottomRoundness)) } return bottomRadius } /** Last requested top roundness associated by [SourceType] */ internal val topRoundnessMap = mutableMapOf() /** Last requested bottom roundness associated by [SourceType] */ internal val bottomRoundnessMap = mutableMapOf() /** Last cached radii */ internal val radiiBuffer = FloatArray(8) /** Is top roundness animation in progress? */ internal fun isTopAnimating() = PropertyAnimator.isAnimating(targetView, topAnimatable) /** Is bottom roundness animation in progress? */ internal fun isBottomAnimating() = PropertyAnimator.isAnimating(targetView, bottomAnimatable) /** Set the current top roundness */ internal fun setTopRoundness( value: Float, animated: Boolean, ) { PropertyAnimator.setProperty(targetView, topAnimatable, value, DURATION, animated) } /** Set the current bottom roundness */ internal fun setBottomRoundness( value: Float, animated: Boolean, ) { PropertyAnimator.setProperty(targetView, bottomAnimatable, value, DURATION, animated) } fun setMaxRadius(radius: Float) { if (maxRadius != radius) { maxRadius = radius roundable.applyRoundnessAndInvalidate() } } fun debugString() = buildString { append("Roundable { ") append("top: { value: $topRoundness, requests: $topRoundnessMap}") append(", ") append("bottom: { value: $bottomRoundness, requests: $bottomRoundnessMap}") append("}") } companion object { private val DURATION: AnimationProperties = AnimationProperties() .setDuration(StackStateAnimator.ANIMATION_DURATION_CORNER_RADIUS.toLong()) private fun topAnimatable(roundable: Roundable): AnimatableProperty = AnimatableProperty.from( object : FloatProperty("topRoundness") { override fun get(view: View): Float = roundable.topRoundness override fun setValue(view: View, value: Float) { roundable.roundableState.topRoundness = value roundable.applyRoundnessAndInvalidate() } }, R.id.top_roundess_animator_tag, R.id.top_roundess_animator_end_tag, R.id.top_roundess_animator_start_tag, ) private fun bottomAnimatable(roundable: Roundable): AnimatableProperty = AnimatableProperty.from( object : FloatProperty("bottomRoundness") { override fun get(view: View): Float = roundable.bottomRoundness override fun setValue(view: View, value: Float) { roundable.roundableState.bottomRoundness = value roundable.applyRoundnessAndInvalidate() } }, R.id.bottom_roundess_animator_tag, R.id.bottom_roundess_animator_end_tag, R.id.bottom_roundess_animator_start_tag, ) } } /** * Interface used to define the owner of a roundness. Usually the [SourceType] is defined as a * private property of a class. */ interface SourceType { companion object { /** * This is the most convenient way to define a new [SourceType]. * * For example: * ```kotlin * private val SECTION = SourceType.from("Section") * ``` */ @JvmStatic fun from(name: String) = object : SourceType { override fun toString() = name } } }