/*
* Copyright 2020 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.AnimatedFloatModel
import androidx.compose.animation.asDisposableClock
import androidx.compose.animation.core.AnimationClockObservable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.TweenSpec
import androidx.compose.foundation.Indication
import androidx.compose.foundation.IndicationInstance
import androidx.compose.foundation.Interaction
import androidx.compose.foundation.InteractionState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
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.graphics.useOrElse
import androidx.compose.ui.platform.AmbientAnimationClock
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.nativeClass
/**
* Creates and [remember]s a Ripple using values provided by [RippleTheme].
*
* A Ripple is a Material implementation of [Indication] that expresses different [Interaction]s
* by drawing ripple animations and state layers.
*
* A Ripple responds to [Interaction.Pressed] by starting a new [RippleAnimation], and
* responds to other [Interaction]s by showing a fixed [StateLayer] with varying alpha values
* depending on the [Interaction].
*
* If you are using MaterialTheme in your hierarchy, a Ripple will be used as the default
* [Indication] inside components such as [androidx.compose.foundation.clickable] and
* [androidx.compose.foundation.indication]. You can also manually provide Ripples through
* [androidx.compose.foundation.AmbientIndication] for the same effect if you are not using
* MaterialTheme.
*
* You can also explicitly create a Ripple and provide it to components in order to change the
* parameters from the default, such as to create an unbounded ripple with a fixed size.
*
* @param bounded If true, ripples are clipped by the bounds of the target layout. Unbounded
* ripples always animate from the target layout center, bounded ripples animate from the touch
* position.
* @param radius the radius for the ripple. If `null` is provided then the size will be calculated
* based on the target layout size.
* @param color the color of the ripple. This color is usually the same color used by the text or
* iconography in the component. This color will then have [RippleTheme.rippleAlpha] applied to
* calculate the final color used to draw the ripple. If [Color.Unspecified] is provided the color
* used will be [RippleTheme.defaultColor] instead.
*/
@Deprecated(
"Replaced with rememberRipple",
ReplaceWith(
"rememberRipple(bounded, radius, color)",
"androidx.compose.material.ripple.rememberRipple"
)
)
@Composable
@OptIn(ExperimentalRippleApi::class)
public fun rememberRippleIndication(
bounded: Boolean = true,
radius: Dp? = null,
color: Color = Color.Unspecified
): Indication {
val theme = AmbientRippleTheme.current
val clock = AmbientAnimationClock.current.asDisposableClock()
val resolvedColor = color.useOrElse { theme.defaultColor() }
val colorState = remember { mutableStateOf(resolvedColor, structuralEqualityPolicy()) }
colorState.value = resolvedColor
val rippleAlpha = theme.rippleAlpha()
return remember(bounded, radius, theme, clock) {
Ripple(bounded, radius ?: Dp.Unspecified, colorState, rippleAlpha, clock)
}
}
/**
* Creates and [remember]s a Ripple using values provided by [RippleTheme].
*
* A Ripple is a Material implementation of [Indication] that expresses different [Interaction]s
* by drawing ripple animations and state layers.
*
* A Ripple responds to [Interaction.Pressed] by starting a new [RippleAnimation], and
* responds to other [Interaction]s by showing a fixed [StateLayer] with varying alpha values
* depending on the [Interaction].
*
* If you are using MaterialTheme in your hierarchy, a Ripple will be used as the default
* [Indication] inside components such as [androidx.compose.foundation.clickable] and
* [androidx.compose.foundation.indication]. You can also manually provide Ripples through
* [androidx.compose.foundation.AmbientIndication] for the same effect if you are not using
* MaterialTheme.
*
* You can also explicitly create a Ripple and provide it to components in order to change the
* parameters from the default, such as to create an unbounded ripple with a fixed size.
*
* @param bounded If true, ripples are clipped by the bounds of the target layout. Unbounded
* ripples always animate from the target layout center, bounded ripples animate from the touch
* position.
* @param radius the radius for the ripple. If [Dp.Unspecified] is provided then the size will be
* calculated based on the target layout size.
* @param color the color of the ripple. This color is usually the same color used by the text or
* iconography in the component. This color will then have [RippleTheme.rippleAlpha] applied to
* calculate the final color used to draw the ripple. If [Color.Unspecified] is provided the color
* used will be [RippleTheme.defaultColor] instead.
*/
@Composable
@OptIn(ExperimentalRippleApi::class)
public fun rememberRipple(
bounded: Boolean = true,
radius: Dp = Dp.Unspecified,
color: Color = Color.Unspecified
): Indication {
val theme = AmbientRippleTheme.current
val clock = AmbientAnimationClock.current.asDisposableClock()
val resolvedColor = color.useOrElse { theme.defaultColor() }
val colorState = remember { mutableStateOf(resolvedColor, structuralEqualityPolicy()) }
colorState.value = resolvedColor
val rippleAlpha = theme.rippleAlpha()
return remember(bounded, radius, theme, clock) {
Ripple(bounded, radius, colorState, rippleAlpha, clock)
}
}
/**
* A Ripple is a Material implementation of [Indication] that expresses different [Interaction]s
* by drawing ripple animations and state layers.
*
* A Ripple responds to [Interaction.Pressed] by starting a new [RippleAnimation], and
* responds to other [Interaction]s by showing a fixed [StateLayer] with varying alpha values
* depending on the [Interaction].
*
* If you are using MaterialTheme in your hierarchy, a Ripple will be used as the default
* [Indication] inside components such as [androidx.compose.foundation.clickable] and
* [androidx.compose.foundation.indication]. You can also manually provide Ripples through
* [androidx.compose.foundation.AmbientIndication] for the same effect if you are not using
* MaterialTheme.
*
* You can also explicitly create a Ripple and provide it to components in order to change the
* parameters from the default, such as to create an unbounded ripple with a fixed size.
*/
@Stable
@ExperimentalRippleApi
private class Ripple(
private val bounded: Boolean,
private val radius: Dp,
private val color: State<Color>,
private val rippleAlpha: RippleAlpha,
private val clock: AnimationClockObservable
) : Indication {
override fun createInstance(): IndicationInstance {
return RippleIndicationInstance(bounded, radius, color, rippleAlpha, clock)
}
// to force stability on this indication we need equals and hashcode, there's no value in
// making this class to be "data class"
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (this.nativeClass() != other?.nativeClass()) return false
other as Ripple
if (bounded != other.bounded) return false
if (radius != other.radius) return false
if (color != other.color) return false
if (rippleAlpha != other.rippleAlpha) return false
if (clock != other.clock) return false
return true
}
override fun hashCode(): Int {
var result = bounded.hashCode()
result = 31 * result + radius.hashCode()
result = 31 * result + color.hashCode()
result = 31 * result + rippleAlpha.hashCode()
result = 31 * result + clock.hashCode()
return result
}
}
@ExperimentalRippleApi
private class RippleIndicationInstance constructor(
private val bounded: Boolean,
private val radius: Dp,
private val color: State<Color>,
private val rippleAlpha: RippleAlpha,
private val clock: AnimationClockObservable
) : IndicationInstance {
private val stateLayer = StateLayer(clock, bounded, rippleAlpha)
private val ripples = mutableStateListOf<RippleAnimation>()
private var currentPressPosition: Offset? = null
private var currentRipple: RippleAnimation? = null
override fun ContentDrawScope.drawIndication(interactionState: InteractionState) {
val color = color.value
// TODO: b/174310811 use library function instead of manual logic here
val targetRadius = if (radius == Dp.Unspecified) {
getRippleEndRadius(bounded, size)
} else {
radius.toPx()
}
drawContent()
with(stateLayer) {
drawStateLayer(interactionState, targetRadius, color)
}
val pressPosition = interactionState.interactionPositionFor(Interaction.Pressed)
if (pressPosition != null) {
if (currentPressPosition != pressPosition) {
addRipple(targetRadius, pressPosition)
}
} else {
removeRipple()
}
drawRipples(color)
}
private fun ContentDrawScope.addRipple(targetRadius: Float, pressPosition: Offset) {
currentRipple?.finish()
val pxSize = Size(size.width, size.height)
val center = Offset(size.width / 2f, size.height / 2f)
val position = if (bounded) pressPosition else center
val ripple = RippleAnimation(pxSize, position, targetRadius, bounded, clock) { ripple ->
ripples.remove(ripple)
if (currentRipple == ripple) {
currentRipple = null
}
}
ripples.add(ripple)
currentPressPosition = pressPosition
currentRipple = ripple
}
private fun removeRipple() {
currentRipple?.finish()
currentRipple = null
currentPressPosition = null
}
private fun DrawScope.drawRipples(color: Color) {
ripples.fastForEach {
with(it) {
val alpha = rippleAlpha.alphaForInteraction(Interaction.Pressed)
if (alpha != 0f) {
draw(color.copy(alpha = alpha))
}
}
}
}
override fun onDispose() {
ripples.clear()
currentRipple = null
}
}
/**
* Represents the layer underneath the press ripple, that displays an overlay for states such as
* [Interaction.Dragged].
*
* Typically, there should be both an 'incoming' and an 'outgoing' layer, so that when
* transitioning between two states, the incoming of the new state, and the outgoing of the old
* state can be displayed. However, because:
*
* a) the duration of these outgoing transitions are so short (mostly 15ms, which is less than 1
* frame at 60fps), and hence are barely noticeable if they happen at the same time as an
* incoming transition
* b) two layers cause a lot of extra work, and related performance concerns
*
* We skip managing two layers, and instead only show one layer. The details for the
* [AnimationSpec]s used are as follows:
*
* No state -> a state = incoming transition for the new state
* A state -> a different state = incoming transition for the new state
* A state -> no state = outgoing transition for the old state
*
* @see IncomingStateLayerAnimationSpecs
* @see OutgoingStateLayerAnimationSpecs
*/
@ExperimentalRippleApi
private class StateLayer(
clock: AnimationClockObservable,
private val bounded: Boolean,
private val rippleAlpha: RippleAlpha
) {
private val animatedAlpha = AnimatedFloatModel(0f, clock)
private var previousInteractions: Set<Interaction> = emptySet()
private var lastDrawnInteraction: Interaction? = null
fun ContentDrawScope.drawStateLayer(
interactionState: InteractionState,
targetRadius: Float,
color: Color
) {
val currentInteractions = interactionState.value
var handled = false
// Handle a new interaction, starting from the end as we care about the most recent
// interaction, not the oldest interaction.
for (interaction in currentInteractions.reversed()) {
// Stop looping if we have already moved to a new state
if (handled) break
// Pressed state is explicitly handled with a ripple animation, and not a state layer
if (interaction is Interaction.Pressed) continue
// Move to the next interaction if this interaction is not a new interaction
if (interaction in previousInteractions) continue
// Move to the next interaction if this is not an interaction we show a state layer for
val targetAlpha = rippleAlpha.alphaForInteraction(interaction)
if (targetAlpha == 0f) continue
// TODO: consider defaults - these will be used for a custom Interaction that we are
// not aware of, but has an alpha that should be shown because of a custom RippleTheme.
val incomingAnimationSpec = IncomingStateLayerAnimationSpecs[interaction]
?: TweenSpec(durationMillis = 15, easing = LinearEasing)
animatedAlpha.animateTo(targetAlpha, incomingAnimationSpec)
lastDrawnInteraction = interaction
handled = true
}
// Clean up any stale interactions if we have not moved to a new interaction
if (!handled) {
val previousInteraction = lastDrawnInteraction
if (previousInteraction != null && previousInteraction !in currentInteractions) {
// TODO: consider defaults - these will be used for a custom Interaction that we are
// not aware of, but has an alpha that should be shown because of a custom
// RippleTheme.
val outgoingAnimationSpec = OutgoingStateLayerAnimationSpecs[previousInteraction]
?: TweenSpec(durationMillis = 15, easing = LinearEasing)
animatedAlpha.animateTo(0f, outgoingAnimationSpec)
lastDrawnInteraction = null
}
}
previousInteractions = currentInteractions
val alpha = animatedAlpha.value
if (alpha > 0f) {
val modulatedColor = color.copy(alpha = alpha)
if (bounded) {
clipRect {
drawCircle(modulatedColor, targetRadius)
}
} else {
drawCircle(modulatedColor, targetRadius)
}
}
}
}
/**
* [AnimationSpec]s used when transitioning to a new state, either from a previous state, or no
* state.
*
* TODO: handle hover / focus states
*/
private val IncomingStateLayerAnimationSpecs: Map<Interaction, AnimationSpec<Float>> = mapOf(
Interaction.Dragged to TweenSpec(
durationMillis = 45,
easing = LinearEasing
)
)
/**
* [AnimationSpec]s used when transitioning away from a state, to no state.
*
* TODO: handle hover / focus states
*/
private val OutgoingStateLayerAnimationSpecs: Map<Interaction, AnimationSpec<Float>> = mapOf(
Interaction.Dragged to TweenSpec(
durationMillis = 150,
easing = LinearEasing
)
)