1 /* <lambda>null2 * Copyright (C) 2024 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 com.android.compose.gesture 18 19 import androidx.compose.foundation.ScrollState 20 import androidx.compose.foundation.gestures.Orientation 21 import androidx.compose.foundation.horizontalScroll 22 import androidx.compose.foundation.layout.Box 23 import androidx.compose.foundation.layout.fillMaxSize 24 import androidx.compose.foundation.rememberScrollState 25 import androidx.compose.foundation.verticalScroll 26 import androidx.compose.runtime.Composable 27 import androidx.compose.runtime.getValue 28 import androidx.compose.runtime.mutableStateOf 29 import androidx.compose.runtime.remember 30 import androidx.compose.runtime.setValue 31 import androidx.compose.ui.Modifier 32 import androidx.compose.ui.geometry.Offset 33 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection 34 import androidx.compose.ui.input.nestedscroll.nestedScroll 35 import androidx.compose.ui.platform.LocalViewConfiguration 36 import androidx.compose.ui.test.junit4.ComposeContentTestRule 37 import androidx.compose.ui.test.junit4.createComposeRule 38 import androidx.compose.ui.test.onRoot 39 import androidx.compose.ui.test.performTouchInput 40 import androidx.compose.ui.test.swipeDown 41 import androidx.compose.ui.test.swipeLeft 42 import androidx.compose.ui.unit.Velocity 43 import com.google.common.truth.Truth.assertThat 44 import kotlin.math.ceil 45 import kotlinx.coroutines.awaitCancellation 46 import org.junit.Ignore 47 import org.junit.Rule 48 import org.junit.Test 49 import org.junit.runner.RunWith 50 import org.junit.runners.Parameterized 51 52 @RunWith(Parameterized::class) 53 class NestedDraggableTest(override val orientation: Orientation) : OrientationAware { 54 companion object { 55 @Parameterized.Parameters(name = "{0}") 56 @JvmStatic 57 fun orientations() = listOf(Orientation.Horizontal, Orientation.Vertical) 58 } 59 60 @get:Rule val rule = createComposeRule() 61 62 @Test 63 fun simpleDrag() { 64 val draggable = TestDraggable() 65 val touchSlop = 66 rule.setContentWithTouchSlop { 67 Box(Modifier.fillMaxSize().nestedDraggable(draggable, orientation)) 68 } 69 70 assertThat(draggable.onDragStartedCalled).isFalse() 71 assertThat(draggable.onDragCalled).isFalse() 72 assertThat(draggable.onDragStoppedCalled).isFalse() 73 74 var rootCenter = Offset.Zero 75 rule.onRoot().performTouchInput { 76 rootCenter = center 77 down(center) 78 moveBy((touchSlop + 10f).toOffset()) 79 } 80 81 assertThat(draggable.onDragStartedCalled).isTrue() 82 assertThat(draggable.onDragCalled).isTrue() 83 assertThat(draggable.onDragDelta).isEqualTo(10f) 84 assertThat(draggable.onDragStartedPosition).isEqualTo(rootCenter) 85 assertThat(draggable.onDragStartedSign).isEqualTo(1f) 86 assertThat(draggable.onDragStoppedCalled).isFalse() 87 88 rule.onRoot().performTouchInput { moveBy(20f.toOffset()) } 89 90 assertThat(draggable.onDragDelta).isEqualTo(30f) 91 assertThat(draggable.onDragStoppedCalled).isFalse() 92 93 rule.onRoot().performTouchInput { 94 moveBy((-15f).toOffset()) 95 up() 96 } 97 98 assertThat(draggable.onDragDelta).isEqualTo(15f) 99 assertThat(draggable.onDragStoppedCalled).isTrue() 100 } 101 102 @Test 103 fun nestedScrollable() { 104 val draggable = TestDraggable() 105 val touchSlop = 106 rule.setContentWithTouchSlop { 107 Box( 108 Modifier.fillMaxSize() 109 .nestedDraggable(draggable, orientation) 110 .nestedScrollable(rememberScrollState()) 111 ) 112 } 113 114 assertThat(draggable.onDragStartedCalled).isFalse() 115 assertThat(draggable.onDragCalled).isFalse() 116 assertThat(draggable.onDragStoppedCalled).isFalse() 117 118 var rootCenter = Offset.Zero 119 rule.onRoot().performTouchInput { 120 rootCenter = center 121 down(center) 122 moveBy((-touchSlop - 10f).toOffset()) 123 } 124 125 assertThat(draggable.onDragStartedCalled).isTrue() 126 assertThat(draggable.onDragCalled).isTrue() 127 assertThat(draggable.onDragDelta).isEqualTo(-10f) 128 assertThat(draggable.onDragStartedPosition).isEqualTo(rootCenter) 129 assertThat(draggable.onDragStartedSign).isEqualTo(-1f) 130 assertThat(draggable.onDragStoppedCalled).isFalse() 131 132 rule.onRoot().performTouchInput { moveBy((-20f).toOffset()) } 133 134 assertThat(draggable.onDragStartedCalled).isTrue() 135 assertThat(draggable.onDragCalled).isTrue() 136 assertThat(draggable.onDragDelta).isEqualTo(-30f) 137 assertThat(draggable.onDragStoppedCalled).isFalse() 138 139 rule.onRoot().performTouchInput { 140 moveBy(15f.toOffset()) 141 up() 142 } 143 144 assertThat(draggable.onDragStartedCalled).isTrue() 145 assertThat(draggable.onDragCalled).isTrue() 146 assertThat(draggable.onDragDelta).isEqualTo(-15f) 147 assertThat(draggable.onDragStoppedCalled).isTrue() 148 } 149 150 @Test 151 fun onDragStoppedIsCalledWhenDraggableIsUpdatedAndReset() { 152 val draggable = TestDraggable() 153 var orientation by mutableStateOf(orientation) 154 val touchSlop = 155 rule.setContentWithTouchSlop { 156 Box(Modifier.fillMaxSize().nestedDraggable(draggable, orientation)) 157 } 158 159 assertThat(draggable.onDragStartedCalled).isFalse() 160 161 rule.onRoot().performTouchInput { 162 down(center) 163 moveBy(touchSlop.toOffset()) 164 } 165 166 assertThat(draggable.onDragStartedCalled).isTrue() 167 assertThat(draggable.onDragStoppedCalled).isFalse() 168 169 orientation = 170 when (orientation) { 171 Orientation.Horizontal -> Orientation.Vertical 172 Orientation.Vertical -> Orientation.Horizontal 173 } 174 rule.waitForIdle() 175 assertThat(draggable.onDragStoppedCalled).isTrue() 176 } 177 178 @Test 179 fun onDragStoppedIsCalledWhenDraggableIsUpdatedAndReset_nestedScroll() { 180 val draggable = TestDraggable() 181 var orientation by mutableStateOf(orientation) 182 val touchSlop = 183 rule.setContentWithTouchSlop { 184 Box( 185 Modifier.fillMaxSize() 186 .nestedDraggable(draggable, orientation) 187 .nestedScrollable(rememberScrollState()) 188 ) 189 } 190 191 assertThat(draggable.onDragStartedCalled).isFalse() 192 193 rule.onRoot().performTouchInput { 194 down(center) 195 moveBy((touchSlop + 1f).toOffset()) 196 } 197 198 assertThat(draggable.onDragStartedCalled).isTrue() 199 assertThat(draggable.onDragStoppedCalled).isFalse() 200 201 orientation = 202 when (orientation) { 203 Orientation.Horizontal -> Orientation.Vertical 204 Orientation.Vertical -> Orientation.Horizontal 205 } 206 rule.waitForIdle() 207 assertThat(draggable.onDragStoppedCalled).isTrue() 208 } 209 210 @Test 211 fun onDragStoppedIsCalledWhenDraggableIsRemovedDuringDrag() { 212 val draggable = TestDraggable() 213 var composeContent by mutableStateOf(true) 214 val touchSlop = 215 rule.setContentWithTouchSlop { 216 if (composeContent) { 217 Box(Modifier.fillMaxSize().nestedDraggable(draggable, orientation)) 218 } 219 } 220 221 assertThat(draggable.onDragStartedCalled).isFalse() 222 223 rule.onRoot().performTouchInput { 224 down(center) 225 moveBy(touchSlop.toOffset()) 226 } 227 228 assertThat(draggable.onDragStartedCalled).isTrue() 229 assertThat(draggable.onDragStoppedCalled).isFalse() 230 231 composeContent = false 232 rule.waitForIdle() 233 assertThat(draggable.onDragStoppedCalled).isTrue() 234 } 235 236 @Test 237 fun onDragStoppedIsCalledWhenDraggableIsRemovedDuringDrag_nestedScroll() { 238 val draggable = TestDraggable() 239 var composeContent by mutableStateOf(true) 240 val touchSlop = 241 rule.setContentWithTouchSlop { 242 if (composeContent) { 243 Box( 244 Modifier.fillMaxSize() 245 .nestedDraggable(draggable, orientation) 246 .nestedScrollable(rememberScrollState()) 247 ) 248 } 249 } 250 251 assertThat(draggable.onDragStartedCalled).isFalse() 252 253 rule.onRoot().performTouchInput { 254 down(center) 255 moveBy((touchSlop + 1f).toOffset()) 256 } 257 258 assertThat(draggable.onDragStartedCalled).isTrue() 259 assertThat(draggable.onDragStoppedCalled).isFalse() 260 261 composeContent = false 262 rule.waitForIdle() 263 assertThat(draggable.onDragStoppedCalled).isTrue() 264 } 265 266 @Test 267 fun onDragStoppedIsCalledWhenDraggableIsRemovedDuringFling() { 268 val draggable = TestDraggable() 269 var composeContent by mutableStateOf(true) 270 var preFlingCalled = false 271 rule.setContent { 272 if (composeContent) { 273 Box( 274 Modifier.fillMaxSize() 275 // This nested scroll connection indefinitely suspends on pre fling, so that 276 // we can emulate what happens when the draggable is removed from 277 // composition while the pre-fling happens and onDragStopped() was not 278 // called yet. 279 .nestedScroll( 280 remember { 281 object : NestedScrollConnection { 282 override suspend fun onPreFling(available: Velocity): Velocity { 283 preFlingCalled = true 284 awaitCancellation() 285 } 286 } 287 } 288 ) 289 .nestedDraggable(draggable, orientation) 290 ) 291 } 292 } 293 294 assertThat(draggable.onDragStartedCalled).isFalse() 295 296 // Swipe down. 297 rule.onRoot().performTouchInput { 298 when (orientation) { 299 Orientation.Horizontal -> swipeLeft() 300 Orientation.Vertical -> swipeDown() 301 } 302 } 303 304 assertThat(draggable.onDragStartedCalled).isTrue() 305 assertThat(draggable.onDragStoppedCalled).isFalse() 306 assertThat(preFlingCalled).isTrue() 307 308 composeContent = false 309 rule.waitForIdle() 310 assertThat(draggable.onDragStoppedCalled).isTrue() 311 } 312 313 @Test 314 @Ignore("b/303224944#comment22") 315 fun onDragStoppedIsCalledWhenNestedScrollableIsRemoved() { 316 val draggable = TestDraggable() 317 var composeNestedScrollable by mutableStateOf(true) 318 val touchSlop = 319 rule.setContentWithTouchSlop { 320 Box( 321 Modifier.fillMaxSize() 322 .nestedDraggable(draggable, orientation) 323 .then( 324 if (composeNestedScrollable) { 325 Modifier.nestedScrollable(rememberScrollState()) 326 } else { 327 Modifier 328 } 329 ) 330 ) 331 } 332 333 assertThat(draggable.onDragStartedCalled).isFalse() 334 335 rule.onRoot().performTouchInput { 336 down(center) 337 moveBy((touchSlop + 1f).toOffset()) 338 } 339 340 assertThat(draggable.onDragStartedCalled).isTrue() 341 assertThat(draggable.onDragStoppedCalled).isFalse() 342 343 composeNestedScrollable = false 344 rule.waitForIdle() 345 assertThat(draggable.onDragStoppedCalled).isTrue() 346 } 347 348 @Test 349 fun enabled() { 350 val draggable = TestDraggable() 351 var enabled by mutableStateOf(false) 352 val touchSlop = 353 rule.setContentWithTouchSlop { 354 Box( 355 Modifier.fillMaxSize() 356 .nestedDraggable(draggable, orientation, enabled = enabled) 357 ) 358 } 359 360 assertThat(draggable.onDragStartedCalled).isFalse() 361 362 rule.onRoot().performTouchInput { 363 down(center) 364 moveBy(touchSlop.toOffset()) 365 } 366 367 assertThat(draggable.onDragStartedCalled).isFalse() 368 assertThat(draggable.onDragStoppedCalled).isFalse() 369 370 enabled = true 371 rule.onRoot().performTouchInput { 372 // Release previously up finger. 373 up() 374 375 down(center) 376 moveBy(touchSlop.toOffset()) 377 } 378 379 assertThat(draggable.onDragStartedCalled).isTrue() 380 assertThat(draggable.onDragStoppedCalled).isFalse() 381 382 enabled = false 383 rule.waitForIdle() 384 assertThat(draggable.onDragStoppedCalled).isTrue() 385 } 386 387 @Test 388 fun pointersDown() { 389 val draggable = TestDraggable() 390 val touchSlop = 391 rule.setContentWithTouchSlop { 392 Box(Modifier.fillMaxSize().nestedDraggable(draggable, orientation)) 393 } 394 395 (1..5).forEach { nDown -> 396 rule.onRoot().performTouchInput { 397 repeat(nDown) { pointerId -> down(pointerId, center) } 398 399 moveBy(pointerId = 0, touchSlop.toOffset()) 400 } 401 402 assertThat(draggable.onDragStartedPointersDown).isEqualTo(nDown) 403 404 rule.onRoot().performTouchInput { 405 repeat(nDown) { pointerId -> up(pointerId = pointerId) } 406 } 407 } 408 } 409 410 @Test 411 fun pointersDown_nestedScroll() { 412 val draggable = TestDraggable() 413 val touchSlop = 414 rule.setContentWithTouchSlop { 415 Box( 416 Modifier.fillMaxSize() 417 .nestedDraggable(draggable, orientation) 418 .nestedScrollable(rememberScrollState()) 419 ) 420 } 421 422 (1..5).forEach { nDown -> 423 rule.onRoot().performTouchInput { 424 repeat(nDown) { pointerId -> down(pointerId, center) } 425 426 moveBy(pointerId = 0, (touchSlop + 1f).toOffset()) 427 } 428 429 assertThat(draggable.onDragStartedPointersDown).isEqualTo(nDown) 430 431 rule.onRoot().performTouchInput { 432 repeat(nDown) { pointerId -> up(pointerId = pointerId) } 433 } 434 } 435 } 436 437 @Test 438 fun pointersDown_downThenUpThenDown() { 439 val draggable = TestDraggable() 440 val touchSlop = 441 rule.setContentWithTouchSlop { 442 Box(Modifier.fillMaxSize().nestedDraggable(draggable, orientation)) 443 } 444 445 val slopThird = ceil(touchSlop / 3f).toOffset() 446 rule.onRoot().performTouchInput { 447 repeat(5) { down(pointerId = it, center) } // + 5 448 moveBy(pointerId = 0, slopThird) 449 450 listOf(2, 3).forEach { up(pointerId = it) } // - 2 451 moveBy(pointerId = 0, slopThird) 452 453 listOf(5, 6, 7).forEach { down(pointerId = it, center) } // + 3 454 moveBy(pointerId = 0, slopThird) 455 } 456 457 assertThat(draggable.onDragStartedPointersDown).isEqualTo(6) 458 } 459 460 private fun ComposeContentTestRule.setContentWithTouchSlop( 461 content: @Composable () -> Unit 462 ): Float { 463 var touchSlop = 0f 464 setContent { 465 touchSlop = LocalViewConfiguration.current.touchSlop 466 content() 467 } 468 return touchSlop 469 } 470 471 private fun Modifier.nestedScrollable(scrollState: ScrollState): Modifier { 472 return when (orientation) { 473 Orientation.Vertical -> verticalScroll(scrollState) 474 Orientation.Horizontal -> horizontalScroll(scrollState) 475 } 476 } 477 478 private class TestDraggable( 479 private val onDragStarted: (Offset, Float) -> Unit = { _, _ -> }, 480 private val onDrag: (Float) -> Float = { it }, 481 private val onDragStopped: suspend (Float) -> Float = { it }, 482 private val shouldConsumeNestedScroll: (Float) -> Boolean = { true }, 483 ) : NestedDraggable { 484 var onDragStartedCalled = false 485 var onDragCalled = false 486 var onDragStoppedCalled = false 487 488 var onDragStartedPosition = Offset.Zero 489 var onDragStartedSign = 0f 490 var onDragStartedPointersDown = 0 491 var onDragDelta = 0f 492 493 override fun onDragStarted( 494 position: Offset, 495 sign: Float, 496 pointersDown: Int, 497 ): NestedDraggable.Controller { 498 onDragStartedCalled = true 499 onDragStartedPosition = position 500 onDragStartedSign = sign 501 onDragStartedPointersDown = pointersDown 502 onDragDelta = 0f 503 504 onDragStarted.invoke(position, sign) 505 return object : NestedDraggable.Controller { 506 override fun onDrag(delta: Float): Float { 507 onDragCalled = true 508 onDragDelta += delta 509 return onDrag.invoke(delta) 510 } 511 512 override suspend fun onDragStopped(velocity: Float): Float { 513 onDragStoppedCalled = true 514 return onDragStopped.invoke(velocity) 515 } 516 } 517 } 518 519 override fun shouldConsumeNestedScroll(sign: Float): Boolean { 520 return shouldConsumeNestedScroll.invoke(sign) 521 } 522 } 523 } 524