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.animation.scene 18 19 import androidx.compose.foundation.gestures.Orientation 20 import androidx.compose.foundation.gestures.rememberScrollableState 21 import androidx.compose.foundation.gestures.scrollable 22 import androidx.compose.foundation.layout.Box 23 import androidx.compose.foundation.layout.fillMaxSize 24 import androidx.compose.foundation.layout.size 25 import androidx.compose.runtime.getValue 26 import androidx.compose.runtime.mutableStateOf 27 import androidx.compose.runtime.setValue 28 import androidx.compose.ui.Modifier 29 import androidx.compose.ui.geometry.Offset 30 import androidx.compose.ui.geometry.Size 31 import androidx.compose.ui.input.pointer.AwaitPointerEventScope 32 import androidx.compose.ui.input.pointer.PointerEventPass 33 import androidx.compose.ui.input.pointer.PointerInputChange 34 import androidx.compose.ui.input.pointer.pointerInput 35 import androidx.compose.ui.platform.LocalDensity 36 import androidx.compose.ui.platform.LocalViewConfiguration 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.test.ext.junit.runners.AndroidJUnit4 41 import com.google.common.truth.Truth.assertThat 42 import kotlinx.coroutines.coroutineScope 43 import kotlinx.coroutines.isActive 44 import org.junit.Rule 45 import org.junit.Test 46 import org.junit.runner.RunWith 47 48 @RunWith(AndroidJUnit4::class) 49 class MultiPointerDraggableTest { 50 @get:Rule val rule = createComposeRule() 51 52 @Test 53 fun cancellingPointerCallsOnDragStopped() { 54 val size = 200f 55 val middle = Offset(size / 2f, size / 2f) 56 57 var enabled by mutableStateOf(false) 58 var started = false 59 var dragged = false 60 var stopped = false 61 62 var touchSlop = 0f 63 rule.setContent { 64 touchSlop = LocalViewConfiguration.current.touchSlop 65 Box( 66 Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) 67 .multiPointerDraggable( 68 orientation = Orientation.Vertical, 69 enabled = { enabled }, 70 startDragImmediately = { false }, 71 onDragStarted = { _, _, _ -> 72 started = true 73 object : DragController { 74 override fun onDrag(delta: Float) { 75 dragged = true 76 } 77 78 override fun onStop(velocity: Float, canChangeScene: Boolean) { 79 stopped = true 80 } 81 } 82 }, 83 ) 84 ) 85 } 86 87 fun startDraggingDown() { 88 rule.onRoot().performTouchInput { 89 down(middle) 90 moveBy(Offset(0f, touchSlop)) 91 } 92 } 93 94 fun releaseFinger() { 95 rule.onRoot().performTouchInput { up() } 96 } 97 98 // Swiping down does nothing because enabled is false. 99 startDraggingDown() 100 assertThat(started).isFalse() 101 assertThat(dragged).isFalse() 102 assertThat(stopped).isFalse() 103 releaseFinger() 104 105 // Enable the draggable and swipe down. This should both call onDragStarted() and 106 // onDragDelta(). 107 enabled = true 108 rule.waitForIdle() 109 startDraggingDown() 110 assertThat(started).isTrue() 111 assertThat(dragged).isTrue() 112 assertThat(stopped).isFalse() 113 114 // Disable the pointer input. This should call onDragStopped() even if didn't release the 115 // finger yet. 116 enabled = false 117 rule.waitForIdle() 118 assertThat(started).isTrue() 119 assertThat(dragged).isTrue() 120 assertThat(stopped).isTrue() 121 } 122 123 @Test 124 fun handleDisappearingScrollableDuringAGesture() { 125 val size = 200f 126 val middle = Offset(size / 2f, size / 2f) 127 128 var started = false 129 var dragged = false 130 var stopped = false 131 var consumedByScroll = false 132 var hasScrollable by mutableStateOf(true) 133 134 var touchSlop = 0f 135 rule.setContent { 136 touchSlop = LocalViewConfiguration.current.touchSlop 137 Box( 138 Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) 139 .multiPointerDraggable( 140 orientation = Orientation.Vertical, 141 enabled = { true }, 142 startDragImmediately = { false }, 143 onDragStarted = { _, _, _ -> 144 started = true 145 object : DragController { 146 override fun onDrag(delta: Float) { 147 dragged = true 148 } 149 150 override fun onStop(velocity: Float, canChangeScene: Boolean) { 151 stopped = true 152 } 153 } 154 }, 155 ) 156 ) { 157 if (hasScrollable) { 158 Box( 159 Modifier.scrollable( 160 // Consume all the vertical scroll gestures 161 rememberScrollableState( 162 consumeScrollDelta = { 163 consumedByScroll = true 164 it 165 } 166 ), 167 Orientation.Vertical 168 ) 169 .fillMaxSize() 170 ) 171 } 172 } 173 } 174 175 fun startDraggingDown() { 176 rule.onRoot().performTouchInput { 177 down(middle) 178 moveBy(Offset(0f, touchSlop)) 179 } 180 } 181 182 fun continueDraggingDown() { 183 rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) } 184 } 185 186 fun releaseFinger() { 187 rule.onRoot().performTouchInput { up() } 188 } 189 190 // Swipe down. This should intercepted by the scrollable modifier. 191 startDraggingDown() 192 assertThat(consumedByScroll).isTrue() 193 assertThat(started).isFalse() 194 assertThat(dragged).isFalse() 195 assertThat(stopped).isFalse() 196 197 // Reset the scroll state for the test 198 consumedByScroll = false 199 200 // Suddenly remove the scrollable container 201 hasScrollable = false 202 rule.waitForIdle() 203 204 // Swipe down. This will be intercepted by multiPointerDraggable, it will wait touchSlop 205 // before consuming it. 206 continueDraggingDown() 207 assertThat(consumedByScroll).isFalse() 208 assertThat(started).isFalse() 209 assertThat(dragged).isFalse() 210 assertThat(stopped).isFalse() 211 212 // Swipe down. This should both call onDragStarted() and onDragDelta(). 213 continueDraggingDown() 214 assertThat(consumedByScroll).isFalse() 215 assertThat(started).isTrue() 216 assertThat(dragged).isTrue() 217 assertThat(stopped).isFalse() 218 219 rule.waitForIdle() 220 releaseFinger() 221 assertThat(stopped).isTrue() 222 } 223 224 @Test 225 fun multiPointerWaitAConsumableEventInMainPass() { 226 val size = 200f 227 val middle = Offset(size / 2f, size / 2f) 228 229 var started = false 230 var dragged = false 231 var stopped = false 232 233 var childConsumesOnPass: PointerEventPass? = null 234 235 suspend fun AwaitPointerEventScope.childPointerInputScope() { 236 awaitPointerEvent(PointerEventPass.Initial).also { initial -> 237 // Check unconsumed: it should be always true 238 assertThat(initial.changes.any { it.isConsumed }).isFalse() 239 240 if (childConsumesOnPass == PointerEventPass.Initial) { 241 initial.changes.first().consume() 242 } 243 } 244 245 awaitPointerEvent(PointerEventPass.Main).also { main -> 246 // Check unconsumed 247 if (childConsumesOnPass != PointerEventPass.Initial) { 248 assertThat(main.changes.any { it.isConsumed }).isFalse() 249 } 250 251 if (childConsumesOnPass == PointerEventPass.Main) { 252 main.changes.first().consume() 253 } 254 } 255 } 256 257 var touchSlop = 0f 258 rule.setContent { 259 touchSlop = LocalViewConfiguration.current.touchSlop 260 Box( 261 Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) 262 .multiPointerDraggable( 263 orientation = Orientation.Vertical, 264 enabled = { true }, 265 startDragImmediately = { false }, 266 onDragStarted = { _, _, _ -> 267 started = true 268 object : DragController { 269 override fun onDrag(delta: Float) { 270 dragged = true 271 } 272 273 override fun onStop(velocity: Float, canChangeScene: Boolean) { 274 stopped = true 275 } 276 } 277 }, 278 ) 279 ) { 280 Box( 281 Modifier.pointerInput(Unit) { 282 coroutineScope { 283 awaitPointerEventScope { 284 while (isActive) { 285 childPointerInputScope() 286 } 287 } 288 } 289 } 290 .fillMaxSize() 291 ) 292 } 293 } 294 295 fun startDraggingDown() { 296 rule.onRoot().performTouchInput { 297 down(middle) 298 moveBy(Offset(0f, touchSlop)) 299 } 300 } 301 302 fun continueDraggingDown() { 303 rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) } 304 } 305 306 childConsumesOnPass = PointerEventPass.Initial 307 308 startDraggingDown() 309 assertThat(started).isFalse() 310 assertThat(dragged).isFalse() 311 assertThat(stopped).isFalse() 312 313 continueDraggingDown() 314 assertThat(started).isFalse() 315 assertThat(dragged).isFalse() 316 assertThat(stopped).isFalse() 317 318 childConsumesOnPass = PointerEventPass.Main 319 320 continueDraggingDown() 321 assertThat(started).isFalse() 322 assertThat(dragged).isFalse() 323 assertThat(stopped).isFalse() 324 325 continueDraggingDown() 326 assertThat(started).isFalse() 327 assertThat(dragged).isFalse() 328 assertThat(stopped).isFalse() 329 330 childConsumesOnPass = null 331 332 // Swipe down. This will be intercepted by multiPointerDraggable, it will wait touchSlop 333 // before consuming it. 334 continueDraggingDown() 335 assertThat(started).isFalse() 336 assertThat(dragged).isFalse() 337 assertThat(stopped).isFalse() 338 339 // Swipe down. This should both call onDragStarted() and onDragDelta(). 340 continueDraggingDown() 341 assertThat(started).isTrue() 342 assertThat(dragged).isTrue() 343 assertThat(stopped).isFalse() 344 345 childConsumesOnPass = PointerEventPass.Main 346 347 continueDraggingDown() 348 assertThat(stopped).isTrue() 349 } 350 351 @Test 352 fun multiPointerDuringAnotherGestureWaitAConsumableEventAfterMainPass() { 353 val size = 200f 354 val middle = Offset(size / 2f, size / 2f) 355 356 var verticalStarted = false 357 var verticalDragged = false 358 var verticalStopped = false 359 var horizontalStarted = false 360 var horizontalDragged = false 361 var horizontalStopped = false 362 363 var touchSlop = 0f 364 rule.setContent { 365 touchSlop = LocalViewConfiguration.current.touchSlop 366 Box( 367 Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) 368 .multiPointerDraggable( 369 orientation = Orientation.Vertical, 370 enabled = { true }, 371 startDragImmediately = { false }, 372 onDragStarted = { _, _, _ -> 373 verticalStarted = true 374 object : DragController { 375 override fun onDrag(delta: Float) { 376 verticalDragged = true 377 } 378 379 override fun onStop(velocity: Float, canChangeScene: Boolean) { 380 verticalStopped = true 381 } 382 } 383 }, 384 ) 385 .multiPointerDraggable( 386 orientation = Orientation.Horizontal, 387 enabled = { true }, 388 startDragImmediately = { false }, 389 onDragStarted = { _, _, _ -> 390 horizontalStarted = true 391 object : DragController { 392 override fun onDrag(delta: Float) { 393 horizontalDragged = true 394 } 395 396 override fun onStop(velocity: Float, canChangeScene: Boolean) { 397 horizontalStopped = true 398 } 399 } 400 }, 401 ) 402 ) 403 } 404 405 fun startDraggingDown() { 406 rule.onRoot().performTouchInput { 407 down(middle) 408 moveBy(Offset(0f, touchSlop)) 409 } 410 } 411 412 fun startDraggingRight() { 413 rule.onRoot().performTouchInput { 414 down(middle) 415 moveBy(Offset(touchSlop, 0f)) 416 } 417 } 418 419 fun stopDragging() { 420 rule.onRoot().performTouchInput { up() } 421 } 422 423 fun continueDown() { 424 rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) } 425 } 426 427 fun continueRight() { 428 rule.onRoot().performTouchInput { moveBy(Offset(touchSlop, 0f)) } 429 } 430 431 startDraggingDown() 432 assertThat(verticalStarted).isTrue() 433 assertThat(verticalDragged).isTrue() 434 assertThat(verticalStopped).isFalse() 435 436 // Ignore right swipe, do not interrupt the dragging gesture. 437 continueRight() 438 assertThat(horizontalStarted).isFalse() 439 assertThat(horizontalDragged).isFalse() 440 assertThat(horizontalStopped).isFalse() 441 assertThat(verticalStopped).isFalse() 442 443 stopDragging() 444 assertThat(verticalStopped).isTrue() 445 446 verticalStarted = false 447 verticalDragged = false 448 verticalStopped = false 449 450 startDraggingRight() 451 assertThat(horizontalStarted).isTrue() 452 assertThat(horizontalDragged).isTrue() 453 assertThat(horizontalStopped).isFalse() 454 455 // Ignore down swipe, do not interrupt the dragging gesture. 456 continueDown() 457 assertThat(verticalStarted).isFalse() 458 assertThat(verticalDragged).isFalse() 459 assertThat(verticalStopped).isFalse() 460 assertThat(horizontalStopped).isFalse() 461 462 stopDragging() 463 assertThat(horizontalStopped).isTrue() 464 } 465 466 @Test 467 fun multiPointerSwipeDetectorInteraction() { 468 val size = 200f 469 val middle = Offset(size / 2f, size / 2f) 470 471 var started = false 472 473 var capturedChange: PointerInputChange? = null 474 var swipeConsume = false 475 476 var touchSlop = 0f 477 rule.setContent { 478 touchSlop = LocalViewConfiguration.current.touchSlop 479 Box( 480 Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) 481 .multiPointerDraggable( 482 orientation = Orientation.Vertical, 483 enabled = { true }, 484 startDragImmediately = { false }, 485 swipeDetector = 486 object : SwipeDetector { 487 override fun detectSwipe(change: PointerInputChange): Boolean { 488 capturedChange = change 489 return swipeConsume 490 } 491 }, 492 onDragStarted = { _, _, _ -> 493 started = true 494 object : DragController { 495 override fun onDrag(delta: Float) {} 496 497 override fun onStop(velocity: Float, canChangeScene: Boolean) {} 498 } 499 }, 500 ) 501 ) {} 502 } 503 504 fun startDraggingDown() { 505 rule.onRoot().performTouchInput { 506 down(middle) 507 moveBy(Offset(0f, touchSlop)) 508 } 509 } 510 511 fun continueDraggingDown() { 512 rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) } 513 } 514 515 startDraggingDown() 516 assertThat(capturedChange).isNotNull() 517 capturedChange = null 518 assertThat(started).isFalse() 519 520 swipeConsume = true 521 continueDraggingDown() 522 assertThat(capturedChange).isNotNull() 523 capturedChange = null 524 525 continueDraggingDown() 526 assertThat(capturedChange).isNull() 527 528 assertThat(started).isTrue() 529 } 530 } 531