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