1 /*
2  * Copyright 2023 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 android.os.Build
20 import androidx.compose.foundation.BorderStroke
21 import androidx.compose.foundation.Indication
22 import androidx.compose.foundation.background
23 import androidx.compose.foundation.border
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.MutableInteractionSource
30 import androidx.compose.foundation.interaction.PressInteraction
31 import androidx.compose.foundation.layout.Box
32 import androidx.compose.foundation.layout.fillMaxSize
33 import androidx.compose.foundation.layout.height
34 import androidx.compose.foundation.layout.padding
35 import androidx.compose.foundation.layout.width
36 import androidx.compose.foundation.shape.RoundedCornerShape
37 import androidx.compose.runtime.Composable
38 import androidx.compose.runtime.CompositionLocalProvider
39 import androidx.compose.runtime.compositionLocalOf
40 import androidx.compose.runtime.getValue
41 import androidx.compose.runtime.mutableStateOf
42 import androidx.compose.runtime.rememberCoroutineScope
43 import androidx.compose.runtime.setValue
44 import androidx.compose.ui.Alignment
45 import androidx.compose.ui.Modifier
46 import androidx.compose.ui.draw.clip
47 import androidx.compose.ui.geometry.Offset
48 import androidx.compose.ui.graphics.Color
49 import androidx.compose.ui.graphics.asAndroidBitmap
50 import androidx.compose.ui.graphics.compositeOver
51 import androidx.compose.ui.platform.testTag
52 import androidx.compose.ui.semantics.semantics
53 import androidx.compose.ui.test.captureToImage
54 import androidx.compose.ui.test.junit4.createComposeRule
55 import androidx.compose.ui.test.onNodeWithTag
56 import androidx.compose.ui.unit.dp
57 import androidx.test.ext.junit.runners.AndroidJUnit4
58 import androidx.test.filters.LargeTest
59 import androidx.test.filters.SdkSuppress
60 import com.google.common.truth.Truth
61 import kotlinx.coroutines.CoroutineScope
62 import kotlinx.coroutines.launch
63 import org.junit.Rule
64 import org.junit.Test
65 import org.junit.runner.RunWith
66 
67 /** Test for (the deprecated) [rememberRipple] and [RippleTheme] APIs to ensure no regressions. */
68 @LargeTest
69 @RunWith(AndroidJUnit4::class)
70 @SdkSuppress(
71     // Below P the press ripple is split into two layers with half alpha, and we multiply the alpha
72     // first so each layer will have the expected alpha to ensure that the minimum contrast in
73     // areas where the ripples don't overlap is still correct - as a result the colors aren't
74     // exactly what we expect here so we can't really reliably assert
75     minSdkVersion = Build.VERSION_CODES.P,
76     // On S and above, the press ripple is patterned and has inconsistent behaviour in terms of
77     // alpha, so it doesn't behave according to our expectations - we can't explicitly assert on the
78     // color.
79     maxSdkVersion = Build.VERSION_CODES.R
80 )
81 @Suppress("DEPRECATION_ERROR")
82 class RememberRippleTest {
83 
84     @get:Rule val rule = createComposeRule()
85 
86     private val TestRippleColor = Color.Red
87 
88     private val TestRippleAlpha =
89         RippleAlpha(
90             draggedAlpha = 0.1f,
91             focusedAlpha = 0.2f,
92             hoveredAlpha = 0.3f,
93             pressedAlpha = 0.4f
94         )
95 
96     private val TestRippleTheme =
97         object : RippleTheme {
98             @Deprecated("Super method is deprecated")
99             @Composable
defaultColornull100             override fun defaultColor() = TestRippleColor
101 
102             @Deprecated("Super method is deprecated")
103             @Composable
104             override fun rippleAlpha() = TestRippleAlpha
105         }
106 
107     @Test
108     fun pressed() {
109         val interactionSource = MutableInteractionSource()
110 
111         var scope: CoroutineScope? = null
112 
113         rule.setContent {
114             scope = rememberCoroutineScope()
115             CompositionLocalProvider(LocalRippleTheme provides TestRippleTheme) {
116                 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
117                     RippleBoxWithBackground(interactionSource, rememberRipple(), bounded = true)
118                 }
119             }
120         }
121 
122         val expectedColor =
123             calculateResultingRippleColor(
124                 TestRippleColor,
125                 rippleOpacity = TestRippleAlpha.pressedAlpha
126             )
127 
128         assertRippleMatches(
129             scope!!,
130             interactionSource,
131             PressInteraction.Press(Offset(10f, 10f)),
132             expectedColor
133         )
134     }
135 
136     @Test
hoverednull137     fun hovered() {
138         val interactionSource = MutableInteractionSource()
139 
140         var scope: CoroutineScope? = null
141 
142         rule.setContent {
143             scope = rememberCoroutineScope()
144             CompositionLocalProvider(LocalRippleTheme provides TestRippleTheme) {
145                 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
146                     RippleBoxWithBackground(interactionSource, rememberRipple(), bounded = true)
147                 }
148             }
149         }
150 
151         val expectedColor =
152             calculateResultingRippleColor(
153                 TestRippleColor,
154                 rippleOpacity = TestRippleAlpha.hoveredAlpha
155             )
156 
157         assertRippleMatches(scope!!, interactionSource, HoverInteraction.Enter(), expectedColor)
158     }
159 
160     @Test
focusednull161     fun focused() {
162         val interactionSource = MutableInteractionSource()
163 
164         var scope: CoroutineScope? = null
165 
166         rule.setContent {
167             scope = rememberCoroutineScope()
168             CompositionLocalProvider(LocalRippleTheme provides TestRippleTheme) {
169                 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
170                     RippleBoxWithBackground(interactionSource, rememberRipple(), bounded = true)
171                 }
172             }
173         }
174 
175         val expectedColor =
176             calculateResultingRippleColor(
177                 TestRippleColor,
178                 rippleOpacity = TestRippleAlpha.focusedAlpha
179             )
180 
181         assertRippleMatches(scope!!, interactionSource, FocusInteraction.Focus(), expectedColor)
182     }
183 
184     @Test
draggednull185     fun dragged() {
186         val interactionSource = MutableInteractionSource()
187 
188         var scope: CoroutineScope? = null
189 
190         rule.setContent {
191             scope = rememberCoroutineScope()
192             CompositionLocalProvider(LocalRippleTheme provides TestRippleTheme) {
193                 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
194                     RippleBoxWithBackground(interactionSource, rememberRipple(), bounded = true)
195                 }
196             }
197         }
198 
199         val expectedColor =
200             calculateResultingRippleColor(
201                 TestRippleColor,
202                 rippleOpacity = TestRippleAlpha.draggedAlpha
203             )
204 
205         assertRippleMatches(scope!!, interactionSource, DragInteraction.Start(), expectedColor)
206     }
207 
208     /**
209      * Test case for changing LocalRippleTheme during an existing ripple effect
210      *
211      * Note: no corresponding test for pressed ripples since RippleForeground does not update the
212      * color of currently active ripples unless they are being drawn on the UI thread (which should
213      * only happen if the target radius also changes).
214      */
215     @Test
themeChangeDuringRipple_draggednull216     fun themeChangeDuringRipple_dragged() {
217         val interactionSource = MutableInteractionSource()
218 
219         fun createRippleTheme(color: Color, alpha: Float) =
220             object : RippleTheme {
221                 val rippleAlpha = RippleAlpha(alpha, alpha, alpha, alpha)
222 
223                 @Deprecated("Super method is deprecated")
224                 @Composable
225                 override fun defaultColor() = color
226 
227                 @Deprecated("Super method is deprecated")
228                 @Composable
229                 override fun rippleAlpha() = rippleAlpha
230             }
231 
232         val initialColor = Color.Red
233         val initialAlpha = 0.5f
234 
235         var rippleTheme by mutableStateOf(createRippleTheme(initialColor, initialAlpha))
236 
237         var scope: CoroutineScope? = null
238 
239         rule.setContent {
240             scope = rememberCoroutineScope()
241             CompositionLocalProvider(LocalRippleTheme provides rippleTheme) {
242                 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
243                     RippleBoxWithBackground(interactionSource, rememberRipple(), bounded = true)
244                 }
245             }
246         }
247 
248         rule.runOnIdle { scope!!.launch { interactionSource.emit(DragInteraction.Start()) } }
249         rule.waitForIdle()
250 
251         with(rule.onNodeWithTag(Tag)) {
252             val centerPixel =
253                 captureToImage().asAndroidBitmap().run { getPixel(width / 2, height / 2) }
254 
255             val expectedColor =
256                 calculateResultingRippleColor(initialColor, rippleOpacity = initialAlpha)
257 
258             Truth.assertThat(Color(centerPixel)).isEqualTo(expectedColor)
259         }
260 
261         val newColor = Color.Green
262         // TODO: changing alpha for existing state layers is not currently supported
263         val newAlpha = 0.5f
264 
265         rule.runOnUiThread { rippleTheme = createRippleTheme(newColor, newAlpha) }
266 
267         with(rule.onNodeWithTag(Tag)) {
268             val centerPixel =
269                 captureToImage().asAndroidBitmap().run { getPixel(width / 2, height / 2) }
270 
271             val expectedColor = calculateResultingRippleColor(newColor, rippleOpacity = newAlpha)
272 
273             Truth.assertThat(Color(centerPixel)).isEqualTo(expectedColor)
274         }
275     }
276 
277     /**
278      * Test case for changing a CompositionLocal consumed by the RippleTheme during an existing
279      * ripple effect
280      *
281      * Note: no corresponding test for pressed ripples since RippleForeground does not update the
282      * color of currently active ripples unless they are being drawn on the UI thread (which should
283      * only happen if the target radius also changes).
284      */
285     @Test
compositionLocalChangeDuringRipple_draggednull286     fun compositionLocalChangeDuringRipple_dragged() {
287         val interactionSource = MutableInteractionSource()
288 
289         val initialColor = Color.Red
290         var themeColor by mutableStateOf(initialColor)
291 
292         val expectedAlpha = 0.5f
293         val rippleAlpha = RippleAlpha(expectedAlpha, expectedAlpha, expectedAlpha, expectedAlpha)
294 
295         val localThemeColor = compositionLocalOf { Color.Unspecified }
296 
297         val rippleTheme =
298             object : RippleTheme {
299                 @Deprecated("Super method is deprecated")
300                 @Composable
301                 override fun defaultColor() = localThemeColor.current
302 
303                 @Deprecated("Super method is deprecated")
304                 @Composable
305                 override fun rippleAlpha() = rippleAlpha
306             }
307 
308         var scope: CoroutineScope? = null
309 
310         rule.setContent {
311             scope = rememberCoroutineScope()
312             CompositionLocalProvider(
313                 LocalRippleTheme provides rippleTheme,
314                 localThemeColor provides themeColor
315             ) {
316                 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
317                     RippleBoxWithBackground(interactionSource, rememberRipple(), bounded = true)
318                 }
319             }
320         }
321 
322         rule.runOnIdle { scope!!.launch { interactionSource.emit(DragInteraction.Start()) } }
323         rule.waitForIdle()
324 
325         with(rule.onNodeWithTag(Tag)) {
326             val centerPixel =
327                 captureToImage().asAndroidBitmap().run { getPixel(width / 2, height / 2) }
328 
329             val expectedColor =
330                 calculateResultingRippleColor(initialColor, rippleOpacity = expectedAlpha)
331 
332             Truth.assertThat(Color(centerPixel)).isEqualTo(expectedColor)
333         }
334 
335         val newColor = Color.Green
336 
337         rule.runOnUiThread { themeColor = newColor }
338 
339         with(rule.onNodeWithTag(Tag)) {
340             val centerPixel =
341                 captureToImage().asAndroidBitmap().run { getPixel(width / 2, height / 2) }
342 
343             val expectedColor =
344                 calculateResultingRippleColor(newColor, rippleOpacity = expectedAlpha)
345 
346             Truth.assertThat(Color(centerPixel)).isEqualTo(expectedColor)
347         }
348     }
349 
350     /**
351      * Test case to ensure that CompositionLocals are queried at the place where the ripple is
352      * consumed, not where it is created (i.e, inside the component applying indication).
353      */
354     @Test
compositionLocalProvidedAfterRipplenull355     fun compositionLocalProvidedAfterRipple() {
356         val interactionSource = MutableInteractionSource()
357 
358         val alpha = 0.5f
359         val rippleAlpha = RippleAlpha(alpha, alpha, alpha, alpha)
360         val expectedRippleColor = Color.Red
361 
362         val localThemeColor = compositionLocalOf { Color.Unspecified }
363 
364         val rippleTheme =
365             object : RippleTheme {
366                 @Deprecated("Super method is deprecated")
367                 @Composable
368                 override fun defaultColor() = localThemeColor.current
369 
370                 @Deprecated("Super method is deprecated")
371                 @Composable
372                 override fun rippleAlpha() = rippleAlpha
373             }
374 
375         var scope: CoroutineScope? = null
376 
377         rule.setContent {
378             scope = rememberCoroutineScope()
379             CompositionLocalProvider(
380                 LocalRippleTheme provides rippleTheme,
381                 localThemeColor provides Color.Black
382             ) {
383                 // Create ripple where localThemeColor is black
384                 val ripple = rememberRipple()
385                 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
386                     CompositionLocalProvider(localThemeColor provides expectedRippleColor) {
387                         // Ripple is used where localThemeColor is red, so the instance
388                         // should get the red color when it is created
389                         RippleBoxWithBackground(interactionSource, ripple, bounded = true)
390                     }
391                 }
392             }
393         }
394 
395         rule.runOnIdle {
396             scope!!.launch { interactionSource.emit(PressInteraction.Press(Offset(10f, 10f))) }
397         }
398 
399         rule.waitForIdle()
400         // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for
401         // synchronization. Instead just wait until after the ripples are finished animating.
402         @Suppress("BanThreadSleep") Thread.sleep(300)
403 
404         with(rule.onNodeWithTag(Tag)) {
405             val centerPixel =
406                 captureToImage().asAndroidBitmap().run { getPixel(width / 2, height / 2) }
407 
408             val expectedColor =
409                 calculateResultingRippleColor(expectedRippleColor, rippleOpacity = alpha)
410 
411             Truth.assertThat(Color(centerPixel)).isEqualTo(expectedColor)
412         }
413     }
414 
415     /**
416      * Asserts that the resultant color of the ripple on screen matches [expectedCenterPixelColor].
417      *
418      * @param interactionSource the [MutableInteractionSource] driving the ripple
419      * @param interaction the [Interaction] to assert for
420      * @param expectedCenterPixelColor the expected color for the pixel at the center of the
421      *   [RippleBoxWithBackground]
422      */
assertRippleMatchesnull423     private fun assertRippleMatches(
424         scope: CoroutineScope,
425         interactionSource: MutableInteractionSource,
426         interaction: Interaction,
427         expectedCenterPixelColor: Color
428     ) {
429         // Pause the clock if we are drawing a state layer
430         if (interaction !is PressInteraction) {
431             rule.mainClock.autoAdvance = false
432         }
433 
434         // Start ripple
435         rule.runOnIdle { scope.launch { interactionSource.emit(interaction) } }
436 
437         // Advance to the end of the ripple / state layer animation
438         rule.waitForIdle()
439         @Suppress("BanThreadSleep")
440         if (interaction is PressInteraction) {
441             // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for
442             // synchronization. Instead just wait until after the ripples are finished animating.
443             Thread.sleep(300)
444         } else {
445             rule.mainClock.advanceTimeBy(milliseconds = 300)
446         }
447 
448         // Compare expected and actual pixel color
449         val centerPixel =
450             rule.onNodeWithTag(Tag).captureToImage().asAndroidBitmap().run {
451                 getPixel(width / 2, height / 2)
452             }
453 
454         Truth.assertThat(Color(centerPixel)).isEqualTo(expectedCenterPixelColor)
455     }
456 }
457 
458 /**
459  * Generic Button like component with a border that allows injecting an [Indication], and has a
460  * background with the same color around it - this makes the ripple contrast better and make it more
461  * visible in screenshots.
462  *
463  * @param interactionSource the [MutableInteractionSource] that is used to drive the ripple state
464  * @param ripple ripple [Indication] placed inside the surface
465  * @param bounded whether [ripple] is bounded or not - this controls the clipping behavior
466  */
467 @Composable
RippleBoxWithBackgroundnull468 private fun RippleBoxWithBackground(
469     interactionSource: MutableInteractionSource,
470     ripple: Indication,
471     bounded: Boolean
472 ) {
473     Box(Modifier.semantics(mergeDescendants = true) {}.testTag(Tag)) {
474         Box(Modifier.padding(25.dp).background(RippleBoxBackgroundColor)) {
475             val shape = RoundedCornerShape(20)
476             // If the ripple is bounded, we want to clip to the shape, otherwise don't clip as
477             // the ripple should draw outside the bounds.
478             val clip = if (bounded) Modifier.clip(shape) else Modifier
479             Box(
480                 Modifier.padding(25.dp)
481                     .width(40.dp)
482                     .height(40.dp)
483                     .border(BorderStroke(2.dp, Color.Black), shape)
484                     .background(color = RippleBoxBackgroundColor, shape = shape)
485                     .then(clip)
486                     .indication(interactionSource = interactionSource, indication = ripple)
487             ) {}
488         }
489     }
490 }
491 
492 /**
493  * Blends ([contentColor] with [rippleOpacity]) on top of [RippleBoxBackgroundColor] to provide the
494  * resulting RGB color that can be used for pixel comparison.
495  */
calculateResultingRippleColornull496 private fun calculateResultingRippleColor(contentColor: Color, rippleOpacity: Float) =
497     contentColor.copy(alpha = rippleOpacity).compositeOver(RippleBoxBackgroundColor)
498 
499 private val RippleBoxBackgroundColor = Color.Blue
500 
501 private const val Tag = "Ripple"
502