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.AnimationClockObservable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.FloatPropKey
import androidx.compose.animation.core.InterruptionHandling
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.TransitionAnimation
import androidx.compose.animation.core.createAnimation
import androidx.compose.animation.core.transitionDefinition
import androidx.compose.animation.core.tween
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.animation.OffsetPropKey
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.inMilliseconds
import androidx.compose.ui.unit.milliseconds
import kotlin.math.max

/**
 * [RippleAnimation]s are drawn as part of [RippleIndication] as a visual indicator for an
 * [androidx.compose.foundation.Interaction.Pressed] state.
 *
 * Use [androidx.compose.foundation.clickable] or [androidx.compose.foundation.indication] to add a
 * [RippleIndication] 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.
 *
 * A circular ripple effect whose origin starts at the input touch point and
 * whose radius expands 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 size The size of the target layout.
 * @param startPosition The position the animation will start from.
 * @param radius Effects grow up to this size.
 * @param clipped If true the effect should be clipped by the target layout bounds.
 * @param clock The animation clock observable that will drive this ripple effect
 * @param onAnimationFinished Call when the effect animation has been finished.
 */
internal class RippleAnimation(
    size: Size,
    startPosition: Offset,
    radius: Float,
    private val clipped: Boolean,
    clock: AnimationClockObservable,
    private val onAnimationFinished: (RippleAnimation) -> Unit
) {

    private val animation: TransitionAnimation<RippleTransition.State>
    private var transitionState = RippleTransition.State.Initial
    private var finishRequested = false
    private var animationPulse by mutableStateOf(0L)

    init {
        val surfaceSize = size
        val startRadius = getRippleStartRadius(surfaceSize)
        val targetRadius = radius

        val center = size.center()
        animation = RippleTransition.definition(
            startRadius = startRadius,
            endRadius = targetRadius,
            startCenter = startPosition,
            endCenter = center
        ).createAnimation(clock)
        animation.onUpdate = {
            // TODO We shouldn't need this animationPulse hack b/152631516
            animationPulse++
        }
        animation.onStateChangeFinished = { stage ->
            transitionState = stage
            if (transitionState == RippleTransition.State.Finished) {
                onAnimationFinished(this)
            }
        }
        // currently we are in Initial state, now we start the animation:
        animation.toState(RippleTransition.State.Revealed)
    }

    fun finish() {
        finishRequested = true
        animation.toState(RippleTransition.State.Finished)
    }

    fun DrawScope.draw(color: Color) {
        animationPulse // model read so we will be redrawn with the next animation values

        val alpha = if (transitionState == RippleTransition.State.Initial && finishRequested) {
            // if we still fading-in we should immediately switch to the final alpha.
            1f
        } else {
            animation[RippleTransition.Alpha]
        }

        val centerOffset = animation[RippleTransition.Center]
        val radius = animation[RippleTransition.Radius]

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

/**
 * The Ripple transition specification.
 */
private object RippleTransition {

    enum class State {
        /** The starting state.  */
        Initial,
        /** User is still touching the surface.  */
        Revealed,
        /** User stopped touching the surface.  */
        Finished
    }

    private val FadeInDuration = 75.milliseconds
    private val RadiusDuration = 225.milliseconds
    private val FadeOutDuration = 150.milliseconds

    val Alpha = FloatPropKey()
    val Radius = FloatPropKey()
    val Center = OffsetPropKey()

    fun definition(
        startRadius: Float,
        endRadius: Float,
        startCenter: Offset,
        endCenter: Offset
    ) = transitionDefinition<State> {
        state(State.Initial) {
            this[Alpha] = 0f
            this[Radius] = startRadius
            this[Center] = startCenter
        }
        state(State.Revealed) {
            this[Alpha] = 1f
            this[Radius] = endRadius
            this[Center] = endCenter
        }
        state(State.Finished) {
            this[Alpha] = 0f
            // the rest are the same as for Revealed
            this[Radius] = endRadius
            this[Center] = endCenter
        }
        transition(State.Initial to State.Revealed) {
            Alpha using tween(
                durationMillis = FadeInDuration.inMilliseconds().toInt(),
                easing = LinearEasing
            )
            Radius using tween(
                durationMillis = RadiusDuration.inMilliseconds().toInt(),
                easing = FastOutSlowInEasing
            )
            Center using tween(
                durationMillis = RadiusDuration.inMilliseconds().toInt(),
                easing = LinearEasing
            )
            // we need to always finish the radius animation before starting fading out
            interruptionHandling = InterruptionHandling.UNINTERRUPTIBLE
        }
        transition(State.Revealed to State.Finished) {
            fun <T> toFinished() =
                tween<T>(
                    durationMillis = FadeOutDuration.inMilliseconds().toInt(),
                    easing = LinearEasing
                )

            Alpha using toFinished()
            Radius using toFinished()
            Center using toFinished()
        }
    }
}

/**
 * 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