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.OffsetPropKey
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.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