RippleAnimation.kt

/*
 * Copyright 2019 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 androidx.compose.material.ripple

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isUnspecified
import androidx.compose.ui.util.lerp
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlin.math.max

/**
 * [RippleAnimation]s are drawn as part of [Ripple] as a visual indicator for an
 * different [androidx.compose.foundation.interaction.Interaction]s.
 *
 * Use [androidx.compose.foundation.clickable] or [androidx.compose.foundation.indication] to add a
 * ripple to your component, which contains a RippleAnimation for pressed states, and
 * a state layer for other states.
 *
 * This is a default implementation based on the Material Design specification.
 *
 * Draws a circular ripple effect with an origin starting at the input touch point and with a
 * radius expanding from 60% of the final value. The ripple origin animates to the center of its
 * target layout for the bounded version and stays in the center for the unbounded one.
 *
 * @param origin The position the animation will start from. If null the animation will start
 * from the center of the layout bounds.
 * @param radius Effects grow up to this size.
 * @param bounded If true the effect should be clipped by the target layout bounds.
 */
internal class RippleAnimation(
    private var origin: Offset?,
    private val radius: Dp,
    private val bounded: Boolean
) {
    private var startRadius: Float? = null
    private var targetRadius: Float? = null

    private var targetCenter: Offset? = null

    private val animatedAlpha = Animatable(0f)
    private val animatedRadiusPercent = Animatable(0f)
    private val animatedCenterPercent = Animatable(0f)

    private val finishSignalDeferred = CompletableDeferred<Unit>(null)

    private var finishedFadingIn by mutableStateOf(false)
    private var finishRequested by mutableStateOf(false)

    suspend fun animate() {
        fadeIn()
        finishedFadingIn = true
        finishSignalDeferred.await()
        fadeOut()
    }

    private suspend fun fadeIn() {
        coroutineScope {
            launch {
                animatedAlpha.animateTo(
                    1f,
                    tween(durationMillis = FadeInDuration, easing = LinearEasing)
                )
            }
            launch {
                animatedRadiusPercent.animateTo(
                    1f,
                    tween(durationMillis = RadiusDuration, easing = FastOutSlowInEasing)
                )
            }
            launch {
                animatedCenterPercent.animateTo(
                    1f,
                    tween(durationMillis = RadiusDuration, easing = LinearEasing)
                )
            }
        }
    }

    private suspend fun fadeOut() {
        coroutineScope {
            launch {
                animatedAlpha.animateTo(
                    0f,
                    tween(durationMillis = FadeOutDuration, easing = LinearEasing)
                )
            }
        }
    }

    fun finish() {
        finishRequested = true
        finishSignalDeferred.complete(Unit)
    }

    fun DrawScope.draw(color: Color) {
        if (startRadius == null) {
            startRadius = getRippleStartRadius(size)
        }
        if (targetRadius == null) {
            targetRadius = if (radius.isUnspecified) {
                getRippleEndRadius(bounded, size)
            } else {
                radius.toPx()
            }
        }
        if (origin == null) {
            origin = center
        }
        if (targetCenter == null) {
            targetCenter = Offset(size.width / 2.0f, size.height / 2.0f)
        }

        val alpha = if (finishRequested && !finishedFadingIn) {
            // If we are still fading-in we should immediately switch to the final alpha.
            1f
        } else {
            animatedAlpha.value
        }

        val radius = lerp(startRadius!!, targetRadius!!, animatedRadiusPercent.value)
        val centerOffset = Offset(
            lerp(origin!!.x, targetCenter!!.x, animatedCenterPercent.value),
            lerp(origin!!.y, targetCenter!!.y, animatedCenterPercent.value),
        )

        val modulatedColor = color.copy(alpha = color.alpha * alpha)
        if (bounded) {
            clipRect {
                drawCircle(modulatedColor, radius, centerOffset)
            }
        } else {
            drawCircle(modulatedColor, radius, centerOffset)
        }
    }
}

/**
 * According to specs the starting radius is equal to 60% of the largest dimension of the
 * surface it belongs to.
 */
internal fun getRippleStartRadius(size: Size) =
    max(size.width, size.height) * 0.3f

/**
 * According to specs the ending radius
 * - expands to 10dp beyond the border of the surface it belongs to for bounded ripples
 * - fits within the border of the surface it belongs to for unbounded ripples
 */
internal fun Density.getRippleEndRadius(bounded: Boolean, size: Size): Float {
    val radiusCoveringBounds =
        (Offset(size.width, size.height).getDistance() / 2f)
    return if (bounded) {
        radiusCoveringBounds + BoundedRippleExtraRadius.toPx()
    } else {
        radiusCoveringBounds
    }
}

private val BoundedRippleExtraRadius = 10.dp

private const val FadeInDuration = 75
private const val RadiusDuration = 225
private const val FadeOutDuration = 150