1 /*
2  * Copyright 2022 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.wear.compose.material
18 
19 import android.os.Build
20 import androidx.annotation.RequiresApi
21 import androidx.compose.foundation.layout.fillMaxWidth
22 import androidx.compose.runtime.Composable
23 import androidx.compose.runtime.LaunchedEffect
24 import androidx.compose.runtime.MutableState
25 import androidx.compose.runtime.mutableStateOf
26 import androidx.compose.runtime.remember
27 import androidx.compose.ui.Modifier
28 import androidx.compose.ui.geometry.Offset
29 import androidx.compose.ui.graphics.Color
30 import androidx.compose.ui.graphics.compositeOver
31 import androidx.compose.ui.platform.LocalDensity
32 import androidx.compose.ui.platform.testTag
33 import androidx.compose.ui.test.captureToImage
34 import androidx.compose.ui.test.junit4.createComposeRule
35 import androidx.compose.ui.test.onNodeWithTag
36 import androidx.compose.ui.unit.Dp
37 import androidx.test.filters.SdkSuppress
38 import androidx.wear.compose.materialcore.screenHeightDp
39 import androidx.wear.compose.materialcore.screenWidthDp
40 import com.google.common.truth.Truth.assertThat
41 import kotlin.math.max
42 import org.junit.Rule
43 import org.junit.Test
44 
45 class PlaceholderTest {
46     @get:Rule val rule = createComposeRule()
47 
48     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
49     @OptIn(ExperimentalWearMaterialApi::class)
50     @Test
placeholder_initially_show_content_when_contentready_truenull51     fun placeholder_initially_show_content_when_contentready_true() {
52         lateinit var contentReady: MutableState<Boolean>
53         lateinit var placeholderState: PlaceholderState
54         rule.setContentWithTheme {
55             contentReady = remember { mutableStateOf(true) }
56             placeholderState = rememberPlaceholderState { contentReady.value }
57         }
58 
59         // For testing we need to manually manage the frame clock for the placeholder animation
60         placeholderState.initializeTestFrameMillis(PlaceholderStage.ShowContent)
61 
62         // Advance placeholder clock without changing the content ready and confirm still in
63         // ShowPlaceholder
64         placeholderState.advanceToNextPlaceholderAnimationLoopAndCheckStage(
65             PlaceholderStage.ShowContent
66         )
67     }
68 
69     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
70     @OptIn(ExperimentalWearMaterialApi::class)
71     @Test
placeholder_initially_show_placeholder_transitions_correctlynull72     fun placeholder_initially_show_placeholder_transitions_correctly() {
73         lateinit var contentReady: MutableState<Boolean>
74         lateinit var placeholderState: PlaceholderState
75         rule.setContentWithTheme {
76             contentReady = remember { mutableStateOf(false) }
77             placeholderState = rememberPlaceholderState { contentReady.value }
78         }
79 
80         // For testing we need to manually manage the frame clock for the placeholder animation
81         placeholderState.initializeTestFrameMillis()
82 
83         // Advance placeholder clock without changing the content ready and confirm still in
84         // ShowPlaceholder
85         placeholderState.advanceFrameMillisAndCheckState(
86             PLACEHOLDER_SHIMMER_GAP_BETWEEN_ANIMATION_LOOPS_MS,
87             PlaceholderStage.ShowPlaceholder
88         )
89 
90         // Change contentReady and confirm that state is now WipeOff
91         contentReady.value = true
92         placeholderState.advanceFrameMillisAndCheckState(1L, PlaceholderStage.WipeOff)
93 
94         // Advance the clock by one cycle and check we have moved to ShowContent
95         placeholderState.advanceFrameMillisAndCheckState(
96             PLACEHOLDER_WIPE_OFF_PROGRESSION_DURATION_MS,
97             PlaceholderStage.ShowContent
98         )
99     }
100 
101     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
102     @OptIn(ExperimentalWearMaterialApi::class)
103     @Test
placeholder_resets_content_after_show_content_when_contentready_falsenull104     fun placeholder_resets_content_after_show_content_when_contentready_false() {
105         lateinit var contentReady: MutableState<Boolean>
106         lateinit var placeholderState: PlaceholderState
107         rule.setContentWithTheme {
108             contentReady = remember { mutableStateOf(true) }
109             placeholderState = rememberPlaceholderState { contentReady.value }
110             Chip(
111                 modifier = Modifier.fillMaxWidth(),
112                 content = {},
113                 onClick = {},
114                 colors = ChipDefaults.secondaryChipColors(),
115                 border = ChipDefaults.chipBorder()
116             )
117         }
118 
119         // For testing we need to manually manage the frame clock for the placeholder animation
120         placeholderState.initializeTestFrameMillis(PlaceholderStage.ShowContent)
121 
122         // Advance placeholder clock without changing the content ready and confirm still in
123         // ShowPlaceholder
124         placeholderState.advanceToNextPlaceholderAnimationLoopAndCheckStage(
125             PlaceholderStage.ShowContent
126         )
127 
128         contentReady.value = false
129 
130         // Check that the state is set to ResetContent
131         placeholderState.advanceFrameMillisAndCheckState(
132             (PLACEHOLDER_RESET_ANIMATION_DURATION * 0.5f).toLong(),
133             PlaceholderStage.ResetContent
134         )
135     }
136 
137     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
138     @Test
default_placeholder_is_correct_colornull139     fun default_placeholder_is_correct_color() {
140         placeholder_is_correct_color(null)
141     }
142 
143     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
144     @Test
custom_placeholder_is_correct_colornull145     fun custom_placeholder_is_correct_color() {
146         placeholder_is_correct_color(Color.Blue)
147     }
148 
149     @RequiresApi(Build.VERSION_CODES.O)
150     @OptIn(ExperimentalWearMaterialApi::class)
placeholder_is_correct_colornull151     fun placeholder_is_correct_color(placeholderColor: Color?) {
152         var expectedPlaceholderColor = Color.Transparent
153         var expectedBackgroundColor = Color.Transparent
154         lateinit var contentReady: MutableState<Boolean>
155         lateinit var placeholderState: PlaceholderState
156         rule.setContentWithTheme {
157             contentReady = remember { mutableStateOf(false) }
158             placeholderState = rememberPlaceholderState { contentReady.value }
159             expectedPlaceholderColor =
160                 placeholderColor
161                     ?: MaterialTheme.colors.onSurface
162                         .copy(alpha = 0.1f)
163                         .compositeOver(MaterialTheme.colors.surface)
164             expectedBackgroundColor = MaterialTheme.colors.primary
165             Chip(
166                 modifier =
167                     Modifier.testTag("test-item")
168                         .then(
169                             if (placeholderColor != null)
170                                 Modifier.placeholder(
171                                     placeholderState = placeholderState,
172                                     color = placeholderColor
173                                 )
174                             else Modifier.placeholder(placeholderState = placeholderState)
175                         ),
176                 content = {},
177                 onClick = {},
178                 colors = ChipDefaults.primaryChipColors(),
179                 border = ChipDefaults.chipBorder()
180             )
181         }
182 
183         // For testing we need to manually manage the frame clock for the placeholder animation
184         placeholderState.initializeTestFrameMillis()
185 
186         rule
187             .onNodeWithTag("test-item")
188             .captureToImage()
189             .assertContainsColor(expectedPlaceholderColor)
190 
191         // Change contentReady and confirm that state is now WipeOff
192         contentReady.value = true
193         placeholderState.advanceFrameMillisAndCheckState(1L, PlaceholderStage.WipeOff)
194 
195         // Advance the clock by one cycle and check we have moved to ShowContent
196         placeholderState.advanceFrameMillisAndCheckState(
197             PLACEHOLDER_WIPE_OFF_PROGRESSION_DURATION_MS,
198             PlaceholderStage.ShowContent
199         )
200 
201         rule
202             .onNodeWithTag("test-item")
203             .captureToImage()
204             .assertContainsColor(expectedBackgroundColor)
205     }
206 
207     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
208     @OptIn(ExperimentalWearMaterialApi::class)
209     @Test
placeholder_shimmer_visible_during_showplaceholder_onlynull210     fun placeholder_shimmer_visible_during_showplaceholder_only() {
211         var expectedBackgroundColor = Color.Transparent
212         lateinit var contentReady: MutableState<Boolean>
213         lateinit var placeholderState: PlaceholderState
214         rule.setContentWithTheme {
215             contentReady = remember { mutableStateOf(false) }
216             placeholderState = rememberPlaceholderState { contentReady.value }
217             expectedBackgroundColor = MaterialTheme.colors.surface
218 
219             Chip(
220                 modifier =
221                     Modifier.testTag("test-item")
222                         .fillMaxWidth()
223                         .placeholderShimmer(placeholderState = placeholderState),
224                 content = {},
225                 onClick = {},
226                 colors = ChipDefaults.secondaryChipColors(),
227                 border = ChipDefaults.chipBorder()
228             )
229         }
230 
231         placeholderState.initializeTestFrameMillis()
232 
233         // Check the background color is correct
234         rule
235             .onNodeWithTag("test-item")
236             .captureToImage()
237             .assertContainsColor(expectedBackgroundColor, 80f)
238 
239         placeholderState.moveToStartOfNextAnimationLoop(PlaceholderStage.ShowPlaceholder)
240 
241         // Move the start of the next placeholder shimmer animation loop and them advance the
242         // clock to show the shimmer.
243         placeholderState.advanceFrameMillisAndCheckState(
244             (PLACEHOLDER_SHIMMER_DURATION_MS * 0.5f).toLong(),
245             PlaceholderStage.ShowPlaceholder
246         )
247 
248         // The placeholder shimmer effect is faint and largely transparent gradiant, but it should
249         // reduce the amount of the normal color.
250         rule
251             .onNodeWithTag("test-item")
252             .captureToImage()
253             .assertDoesNotContainColor(expectedBackgroundColor)
254 
255         // Change contentReady and confirm that state is now WipeOff
256         contentReady.value = true
257         placeholderState.advanceFrameMillisAndCheckState(1L, PlaceholderStage.WipeOff)
258 
259         // Check the background color is correct
260         rule
261             .onNodeWithTag("test-item")
262             .captureToImage()
263             .assertContainsColor(expectedBackgroundColor, 80f)
264 
265         // Advance the clock by one cycle and check we have moved to ShowContent
266         placeholderState.advanceFrameMillisAndCheckState(
267             PLACEHOLDER_WIPE_OFF_PROGRESSION_DURATION_MS,
268             PlaceholderStage.ShowContent
269         )
270 
271         // Check that the shimmer is no longer visible
272         rule
273             .onNodeWithTag("test-item")
274             .captureToImage()
275             .assertContainsColor(expectedBackgroundColor, 80f)
276     }
277 
278     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
279     @OptIn(ExperimentalWearMaterialApi::class)
280     @Test
wipeoff_takes_background_offset_into_accountnull281     fun wipeoff_takes_background_offset_into_account() {
282         lateinit var contentReady: MutableState<Boolean>
283         lateinit var placeholderState: PlaceholderState
284         var expectedBackgroundColor = Color.Transparent
285         var expectedBackgroundPlaceholderColor: Color = Color.Transparent
286         rule.setContentWithTheme {
287             contentReady = remember { mutableStateOf(false) }
288             placeholderState = rememberPlaceholderState { contentReady.value }
289             val maxScreenDimensionPx =
290                 with(LocalDensity.current) {
291                     Dp(max(screenHeightDp(), screenWidthDp()).toFloat()).toPx()
292                 }
293             // Set the offset to be 50% of the screen
294             placeholderState.backgroundOffset =
295                 Offset(maxScreenDimensionPx / 2f, maxScreenDimensionPx / 2f)
296             expectedBackgroundColor = MaterialTheme.colors.primary
297             expectedBackgroundPlaceholderColor = MaterialTheme.colors.surface
298 
299             Chip(
300                 modifier = Modifier.testTag("test-item").fillMaxWidth(),
301                 content = {},
302                 onClick = {},
303                 colors =
304                     PlaceholderDefaults.placeholderChipColors(
305                         originalChipColors = ChipDefaults.primaryChipColors(),
306                         placeholderState = placeholderState,
307                     ),
308                 border = ChipDefaults.chipBorder()
309             )
310         }
311 
312         placeholderState.initializeTestFrameMillis()
313 
314         // Check the background color is correct
315         rule
316             .onNodeWithTag("test-item")
317             .captureToImage()
318             .assertContainsColor(expectedBackgroundPlaceholderColor, 80f)
319         // Check that there is primary color showing
320         rule
321             .onNodeWithTag("test-item")
322             .captureToImage()
323             .assertDoesNotContainColor(expectedBackgroundColor)
324 
325         // Change contentReady and confirm that state is now WipeOff
326         contentReady.value = true
327         placeholderState.advanceFrameMillisAndCheckState(1L, PlaceholderStage.WipeOff)
328 
329         // Check that placeholder background is still visible
330         rule
331             .onNodeWithTag("test-item")
332             .captureToImage()
333             .assertContainsColor(expectedBackgroundPlaceholderColor, 80f)
334 
335         // Move forward by 25% of the wipe-off and confirm that no wipe-off has happened yet due
336         // to our offset
337         placeholderState.advanceFrameMillisAndCheckState(
338             PLACEHOLDER_WIPE_OFF_PROGRESSION_DURATION_MS / 4,
339             PlaceholderStage.WipeOff
340         )
341 
342         // Check that placeholder background is still visible
343         rule
344             .onNodeWithTag("test-item")
345             .captureToImage()
346             .assertContainsColor(expectedBackgroundPlaceholderColor, 80f)
347 
348         // Now move the end of the wipe-off and confirm that the proper chip background is visible
349         placeholderState.advanceFrameMillisAndCheckState(
350             PLACEHOLDER_WIPE_OFF_PROGRESSION_DURATION_MS,
351             PlaceholderStage.ShowContent
352         )
353 
354         // Check that normal chip background is now visible
355         rule
356             .onNodeWithTag("test-item")
357             .captureToImage()
358             .assertContainsColor(expectedBackgroundColor, 80f)
359     }
360 
361     @OptIn(ExperimentalWearMaterialApi::class)
362     @Composable
TestPlaceholderChipnull363     fun TestPlaceholderChip(contents: String?, currentState: StableRef<PlaceholderState?>) {
364         val placeholderState =
365             rememberPlaceholderState { contents != null }.also { currentState.value = it }
366         Chip(
367             modifier = Modifier.testTag("test-item").placeholderShimmer(placeholderState),
368             content = {},
369             onClick = {},
370             colors =
371                 PlaceholderDefaults.placeholderChipColors(
372                     originalChipColors = ChipDefaults.primaryChipColors(),
373                     placeholderState = placeholderState,
374                 ),
375             border = ChipDefaults.chipBorder(),
376         )
377         LaunchedEffect(placeholderState) { placeholderState.startPlaceholderAnimation() }
378     }
379 
380     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
381     @OptIn(ExperimentalWearMaterialApi::class)
382     @Test
placeholder_lambda_update_worksnull383     fun placeholder_lambda_update_works() {
384         val placeholderState = StableRef<PlaceholderState?>(null)
385         val contentsHolder = StableRef<MutableState<String?>>(mutableStateOf(null))
386         rule.setContentWithTheme {
387             val contents: MutableState<String?> = remember { mutableStateOf(null) }
388             contentsHolder.value = contents
389             TestPlaceholderChip(contents = contents.value, placeholderState)
390         }
391 
392         rule.waitForIdle()
393 
394         placeholderState.value?.initializeTestFrameMillis()
395 
396         assertThat(placeholderState.value).isNotNull()
397         assertThat(placeholderState.value?.placeholderStage)
398             .isEqualTo(PlaceholderStage.ShowPlaceholder)
399 
400         contentsHolder.value.value = "Test"
401 
402         // Trigger move to WipeOff stage
403         placeholderState.value?.advanceFrameMillisAndCheckState(1, PlaceholderStage.WipeOff)
404 
405         placeholderState.value?.advanceFrameMillisAndCheckState(
406             PLACEHOLDER_WIPE_OFF_PROGRESSION_DURATION_MS,
407             PlaceholderStage.ShowContent
408         )
409     }
410 
411     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
412     @OptIn(ExperimentalWearMaterialApi::class)
413     @Test
placeholder_background_is_correct_colornull414     fun placeholder_background_is_correct_color() {
415         var expectedPlaceholderBackgroundColor = Color.Transparent
416         var expectedBackgroundColor = Color.Transparent
417         lateinit var contentReady: MutableState<Boolean>
418         lateinit var placeholderState: PlaceholderState
419         rule.setContentWithTheme {
420             contentReady = remember { mutableStateOf(false) }
421             placeholderState = rememberPlaceholderState { contentReady.value }
422             expectedPlaceholderBackgroundColor = MaterialTheme.colors.surface
423             expectedBackgroundColor = MaterialTheme.colors.primary
424             Chip(
425                 modifier =
426                     Modifier.testTag("test-item")
427                         .fillMaxWidth()
428                         .placeholderShimmer(placeholderState),
429                 content = {},
430                 onClick = {},
431                 colors =
432                     PlaceholderDefaults.placeholderChipColors(
433                         originalChipColors = ChipDefaults.primaryChipColors(),
434                         placeholderState = placeholderState,
435                     ),
436                 border = ChipDefaults.chipBorder(),
437             )
438             LaunchedEffect(placeholderState) { placeholderState.startPlaceholderAnimation() }
439         }
440 
441         placeholderState.initializeTestFrameMillis()
442 
443         rule
444             .onNodeWithTag("test-item")
445             .captureToImage()
446             .assertContainsColor(expectedPlaceholderBackgroundColor)
447 
448         // Change contentReady and confirm that state is now WipeOff
449         contentReady.value = true
450         placeholderState.advanceFrameMillisAndCheckState(1L, PlaceholderStage.WipeOff)
451 
452         placeholderState.advanceFrameMillisAndCheckState(
453             PLACEHOLDER_WIPE_OFF_PROGRESSION_DURATION_MS,
454             PlaceholderStage.ShowContent
455         )
456 
457         // Check the placeholder background has gone and that we can see the chips background
458         rule
459             .onNodeWithTag("test-item")
460             .captureToImage()
461             .assertContainsColor(expectedBackgroundColor)
462     }
463 
464     @OptIn(ExperimentalWearMaterialApi::class)
PlaceholderStatenull465     private fun PlaceholderState.advanceFrameMillisAndCheckState(
466         timeToAdd: Long,
467         expectedStage: PlaceholderStage
468     ) {
469         frameMillis.value += timeToAdd
470         rule.waitForIdle()
471         assertThat(placeholderStage).isEqualTo(expectedStage)
472     }
473 
474     @OptIn(ExperimentalWearMaterialApi::class)
PlaceholderStatenull475     private fun PlaceholderState.advanceToNextPlaceholderAnimationLoopAndCheckStage(
476         expectedStage: PlaceholderStage
477     ) {
478         frameMillis.value += PLACEHOLDER_SHIMMER_DURATION_MS
479         rule.waitForIdle()
480         assertThat(placeholderStage).isEqualTo(expectedStage)
481     }
482 
483     @OptIn(ExperimentalWearMaterialApi::class)
PlaceholderStatenull484     private fun PlaceholderState.initializeTestFrameMillis(
485         initialPlaceholderStage: PlaceholderStage = PlaceholderStage.ShowPlaceholder
486     ): Long {
487         val currentTime = rule.mainClock.currentTime
488         frameMillis.value = currentTime
489         rule.waitForIdle()
490         assertThat(placeholderStage).isEqualTo(initialPlaceholderStage)
491         return currentTime
492     }
493 
494     @OptIn(ExperimentalWearMaterialApi::class)
PlaceholderStatenull495     private fun PlaceholderState.moveToStartOfNextAnimationLoop(
496         expectedPlaceholderStage: PlaceholderStage = PlaceholderStage.ShowPlaceholder
497     ) {
498         val animationLoopStart =
499             (frameMillis.longValue.div(PLACEHOLDER_SHIMMER_GAP_BETWEEN_ANIMATION_LOOPS_MS) + 1) *
500                 PLACEHOLDER_SHIMMER_GAP_BETWEEN_ANIMATION_LOOPS_MS
501         frameMillis.longValue = animationLoopStart
502         rule.waitForIdle()
503         assertThat(placeholderStage).isEqualTo(expectedPlaceholderStage)
504     }
505 }
506