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