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