1 /* <lambda>null2 * Copyright 2021 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.compose.foundation.ScrollState 21 import androidx.compose.foundation.horizontalScroll 22 import androidx.compose.foundation.layout.Arrangement 23 import androidx.compose.foundation.layout.Box 24 import androidx.compose.foundation.layout.Column 25 import androidx.compose.foundation.layout.fillMaxSize 26 import androidx.compose.foundation.rememberScrollState 27 import androidx.compose.runtime.Composable 28 import androidx.compose.runtime.LaunchedEffect 29 import androidx.compose.runtime.getValue 30 import androidx.compose.runtime.mutableStateOf 31 import androidx.compose.runtime.saveable.SaveableStateHolder 32 import androidx.compose.runtime.saveable.rememberSaveable 33 import androidx.compose.runtime.saveable.rememberSaveableStateHolder 34 import androidx.compose.runtime.setValue 35 import androidx.compose.ui.Alignment 36 import androidx.compose.ui.Modifier 37 import androidx.compose.ui.geometry.Offset 38 import androidx.compose.ui.platform.testTag 39 import androidx.compose.ui.test.TouchInjectionScope 40 import androidx.compose.ui.test.assertIsOff 41 import androidx.compose.ui.test.assertIsOn 42 import androidx.compose.ui.test.assertTextContains 43 import androidx.compose.ui.test.junit4.createComposeRule 44 import androidx.compose.ui.test.onNodeWithTag 45 import androidx.compose.ui.test.onNodeWithText 46 import androidx.compose.ui.test.performClick 47 import androidx.compose.ui.test.performTouchInput 48 import androidx.compose.ui.test.swipe 49 import androidx.compose.ui.test.swipeLeft 50 import androidx.compose.ui.test.swipeRight 51 import androidx.compose.ui.test.swipeWithVelocity 52 import androidx.test.filters.SdkSuppress 53 import androidx.wear.compose.foundation.SwipeToDismissBoxState 54 import androidx.wear.compose.foundation.SwipeToDismissValue 55 import androidx.wear.compose.foundation.edgeSwipeToDismiss 56 import androidx.wear.compose.foundation.rememberSwipeToDismissBoxState 57 import com.google.common.truth.Truth.assertThat 58 import java.lang.Math.sin 59 import org.junit.Assert.assertEquals 60 import org.junit.Rule 61 import org.junit.Test 62 63 @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) 64 class SwipeToDismissBoxTest { 65 @get:Rule val rule = createComposeRule() 66 67 @Test 68 fun supports_testtag() { 69 rule.setContentWithTheme { 70 val state = rememberSwipeToDismissBoxState() 71 SwipeToDismissBox(state = state, modifier = Modifier.testTag(TEST_TAG)) { 72 Text("Testing") 73 } 74 } 75 76 rule.onNodeWithTag(TEST_TAG).assertExists() 77 } 78 79 @Test 80 fun dismisses_when_swiped_right() = 81 verifySwipe(gesture = { swipeRight() }, expectedToDismiss = true) 82 83 @Test 84 fun does_not_dismiss_when_swiped_left() = 85 // Swipe left is met with resistance and is not a swipe-to-dismiss. 86 verifySwipe(gesture = { swipeLeft() }, expectedToDismiss = false) 87 88 @Test 89 fun does_not_dismiss_when_swipe_right_incomplete() = 90 // Execute a partial swipe over a longer-than-default duration so that there 91 // is insufficient velocity to perform a 'fling'. 92 verifySwipe( 93 gesture = { 94 swipeWithVelocity( 95 start = Offset(0f, centerY), 96 end = Offset(centerX / 2f, centerY), 97 endVelocity = 1.0f 98 ) 99 }, 100 expectedToDismiss = false 101 ) 102 103 @Test 104 fun does_not_display_background_without_swipe() { 105 rule.setContentWithTheme { 106 val state = rememberSwipeToDismissBoxState() 107 SwipeToDismissBox( 108 state = state, 109 modifier = Modifier.testTag(TEST_TAG), 110 ) { isBackground -> 111 if (isBackground) Text(BACKGROUND_MESSAGE) else messageContent() 112 } 113 } 114 115 rule.onNodeWithText(BACKGROUND_MESSAGE).assertDoesNotExist() 116 } 117 118 @Test 119 fun does_not_dismiss_if_has_background_is_false() { 120 var dismissed = false 121 rule.setContentWithTheme { 122 val state = rememberSwipeToDismissBoxState() 123 LaunchedEffect(state.currentValue) { 124 dismissed = state.currentValue == SwipeToDismissValue.Dismissed 125 } 126 SwipeToDismissBox( 127 state = state, 128 modifier = Modifier.testTag(TEST_TAG), 129 hasBackground = false, 130 ) { 131 Text(CONTENT_MESSAGE, color = MaterialTheme.colors.onPrimary) 132 } 133 } 134 135 rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() }) 136 137 rule.runOnIdle { assertEquals(false, dismissed) } 138 } 139 140 @Test 141 fun remembers_saved_state() { 142 val showCounterForContent = mutableStateOf(true) 143 rule.setContentWithTheme { 144 val state = rememberSwipeToDismissBoxState() 145 val holder = rememberSaveableStateHolder() 146 LaunchedEffect(state.currentValue) { 147 if (state.currentValue == SwipeToDismissValue.Dismissed) { 148 showCounterForContent.value = !showCounterForContent.value 149 state.snapTo(SwipeToDismissValue.Default) 150 } 151 } 152 SwipeToDismissBox( 153 state = state, 154 modifier = Modifier.testTag(TEST_TAG), 155 backgroundKey = if (showCounterForContent.value) TOGGLE_SCREEN else COUNTER_SCREEN, 156 contentKey = if (showCounterForContent.value) COUNTER_SCREEN else TOGGLE_SCREEN, 157 content = { isBackground -> 158 if (showCounterForContent.value xor isBackground) counterScreen(holder) 159 else toggleScreen(holder) 160 } 161 ) 162 } 163 164 // Start with foreground showing Counter screen. 165 rule.onNodeWithTag(COUNTER_SCREEN).assertTextContains("0") 166 rule.onNodeWithTag(COUNTER_SCREEN).performClick() 167 rule.waitForIdle() 168 rule.onNodeWithTag(COUNTER_SCREEN).assertTextContains("1") 169 170 // Swipe to switch to Toggle screen 171 rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() }) 172 rule.waitForIdle() 173 rule.onNodeWithTag(TOGGLE_SCREEN).assertIsOff() 174 rule.onNodeWithTag(TOGGLE_SCREEN).performClick() 175 rule.waitForIdle() 176 rule.onNodeWithTag(TOGGLE_SCREEN).assertIsOn() 177 178 // Swipe back to Counter screen 179 rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() }) 180 rule.waitForIdle() 181 rule.onNodeWithTag(COUNTER_SCREEN).assertTextContains("1") 182 183 // Swipe back to Toggle screen 184 rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() }) 185 rule.waitForIdle() 186 rule.onNodeWithTag(TOGGLE_SCREEN).assertIsOn() 187 } 188 189 @Test 190 fun gives_top_swipe_box_gestures_when_nested() { 191 var outerDismissed = false 192 var innerDismissed = false 193 rule.setContentWithTheme { 194 val outerState = rememberSwipeToDismissBoxState() 195 LaunchedEffect(outerState.currentValue) { 196 outerDismissed = outerState.currentValue == SwipeToDismissValue.Dismissed 197 } 198 SwipeToDismissBox( 199 state = outerState, 200 modifier = Modifier.testTag("OUTER"), 201 hasBackground = true, 202 ) { 203 Text("Outer", color = MaterialTheme.colors.onPrimary) 204 val innerState = rememberSwipeToDismissBoxState() 205 LaunchedEffect(innerState.currentValue) { 206 innerDismissed = innerState.currentValue == SwipeToDismissValue.Dismissed 207 } 208 SwipeToDismissBox( 209 state = innerState, 210 modifier = Modifier.testTag("INNER"), 211 hasBackground = true, 212 ) { 213 Text( 214 text = "Inner", 215 color = MaterialTheme.colors.onPrimary, 216 modifier = Modifier.testTag(TEST_TAG) 217 ) 218 } 219 } 220 } 221 222 rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() }) 223 224 rule.runOnIdle { 225 assertEquals(true, innerDismissed) 226 assertEquals(false, outerDismissed) 227 } 228 } 229 230 @Composable 231 fun toggleScreen(saveableStateHolder: SaveableStateHolder) { 232 saveableStateHolder.SaveableStateProvider(TOGGLE_SCREEN) { 233 var toggle by rememberSaveable { mutableStateOf(false) } 234 ToggleButton( 235 checked = toggle, 236 onCheckedChange = { toggle = !toggle }, 237 content = { Text(text = if (toggle) TOGGLE_ON else TOGGLE_OFF) }, 238 modifier = Modifier.testTag(TOGGLE_SCREEN) 239 ) 240 } 241 } 242 243 @Composable 244 fun counterScreen(saveableStateHolder: SaveableStateHolder) { 245 saveableStateHolder.SaveableStateProvider(COUNTER_SCREEN) { 246 var counter by rememberSaveable { mutableStateOf(0) } 247 Button(onClick = { ++counter }, modifier = Modifier.testTag(COUNTER_SCREEN)) { 248 Text(text = "" + counter) 249 } 250 } 251 } 252 253 @Test 254 fun displays_background_during_swipe() = 255 verifyPartialSwipe(expectedMessage = BACKGROUND_MESSAGE) 256 257 @Test 258 fun displays_content_during_swipe() = verifyPartialSwipe(expectedMessage = CONTENT_MESSAGE) 259 260 @Test 261 fun calls_ondismissed_after_swipe_when_supplied() { 262 var dismissed = false 263 rule.setContentWithTheme { 264 SwipeToDismissBox( 265 onDismissed = { dismissed = true }, 266 modifier = Modifier.testTag(TEST_TAG), 267 ) { 268 Text(CONTENT_MESSAGE, color = MaterialTheme.colors.onPrimary) 269 } 270 } 271 272 rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() }) 273 274 rule.runOnIdle { assertEquals(true, dismissed) } 275 } 276 277 @Test 278 fun edgeswipe_modifier_edge_swiped_right_dismissed() { 279 verifyEdgeSwipeWithNestedScroll(gesture = { swipeRight() }, expectedToDismiss = true) 280 } 281 282 @Test 283 fun edgeswipe_non_edge_swiped_right_with_offset_not_dismissed() { 284 verifyEdgeSwipeWithNestedScroll( 285 gesture = { swipeRight(200f, 400f) }, 286 expectedToDismiss = false, 287 initialScrollState = 200 288 ) 289 } 290 291 @Test 292 fun edgeswipe_non_edge_swiped_right_without_offset_not_dismissed() { 293 verifyEdgeSwipeWithNestedScroll( 294 gesture = { swipeRight(200f, 400f) }, 295 expectedToDismiss = false, 296 initialScrollState = 0 297 ) 298 } 299 300 @Test 301 fun edgeswipe_edge_swiped_left_not_dismissed() { 302 verifyEdgeSwipeWithNestedScroll( 303 gesture = { swipeLeft(20f, -40f) }, 304 expectedToDismiss = false 305 ) 306 } 307 308 @Test 309 fun edgeswipe_non_edge_swiped_left_not_dismissed() { 310 verifyEdgeSwipeWithNestedScroll( 311 gesture = { swipeLeft(200f, 0f) }, 312 expectedToDismiss = false 313 ) 314 } 315 316 @Test 317 fun edgeswipe_swipe_edge_content_was_not_swiped_right() { 318 val initialScrollState = 200 319 lateinit var horizontalScrollState: ScrollState 320 rule.setContentWithTheme { 321 val state = rememberSwipeToDismissBoxState() 322 horizontalScrollState = rememberScrollState(initialScrollState) 323 324 SwipeToDismissBox( 325 state = state, 326 modifier = Modifier.testTag(TEST_TAG), 327 ) { 328 nestedScrollContent(state, horizontalScrollState) 329 } 330 } 331 332 rule.onNodeWithTag(TEST_TAG).performTouchInput { swipeRight(0f, 200f) } 333 rule.runOnIdle { assertThat(horizontalScrollState.value == initialScrollState).isTrue() } 334 } 335 336 @Test 337 fun edgeswipe_swipe_non_edge_content_was_swiped_right() { 338 val initialScrollState = 200 339 lateinit var horizontalScrollState: ScrollState 340 rule.setContentWithTheme { 341 val state = rememberSwipeToDismissBoxState() 342 horizontalScrollState = rememberScrollState(initialScrollState) 343 344 SwipeToDismissBox( 345 state = state, 346 modifier = Modifier.testTag(TEST_TAG), 347 ) { 348 nestedScrollContent(state, horizontalScrollState) 349 } 350 } 351 352 rule.onNodeWithTag(TEST_TAG).performTouchInput { swipeRight(200f, 400f) } 353 rule.runOnIdle { assertThat(horizontalScrollState.value < initialScrollState).isTrue() } 354 } 355 356 @Test 357 fun edgeswipe_swipe_edge_content_right_then_left_no_scroll() { 358 testBothDirectionScroll( 359 initialTouch = 10, 360 duration = 2000, 361 amplitude = 100, 362 startLeft = false 363 ) { scrollState -> 364 assertEquals(scrollState.value, 200) 365 } 366 } 367 368 @Test 369 fun edgeswipe_fling_edge_content_right_then_left_no_scroll() { 370 testBothDirectionScroll( 371 initialTouch = 10, 372 duration = 100, 373 amplitude = 100, 374 startLeft = false 375 ) { scrollState -> 376 assertEquals(scrollState.value, 200) 377 } 378 } 379 380 @Test 381 fun edgeswipe_swipe_edge_content_left_then_right_with_scroll() { 382 testBothDirectionScroll( 383 initialTouch = 10, 384 duration = 2000, 385 amplitude = 100, 386 startLeft = true 387 ) { scrollState -> 388 // After scrolling to the left, successful scroll to the right 389 // reduced scrollState 390 assertThat(scrollState.value < 200).isTrue() 391 } 392 } 393 394 @Test 395 fun edgeswipe_fling_edge_content_left_then_right_with_scroll() { 396 testBothDirectionScroll( 397 initialTouch = 10, 398 duration = 100, 399 amplitude = 100, 400 startLeft = true 401 ) { scrollState -> 402 // Fling right to the start (0) 403 assertEquals(scrollState.value, 0) 404 } 405 } 406 407 private fun testBothDirectionScroll( 408 initialTouch: Long, 409 duration: Long, 410 amplitude: Long, 411 startLeft: Boolean, 412 testScrollState: (ScrollState) -> Unit 413 ) { 414 val initialScrollState = 200 415 lateinit var horizontalScrollState: ScrollState 416 rule.setContentWithTheme { 417 val state = rememberSwipeToDismissBoxState() 418 horizontalScrollState = rememberScrollState(initialScrollState) 419 420 SwipeToDismissBox( 421 state = state, 422 modifier = Modifier.testTag(TEST_TAG), 423 ) { 424 nestedScrollContent(state, horizontalScrollState) 425 } 426 } 427 428 rule.onNodeWithTag(TEST_TAG).performTouchInput { 429 swipeBothDirections( 430 startLeft = startLeft, 431 startX = initialTouch, 432 amplitude = amplitude, 433 duration = duration 434 ) 435 } 436 rule.runOnIdle { testScrollState(horizontalScrollState) } 437 } 438 439 private fun verifySwipe(gesture: TouchInjectionScope.() -> Unit, expectedToDismiss: Boolean) { 440 var dismissed = false 441 rule.setContentWithTheme { 442 val state = rememberSwipeToDismissBoxState() 443 LaunchedEffect(state.currentValue) { 444 dismissed = state.currentValue == SwipeToDismissValue.Dismissed 445 } 446 SwipeToDismissBox( 447 state = state, 448 modifier = Modifier.testTag(TEST_TAG), 449 ) { 450 messageContent() 451 } 452 } 453 454 rule.onNodeWithTag(TEST_TAG).performTouchInput(gesture) 455 456 rule.runOnIdle { assertEquals(expectedToDismiss, dismissed) } 457 } 458 459 private fun verifyEdgeSwipeWithNestedScroll( 460 gesture: TouchInjectionScope.() -> Unit, 461 expectedToDismiss: Boolean, 462 initialScrollState: Int = 200 463 ) { 464 var dismissed = false 465 rule.setContentWithTheme { 466 val state = rememberSwipeToDismissBoxState() 467 val horizontalScrollState = rememberScrollState(initialScrollState) 468 469 LaunchedEffect(state.currentValue) { 470 dismissed = state.currentValue == SwipeToDismissValue.Dismissed 471 } 472 SwipeToDismissBox( 473 state = state, 474 modifier = Modifier.testTag(TEST_TAG), 475 ) { 476 nestedScrollContent(state, horizontalScrollState) 477 } 478 } 479 480 rule.onNodeWithTag(TEST_TAG).performTouchInput(gesture) 481 482 rule.runOnIdle { assertEquals(expectedToDismiss, dismissed) } 483 } 484 485 private fun verifyPartialSwipe(expectedMessage: String) { 486 rule.setContentWithTheme { 487 val state = rememberSwipeToDismissBoxState() 488 SwipeToDismissBox( 489 state = state, 490 modifier = Modifier.testTag(TEST_TAG), 491 ) { isBackground -> 492 if (isBackground) Text(BACKGROUND_MESSAGE) else messageContent() 493 } 494 } 495 496 // Click down and drag across 1/4 of the screen to start a swipe, 497 // but don't release the finger, so that the screen can be inspected 498 // (note that swipeRight would release the finger and does not pause time midway). 499 rule 500 .onNodeWithTag(TEST_TAG) 501 .performTouchInput({ 502 down(Offset(x = 0f, y = height / 2f)) 503 moveTo(Offset(x = width / 4f, y = height / 2f)) 504 }) 505 506 rule.onNodeWithText(expectedMessage).assertExists() 507 } 508 509 @Composable 510 private fun messageContent() { 511 Column( 512 modifier = Modifier.fillMaxSize(), 513 horizontalAlignment = Alignment.CenterHorizontally, 514 verticalArrangement = Arrangement.Center, 515 ) { 516 Text(CONTENT_MESSAGE, color = MaterialTheme.colors.onPrimary) 517 } 518 } 519 520 @Composable 521 private fun nestedScrollContent( 522 swipeToDismissState: SwipeToDismissBoxState, 523 horizontalScrollState: ScrollState 524 ) { 525 Box(modifier = Modifier.fillMaxSize()) { 526 Text( 527 modifier = 528 Modifier.align(Alignment.Center) 529 .edgeSwipeToDismiss(swipeToDismissState) 530 .horizontalScroll(horizontalScrollState), 531 text = 532 "This text can be scrolled horizontally - to dismiss, swipe " + 533 "right from the left edge of the screen (called Edge Swiping)", 534 ) 535 } 536 } 537 538 private fun TouchInjectionScope.swipeBothDirections( 539 startLeft: Boolean, 540 startX: Long, 541 amplitude: Long, 542 duration: Long = 200 543 ) { 544 val sign = if (startLeft) -1 else 1 545 // By using sin function for range 0.. 3pi/2 , we can achieve 0 -> 1 and 1 -> -1 values 546 swipe( 547 curve = { time -> 548 val x = 549 startX + 550 sign * 551 sin(time.toFloat() / duration.toFloat() * 3 * Math.PI / 2).toFloat() * 552 amplitude 553 Offset(x = x, y = centerY) 554 }, 555 durationMillis = duration 556 ) 557 } 558 } 559 560 private const val BACKGROUND_MESSAGE = "The Background" 561 private const val CONTENT_MESSAGE = "The Content" 562 private const val TOGGLE_SCREEN = "Toggle" 563 private const val COUNTER_SCREEN = "Counter" 564 private const val TOGGLE_ON = "On" 565 private const val TOGGLE_OFF = "Off" 566