1 /*
<lambda>null2 * Copyright 2020 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.material.ripple
18
19 import androidx.collection.mutableObjectListOf
20 import androidx.compose.animation.core.Animatable
21 import androidx.compose.animation.core.AnimationSpec
22 import androidx.compose.animation.core.LinearEasing
23 import androidx.compose.animation.core.TweenSpec
24 import androidx.compose.foundation.Indication
25 import androidx.compose.foundation.interaction.DragInteraction
26 import androidx.compose.foundation.interaction.FocusInteraction
27 import androidx.compose.foundation.interaction.HoverInteraction
28 import androidx.compose.foundation.interaction.Interaction
29 import androidx.compose.foundation.interaction.InteractionSource
30 import androidx.compose.foundation.interaction.PressInteraction
31 import androidx.compose.runtime.Composable
32 import androidx.compose.runtime.LaunchedEffect
33 import androidx.compose.runtime.Stable
34 import androidx.compose.runtime.State
35 import androidx.compose.runtime.remember
36 import androidx.compose.runtime.rememberUpdatedState
37 import androidx.compose.ui.Modifier
38 import androidx.compose.ui.geometry.Size
39 import androidx.compose.ui.graphics.Color
40 import androidx.compose.ui.graphics.ColorProducer
41 import androidx.compose.ui.graphics.drawscope.ContentDrawScope
42 import androidx.compose.ui.graphics.drawscope.DrawScope
43 import androidx.compose.ui.graphics.drawscope.clipRect
44 import androidx.compose.ui.graphics.isSpecified
45 import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
46 import androidx.compose.ui.node.DelegatableNode
47 import androidx.compose.ui.node.DelegatingNode
48 import androidx.compose.ui.node.DrawModifierNode
49 import androidx.compose.ui.node.LayoutAwareModifierNode
50 import androidx.compose.ui.node.invalidateDraw
51 import androidx.compose.ui.node.requireDensity
52 import androidx.compose.ui.unit.Dp
53 import androidx.compose.ui.unit.IntSize
54 import androidx.compose.ui.unit.isUnspecified
55 import androidx.compose.ui.unit.toSize
56 import kotlinx.coroutines.CoroutineScope
57 import kotlinx.coroutines.launch
58
59 /**
60 * Creates a Ripple node using the values provided.
61 *
62 * A Ripple is a Material implementation of [Indication] that expresses different [Interaction]s by
63 * drawing ripple animations and state layers.
64 *
65 * A Ripple responds to [PressInteraction.Press] by starting a new [RippleAnimation], and responds
66 * to other [Interaction]s by showing a fixed [StateLayer] with varying alpha values depending on
67 * the [Interaction].
68 *
69 * This Ripple node is a low level building block for building IndicationNodeFactory implementations
70 * that use a Ripple - higher level design system libraries such as material and material3 provide
71 * [Indication] implementations using this node internally. In most cases you should use those
72 * factories directly: this node exists for design system libraries to delegate their Ripple
73 * implementation to, after querying any required theme values for customizing the Ripple.
74 *
75 * NOTE: when using this factory with [DelegatingNode.delegate], ensure that the node is created
76 * once or [DelegatingNode.undelegate] is called in [Modifier.Node.onDetach]. Repeatedly delegating
77 * to a new node returned by this method in [Modifier.Node.onAttach] without removing the old one
78 * will result in multiple ripple nodes being attached to the node.
79 *
80 * @param interactionSource the [InteractionSource] used to determine the state of the ripple.
81 * @param bounded if true, ripples are clipped by the bounds of the target layout. Unbounded ripples
82 * always animate from the target layout center, bounded ripples animate from the touch position.
83 * @param radius the radius for the ripple. If [Dp.Unspecified] is provided then the size will be
84 * calculated based on the target layout size.
85 * @param color the color of the ripple. This color is usually the same color used by the text or
86 * iconography in the component. This color will then have [rippleAlpha] applied to calculate the
87 * final color used to draw the ripple.
88 * @param rippleAlpha the [RippleAlpha] that will be applied to the [color] depending on the state
89 * of the ripple.
90 */
91 public fun createRippleModifierNode(
92 interactionSource: InteractionSource,
93 bounded: Boolean,
94 radius: Dp,
95 color: ColorProducer,
96 rippleAlpha: () -> RippleAlpha
97 ): DelegatableNode {
98 return createPlatformRippleNode(interactionSource, bounded, radius, color, rippleAlpha)
99 }
100
101 /**
102 * Creates and [remember]s a Ripple using values provided by [RippleTheme].
103 *
104 * A Ripple is a Material implementation of [Indication] that expresses different [Interaction]s by
105 * drawing ripple animations and state layers.
106 *
107 * A Ripple responds to [PressInteraction.Press] by starting a new [RippleAnimation], and responds
108 * to other [Interaction]s by showing a fixed [StateLayer] with varying alpha values depending on
109 * the [Interaction].
110 *
111 * If you are using MaterialTheme in your hierarchy, a Ripple will be used as the default
112 * [Indication] inside components such as [androidx.compose.foundation.clickable] and
113 * [androidx.compose.foundation.indication]. You can also manually provide Ripples through
114 * [androidx.compose.foundation.LocalIndication] for the same effect if you are not using
115 * MaterialTheme.
116 *
117 * You can also explicitly create a Ripple and provide it to components in order to change the
118 * parameters from the default, such as to create an unbounded ripple with a fixed size.
119 *
120 * @param bounded If true, ripples are clipped by the bounds of the target layout. Unbounded ripples
121 * always animate from the target layout center, bounded ripples animate from the touch position.
122 * @param radius the radius for the ripple. If [Dp.Unspecified] is provided then the size will be
123 * calculated based on the target layout size.
124 * @param color the color of the ripple. This color is usually the same color used by the text or
125 * iconography in the component. This color will then have [RippleTheme.rippleAlpha] applied to
126 * calculate the final color used to draw the ripple. If [Color.Unspecified] is provided the color
127 * used will be [RippleTheme.defaultColor] instead.
128 */
129 @Deprecated(
130 "rememberRipple has been deprecated - it returns an old Indication " +
131 "implementation that is not compatible with the new Indication APIs that provide notable " +
132 "performance improvements. Instead, use the new ripple APIs provided by design system " +
133 "libraries, such as material and material3. If you are implementing your own design " +
134 "system library, use createRippleNode to create your own custom ripple implementation " +
135 "that queries your own theme values. For a migration guide and background " +
136 "information, please visit developer.android.com",
137 level = DeprecationLevel.ERROR
138 )
139 @Suppress("DEPRECATION", "TYPEALIAS_EXPANSION_DEPRECATION")
140 @Composable
rememberRipplenull141 public fun rememberRipple(
142 bounded: Boolean = true,
143 radius: Dp = Dp.Unspecified,
144 color: Color = Color.Unspecified
145 ): Indication {
146 val colorState = rememberUpdatedState(color)
147 return remember(bounded, radius) { PlatformRipple(bounded, radius, colorState) }
148 }
149
150 /** Creates the platform specific [RippleNode] implementation. */
createPlatformRippleNodenull151 internal expect fun createPlatformRippleNode(
152 interactionSource: InteractionSource,
153 bounded: Boolean,
154 radius: Dp,
155 color: ColorProducer,
156 rippleAlpha: () -> RippleAlpha
157 ): DelegatableNode
158
159 /**
160 * A Ripple is a Material implementation of [Indication] that expresses different [Interaction]s by
161 * drawing ripple animations and state layers.
162 *
163 * A Ripple responds to [PressInteraction.Press] by starting a new [RippleAnimation], and responds
164 * to other [Interaction]s by showing a fixed [StateLayer] with varying alpha values depending on
165 * the [Interaction].
166 *
167 * If you are using MaterialTheme in your hierarchy, a Ripple will be used as the default
168 * [Indication] inside components such as [androidx.compose.foundation.clickable] and
169 * [androidx.compose.foundation.indication]. You can also manually provide Ripples through
170 * [androidx.compose.foundation.LocalIndication] for the same effect if you are not using
171 * MaterialTheme.
172 *
173 * You can also explicitly create a Ripple and provide it to components in order to change the
174 * parameters from the default, such as to create an unbounded ripple with a fixed size.
175 *
176 * Ripple is provided on different platforms using [PlatformRipple].
177 */
178 @Suppress("DEPRECATION")
179 @Deprecated("Replaced by the new RippleNode implementation")
180 @Stable
181 internal abstract class Ripple(
182 private val bounded: Boolean,
183 private val radius: Dp,
184 private val color: State<Color>
185 ) : Indication {
186 @Suppress("DEPRECATION_ERROR")
187 @Deprecated("Super method is deprecated")
188 @Composable
189 final override fun rememberUpdatedInstance(
190 interactionSource: InteractionSource
191 ): androidx.compose.foundation.IndicationInstance {
192 val theme = LocalRippleTheme.current
193 val color =
194 rememberUpdatedState(
195 if (color.value.isSpecified) {
196 color.value
197 } else {
198 @Suppress("DEPRECATION_ERROR") theme.defaultColor()
199 }
200 )
201 @Suppress("DEPRECATION_ERROR") val rippleAlpha = rememberUpdatedState(theme.rippleAlpha())
202
203 val instance =
204 rememberUpdatedRippleInstance(interactionSource, bounded, radius, color, rippleAlpha)
205
206 LaunchedEffect(instance, interactionSource) {
207 interactionSource.interactions.collect { interaction ->
208 when (interaction) {
209 is PressInteraction.Press -> instance.addRipple(interaction, this)
210 is PressInteraction.Release -> instance.removeRipple(interaction.press)
211 is PressInteraction.Cancel -> instance.removeRipple(interaction.press)
212 else -> instance.updateStateLayer(interaction, this)
213 }
214 }
215 }
216
217 return instance
218 }
219
220 @Composable
221 abstract fun rememberUpdatedRippleInstance(
222 interactionSource: InteractionSource,
223 bounded: Boolean,
224 radius: Dp,
225 color: State<Color>,
226 rippleAlpha: State<RippleAlpha>
227 ): RippleIndicationInstance
228
229 // To force stability on this Ripple we need equals and hashcode, there's no value in
230 // making this class to be a `data class`
231 override fun equals(other: Any?): Boolean {
232 if (this === other) return true
233 if (other !is Ripple) return false
234
235 if (bounded != other.bounded) return false
236 if (radius != other.radius) return false
237 if (color != other.color) return false
238
239 return true
240 }
241
242 override fun hashCode(): Int {
243 var result = bounded.hashCode()
244 result = 31 * result + radius.hashCode()
245 result = 31 * result + color.hashCode()
246 return result
247 }
248 }
249
250 /**
251 * Platform-specific implementation of [Ripple]. This is needed as expect classes cannot (currently)
252 * have default implementations, otherwise we would make [Ripple] the expect class.
253 */
254 @Suppress("DEPRECATION")
255 @Deprecated("Replaced by the new RippleNode implementation")
256 @Stable
257 internal expect class PlatformRipple(bounded: Boolean, radius: Dp, color: State<Color>) : Ripple {
258
259 @Composable
rememberUpdatedRippleInstancenull260 override fun rememberUpdatedRippleInstance(
261 interactionSource: InteractionSource,
262 bounded: Boolean,
263 radius: Dp,
264 color: State<Color>,
265 rippleAlpha: State<RippleAlpha>
266 ): RippleIndicationInstance
267 }
268
269 /**
270 * Abstract [androidx.compose.foundation.IndicationInstance] that provides common functionality used
271 * by [PlatformRipple] implementations. Implementing classes should call [drawStateLayer] to draw
272 * the [StateLayer], so they only need to handle showing the ripple effect when pressed, and not
273 * other [Interaction]s.
274 */
275 @Suppress("DEPRECATION_ERROR")
276 @Deprecated("Replaced by the new RippleNode implementation")
277 internal abstract class RippleIndicationInstance(
278 private val bounded: Boolean,
279 rippleAlpha: State<RippleAlpha>
280 ) : androidx.compose.foundation.IndicationInstance {
281 private val stateLayer = StateLayer(bounded) { rippleAlpha.value }
282
283 abstract fun addRipple(interaction: PressInteraction.Press, scope: CoroutineScope)
284
285 abstract fun removeRipple(interaction: PressInteraction.Press)
286
287 internal fun updateStateLayer(interaction: Interaction, scope: CoroutineScope) {
288 stateLayer.handleInteraction(interaction, scope)
289 }
290
291 fun DrawScope.drawStateLayer(radius: Dp, color: Color) {
292 with(stateLayer) {
293 val targetRadius =
294 if (radius.isUnspecified) {
295 getRippleEndRadius(bounded, size)
296 } else {
297 radius.toPx()
298 }
299 drawStateLayer(targetRadius, color)
300 }
301 }
302 }
303
304 /**
305 * Abstract [Modifier.Node] that provides common functionality used by ripple node implementations.
306 * Implementing classes should use [stateLayer] to draw the [StateLayer], so they only need to
307 * handle showing the ripple effect when pressed, and not other [Interaction]s.
308 */
309 internal abstract class RippleNode(
310 private val interactionSource: InteractionSource,
311 protected val bounded: Boolean,
312 private val radius: Dp,
313 private val color: ColorProducer,
314 protected val rippleAlpha: () -> RippleAlpha
315 ) :
316 Modifier.Node(),
317 CompositionLocalConsumerModifierNode,
318 DrawModifierNode,
319 LayoutAwareModifierNode {
320 final override val shouldAutoInvalidate: Boolean = false
321
322 private var stateLayer: StateLayer? = null
323
324 // The following are calculated inside onRemeasured(). These must be initialized before adding
325 // a ripple.
326
327 protected var targetRadius: Float = 0f
328 // The size is needed for Android to update ripple bounds if the size changes
329 protected var rippleSize: Size = Size.Zero
330 private set
331
332 val rippleColor: Color
333 get() = color()
334
335 // Track interactions that were emitted before we have been placed - we need to wait until we
336 // have a valid size in order to set the radius and size correctly.
337 private var hasValidSize = false
338 private val pendingInteractions = mutableObjectListOf<PressInteraction>()
339
onRemeasurednull340 override fun onRemeasured(size: IntSize) {
341 hasValidSize = true
342 val density = requireDensity()
343 rippleSize = size.toSize()
344 targetRadius =
345 with(density) {
346 if (radius.isUnspecified) {
347 // Explicitly calculate the radius instead of using RippleDrawable.RADIUS_AUTO
348 // on
349 // Android since the latest spec does not match with the existing radius
350 // calculation
351 // in the framework.
352 getRippleEndRadius(bounded, rippleSize)
353 } else {
354 radius.toPx()
355 }
356 }
357 // Flush any pending interactions that were waiting for measurement
358 pendingInteractions.forEach { handlePressInteraction(it) }
359 pendingInteractions.clear()
360 }
361
onAttachnull362 override fun onAttach() {
363 coroutineScope.launch {
364 interactionSource.interactions.collect { interaction ->
365 when (interaction) {
366 is PressInteraction -> {
367 if (hasValidSize) {
368 handlePressInteraction(interaction)
369 } else {
370 // Handle these later when we have a valid size
371 pendingInteractions += interaction
372 }
373 }
374 else -> updateStateLayer(interaction, this)
375 }
376 }
377 }
378 }
379
handlePressInteractionnull380 private fun handlePressInteraction(pressInteraction: PressInteraction) {
381 when (pressInteraction) {
382 is PressInteraction.Press -> addRipple(pressInteraction, rippleSize, targetRadius)
383 is PressInteraction.Release -> removeRipple(pressInteraction.press)
384 is PressInteraction.Cancel -> removeRipple(pressInteraction.press)
385 }
386 }
387
drawnull388 override fun ContentDrawScope.draw() {
389 drawContent()
390 stateLayer?.run { drawStateLayer(targetRadius, rippleColor) }
391 drawRipples()
392 }
393
DrawScopenull394 abstract fun DrawScope.drawRipples()
395
396 abstract fun addRipple(interaction: PressInteraction.Press, size: Size, targetRadius: Float)
397
398 abstract fun removeRipple(interaction: PressInteraction.Press)
399
400 private fun updateStateLayer(interaction: Interaction, scope: CoroutineScope) {
401 val stateLayer =
402 stateLayer
403 ?: StateLayer(bounded, rippleAlpha).also { instance ->
404 // Invalidate when adding the state layer so we can start drawing it
405 invalidateDraw()
406 stateLayer = instance
407 }
408 stateLayer.handleInteraction(interaction, scope)
409 }
410 }
411
412 /**
413 * Represents the layer underneath the press ripple, that displays an overlay for states such as
414 * [DragInteraction.Start].
415 *
416 * Typically, there should be both an 'incoming' and an 'outgoing' layer, so that when transitioning
417 * between two states, the incoming of the new state, and the outgoing of the old state can be
418 * displayed. However, because:
419 *
420 * a) the duration of these outgoing transitions are so short (mostly 15ms, which is less than 1
421 * frame at 60fps), and hence are barely noticeable if they happen at the same time as an incoming
422 * transition b) two layers cause a lot of extra work, and related performance concerns
423 *
424 * We skip managing two layers, and instead only show one layer. The details for the
425 * [AnimationSpec]s used are as follows:
426 *
427 * No state -> a state = incoming transition for the new state A state -> a different state =
428 * incoming transition for the new state A state -> no state = outgoing transition for the old state
429 *
430 * @see incomingStateLayerAnimationSpecFor
431 * @see outgoingStateLayerAnimationSpecFor
432 */
433 private class StateLayer(private val bounded: Boolean, private val rippleAlpha: () -> RippleAlpha) {
434 private val animatedAlpha = Animatable(0f)
435
436 private val interactions: MutableList<Interaction> = mutableListOf()
437 private var currentInteraction: Interaction? = null
438
handleInteractionnull439 internal fun handleInteraction(interaction: Interaction, scope: CoroutineScope) {
440 when (interaction) {
441 is HoverInteraction.Enter -> {
442 interactions.add(interaction)
443 }
444 is HoverInteraction.Exit -> {
445 interactions.remove(interaction.enter)
446 }
447 is FocusInteraction.Focus -> {
448 interactions.add(interaction)
449 }
450 is FocusInteraction.Unfocus -> {
451 interactions.remove(interaction.focus)
452 }
453 is DragInteraction.Start -> {
454 interactions.add(interaction)
455 }
456 is DragInteraction.Stop -> {
457 interactions.remove(interaction.start)
458 }
459 is DragInteraction.Cancel -> {
460 interactions.remove(interaction.start)
461 }
462 else -> return
463 }
464
465 // The most recent interaction is the one we want to show
466 val newInteraction = interactions.lastOrNull()
467
468 if (currentInteraction != newInteraction) {
469 if (newInteraction != null) {
470 val rippleAlpha = rippleAlpha()
471 val targetAlpha =
472 when (interaction) {
473 is HoverInteraction.Enter -> rippleAlpha.hoveredAlpha
474 is FocusInteraction.Focus -> rippleAlpha.focusedAlpha
475 is DragInteraction.Start -> rippleAlpha.draggedAlpha
476 else -> 0f
477 }
478 val incomingAnimationSpec = incomingStateLayerAnimationSpecFor(newInteraction)
479
480 scope.launch { animatedAlpha.animateTo(targetAlpha, incomingAnimationSpec) }
481 } else {
482 val outgoingAnimationSpec = outgoingStateLayerAnimationSpecFor(currentInteraction)
483
484 scope.launch { animatedAlpha.animateTo(0f, outgoingAnimationSpec) }
485 }
486 currentInteraction = newInteraction
487 }
488 }
489
DrawScopenull490 fun DrawScope.drawStateLayer(radius: Float, color: Color) {
491 val alpha = animatedAlpha.value
492
493 if (alpha > 0f) {
494 val modulatedColor = color.copy(alpha = alpha)
495
496 if (bounded) {
497 clipRect { drawCircle(modulatedColor, radius) }
498 } else {
499 drawCircle(modulatedColor, radius)
500 }
501 }
502 }
503 }
504
505 /**
506 * @return the [AnimationSpec] used when transitioning to [interaction], either from a previous
507 * state, or no state.
508 */
incomingStateLayerAnimationSpecFornull509 private fun incomingStateLayerAnimationSpecFor(interaction: Interaction): AnimationSpec<Float> {
510 return when (interaction) {
511 is HoverInteraction.Enter -> DefaultTweenSpec
512 is FocusInteraction.Focus -> TweenSpec(durationMillis = 45, easing = LinearEasing)
513 is DragInteraction.Start -> TweenSpec(durationMillis = 45, easing = LinearEasing)
514 else -> DefaultTweenSpec
515 }
516 }
517
518 /** @return the [AnimationSpec] used when transitioning away from [interaction], to no state. */
outgoingStateLayerAnimationSpecFornull519 private fun outgoingStateLayerAnimationSpecFor(interaction: Interaction?): AnimationSpec<Float> {
520 return when (interaction) {
521 is HoverInteraction.Enter -> DefaultTweenSpec
522 is FocusInteraction.Focus -> DefaultTweenSpec
523 is DragInteraction.Start -> TweenSpec(durationMillis = 150, easing = LinearEasing)
524 else -> DefaultTweenSpec
525 }
526 }
527
528 /** Default / fallback [AnimationSpec]. */
529 private val DefaultTweenSpec = TweenSpec<Float>(durationMillis = 15, easing = LinearEasing)
530