1 /* <lambda>null2 * Copyright (C) 2023 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.annotation.VisibleForTesting 20 import androidx.compose.foundation.gestures.Orientation 21 import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation 22 import androidx.compose.foundation.gestures.awaitVerticalTouchSlopOrCancellation 23 import androidx.compose.runtime.Stable 24 import androidx.compose.ui.Modifier 25 import androidx.compose.ui.geometry.Offset 26 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection 27 import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher 28 import androidx.compose.ui.input.nestedscroll.NestedScrollSource 29 import androidx.compose.ui.input.pointer.AwaitPointerEventScope 30 import androidx.compose.ui.input.pointer.PointerEvent 31 import androidx.compose.ui.input.pointer.PointerEventPass 32 import androidx.compose.ui.input.pointer.PointerEventType 33 import androidx.compose.ui.input.pointer.PointerId 34 import androidx.compose.ui.input.pointer.PointerInputChange 35 import androidx.compose.ui.input.pointer.PointerInputScope 36 import androidx.compose.ui.input.pointer.PointerType 37 import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode 38 import androidx.compose.ui.input.pointer.changedToDown 39 import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed 40 import androidx.compose.ui.input.pointer.positionChange 41 import androidx.compose.ui.input.pointer.positionChangeIgnoreConsumed 42 import androidx.compose.ui.input.pointer.util.VelocityTracker 43 import androidx.compose.ui.input.pointer.util.addPointerInputChange 44 import androidx.compose.ui.node.CompositionLocalConsumerModifierNode 45 import androidx.compose.ui.node.DelegatingNode 46 import androidx.compose.ui.node.ModifierNodeElement 47 import androidx.compose.ui.node.PointerInputModifierNode 48 import androidx.compose.ui.node.currentValueOf 49 import androidx.compose.ui.platform.LocalViewConfiguration 50 import androidx.compose.ui.unit.IntSize 51 import androidx.compose.ui.unit.Velocity 52 import androidx.compose.ui.util.fastAll 53 import androidx.compose.ui.util.fastAny 54 import androidx.compose.ui.util.fastFilter 55 import androidx.compose.ui.util.fastFirstOrNull 56 import androidx.compose.ui.util.fastForEach 57 import androidx.compose.ui.util.fastSumBy 58 import com.android.compose.ui.util.SpaceVectorConverter 59 import kotlin.coroutines.cancellation.CancellationException 60 import kotlin.math.sign 61 import kotlinx.coroutines.currentCoroutineContext 62 import kotlinx.coroutines.isActive 63 import kotlinx.coroutines.launch 64 65 /** 66 * Make an element draggable in the given [orientation]. 67 * 68 * The main difference with [multiPointerDraggable] and 69 * [androidx.compose.foundation.gestures.draggable] is that [onDragStarted] also receives the number 70 * of pointers that are down when the drag is started. If you don't need this information, you 71 * should use `draggable` instead. 72 * 73 * Note that the current implementation is trivial: we wait for the touch slope on the *first* down 74 * pointer, then we count the number of distinct pointers that are down right before calling 75 * [onDragStarted]. This means that the drag won't start when a first pointer is down (but not 76 * dragged) and a second pointer is down and dragged. This is an implementation detail that might 77 * change in the future. 78 */ 79 @VisibleForTesting 80 @Stable 81 internal fun Modifier.multiPointerDraggable( 82 orientation: Orientation, 83 onDragStarted: (pointersDown: PointersInfo.PointersDown, overSlop: Float) -> DragController, 84 onFirstPointerDown: () -> Unit = {}, 85 swipeDetector: SwipeDetector = DefaultSwipeDetector, 86 dispatcher: NestedScrollDispatcher, 87 ): Modifier = 88 this.then( 89 MultiPointerDraggableElement( 90 orientation, 91 onDragStarted, 92 onFirstPointerDown, 93 swipeDetector, 94 dispatcher, 95 ) 96 ) 97 98 private data class MultiPointerDraggableElement( 99 private val orientation: Orientation, 100 private val onDragStarted: 101 (pointersDown: PointersInfo.PointersDown, overSlop: Float) -> DragController, 102 private val onFirstPointerDown: () -> Unit, 103 private val swipeDetector: SwipeDetector, 104 private val dispatcher: NestedScrollDispatcher, 105 ) : ModifierNodeElement<MultiPointerDraggableNode>() { createnull106 override fun create(): MultiPointerDraggableNode = 107 MultiPointerDraggableNode( 108 orientation = orientation, 109 onDragStarted = onDragStarted, 110 onFirstPointerDown = onFirstPointerDown, 111 swipeDetector = swipeDetector, 112 dispatcher = dispatcher, 113 ) 114 115 override fun update(node: MultiPointerDraggableNode) { 116 node.orientation = orientation 117 node.onDragStarted = onDragStarted 118 node.onFirstPointerDown = onFirstPointerDown 119 node.swipeDetector = swipeDetector 120 } 121 } 122 123 internal class MultiPointerDraggableNode( 124 orientation: Orientation, 125 var onDragStarted: (pointersDown: PointersInfo.PointersDown, overSlop: Float) -> DragController, 126 var onFirstPointerDown: () -> Unit, 127 swipeDetector: SwipeDetector = DefaultSwipeDetector, 128 private val dispatcher: NestedScrollDispatcher, 129 ) : DelegatingNode(), PointerInputModifierNode, CompositionLocalConsumerModifierNode { <lambda>null130 private val pointerTracker = delegate(SuspendingPointerInputModifierNode { pointerTracker() }) <lambda>null131 private val pointerInput = delegate(SuspendingPointerInputModifierNode { pointerInput() }) 132 private val velocityTracker = VelocityTracker() 133 134 var swipeDetector: SwipeDetector = swipeDetector 135 set(value) { 136 if (value != field) { 137 field = value 138 pointerInput.resetPointerInputHandler() 139 } 140 } 141 142 private var converter = SpaceVectorConverter(orientation) 143 <lambda>null144 fun Offset.toFloat(): Float = with(converter) { this@toFloat.toFloat() } 145 <lambda>null146 fun Velocity.toFloat(): Float = with(converter) { this@toFloat.toFloat() } 147 <lambda>null148 fun Float.toOffset(): Offset = with(converter) { this@toOffset.toOffset() } 149 <lambda>null150 fun Float.toVelocity(): Velocity = with(converter) { this@toVelocity.toVelocity() } 151 152 var orientation: Orientation = orientation 153 set(value) { 154 // Reset the pointer input whenever orientation changed. 155 if (value != field) { 156 field = value 157 converter = SpaceVectorConverter(value) 158 pointerInput.resetPointerInputHandler() 159 } 160 } 161 onCancelPointerInputnull162 override fun onCancelPointerInput() { 163 pointerTracker.onCancelPointerInput() 164 pointerInput.onCancelPointerInput() 165 } 166 onPointerEventnull167 override fun onPointerEvent( 168 pointerEvent: PointerEvent, 169 pass: PointerEventPass, 170 bounds: IntSize, 171 ) { 172 // The order is important here: the tracker is always called first. 173 pointerTracker.onPointerEvent(pointerEvent, pass, bounds) 174 pointerInput.onPointerEvent(pointerEvent, pass, bounds) 175 } 176 177 private var lastPointerEvent: PointerEvent? = null 178 private var startedPosition: Offset? = null 179 private var countPointersDown: Int = 0 180 pointersInfonull181 internal fun pointersInfo(): PointersInfo? { 182 // This may be null, i.e. when the user uses TalkBack 183 val lastPointerEvent = lastPointerEvent ?: return null 184 185 if (lastPointerEvent.type == PointerEventType.Scroll) return PointersInfo.MouseWheel 186 187 val startedPosition = startedPosition ?: return null 188 189 return PointersInfo.PointersDown( 190 startedPosition = startedPosition, 191 count = countPointersDown, 192 countByType = 193 buildMap { 194 lastPointerEvent.changes.fastForEach { change -> 195 if (!change.pressed) return@fastForEach 196 val newValue = (get(change.type) ?: 0) + 1 197 put(change.type, newValue) 198 } 199 }, 200 ) 201 } 202 pointerTrackernull203 private suspend fun PointerInputScope.pointerTracker() { 204 val currentContext = currentCoroutineContext() 205 awaitPointerEventScope { 206 var velocityPointerId: PointerId? = null 207 // Intercepts pointer inputs and exposes [PointersInfo], via 208 // [requireAncestorPointersInfoOwner], to our descendants. 209 while (currentContext.isActive) { 210 // During the Initial pass, we receive the event after our ancestors. 211 val pointerEvent = awaitPointerEvent(PointerEventPass.Initial) 212 213 // Ignore cursor has entered the input region. 214 // This will only be sent after the cursor is hovering when in the input region. 215 if (pointerEvent.type == PointerEventType.Enter) continue 216 217 val changes = pointerEvent.changes 218 lastPointerEvent = pointerEvent 219 countPointersDown = changes.countDown() 220 221 when { 222 // There are no more pointers down. 223 countPointersDown == 0 -> { 224 startedPosition = null 225 226 // In case of multiple events with 0 pointers down (not pressed) we may have 227 // already removed the velocityPointer 228 val lastPointerUp = changes.fastFilter { it.id == velocityPointerId } 229 check(lastPointerUp.isEmpty() || lastPointerUp.size == 1) { 230 "There are ${lastPointerUp.size} pointers up: $lastPointerUp" 231 } 232 if (lastPointerUp.size == 1) { 233 velocityTracker.addPointerInputChange(lastPointerUp.first()) 234 } 235 } 236 237 // The first pointer down, startedPosition was not set. 238 startedPosition == null -> { 239 // Mouse wheel could start with multiple pointer down 240 val firstPointerDown = changes.first() 241 velocityPointerId = firstPointerDown.id 242 velocityTracker.resetTracking() 243 velocityTracker.addPointerInputChange(firstPointerDown) 244 startedPosition = firstPointerDown.position 245 onFirstPointerDown() 246 } 247 248 // Changes with at least one pointer 249 else -> { 250 val pointerChange = changes.first() 251 252 // Assuming that the list of changes doesn't have two changes with the same 253 // id (PointerId), we can check: 254 // - If the first change has `id` equals to `velocityPointerId` (this should 255 // always be true unless the pointer has been removed). 256 // - If it does, we've found our change event (assuming there aren't any 257 // others changes with the same id in this PointerEvent - not checked). 258 // - If it doesn't, we can check that the change with that id isn't in first 259 // place (which should never happen - this will crash). 260 check( 261 pointerChange.id == velocityPointerId || 262 !changes.fastAny { it.id == velocityPointerId } 263 ) { 264 "$velocityPointerId is present, but not the first: $changes" 265 } 266 267 // If the previous pointer has been removed, we use the first available 268 // change to keep tracking the velocity. 269 velocityPointerId = 270 if (pointerChange.pressed) { 271 pointerChange.id 272 } else { 273 changes.first { it.pressed }.id 274 } 275 276 velocityTracker.addPointerInputChange(pointerChange) 277 } 278 } 279 } 280 } 281 } 282 pointerInputnull283 private suspend fun PointerInputScope.pointerInput() { 284 val currentContext = currentCoroutineContext() 285 awaitPointerEventScope { 286 while (currentContext.isActive) { 287 try { 288 detectDragGestures( 289 orientation = orientation, 290 onDragStart = { pointersDown, overSlop -> 291 onDragStarted(pointersDown, overSlop) 292 }, 293 onDrag = { controller, amount -> 294 dispatchScrollEvents( 295 availableOnPreScroll = amount, 296 onScroll = { controller.onDrag(it) }, 297 source = NestedScrollSource.UserInput, 298 ) 299 }, 300 onDragEnd = { controller -> 301 startFlingGesture( 302 initialVelocity = 303 currentValueOf(LocalViewConfiguration) 304 .maximumFlingVelocity 305 .let { 306 val maxVelocity = Velocity(it, it) 307 velocityTracker.calculateVelocity(maxVelocity) 308 } 309 .toFloat(), 310 onFling = { controller.onStop(it, canChangeContent = true) }, 311 ) 312 }, 313 onDragCancel = { controller -> 314 startFlingGesture( 315 initialVelocity = 0f, 316 onFling = { controller.onStop(it, canChangeContent = true) }, 317 ) 318 }, 319 swipeDetector = swipeDetector, 320 ) 321 } catch (exception: CancellationException) { 322 // If the coroutine scope is active, we can just restart the drag cycle. 323 if (!currentContext.isActive) { 324 throw exception 325 } 326 } 327 } 328 } 329 } 330 331 /** 332 * Start a fling gesture in another CoroutineScope, this is to ensure that even when the pointer 333 * input scope is reset we will continue any coroutine scope that we started from these methods 334 * while the pointer input scope was active. 335 * 336 * Note: Inspired by [androidx.compose.foundation.gestures.ScrollableNode.onDragStopped] 337 */ startFlingGesturenull338 private fun startFlingGesture( 339 initialVelocity: Float, 340 onFling: suspend (velocity: Float) -> Float, 341 ) { 342 // Note: [AwaitPointerEventScope] is annotated as @RestrictsSuspension, we need another 343 // CoroutineScope to run the fling gestures. 344 // We do not need to cancel this [Job], the source will take care of emitting an 345 // [onPostFling] before starting a new gesture. 346 dispatcher.coroutineScope.launch { 347 dispatchFlingEvents(availableOnPreFling = initialVelocity, onFling = onFling) 348 } 349 } 350 351 /** 352 * Use the nested scroll system to fire scroll events. This allows us to consume events from our 353 * ancestors during the pre-scroll and post-scroll phases. 354 * 355 * @param availableOnPreScroll amount available before the scroll, this can be partially 356 * consumed by our ancestors. 357 * @param onScroll function that returns the amount consumed during a scroll given the amount 358 * available after the [NestedScrollConnection.onPreScroll]. 359 * @param source the source of the scroll event 360 * @return Total offset consumed. 361 */ dispatchScrollEventsnull362 private inline fun dispatchScrollEvents( 363 availableOnPreScroll: Float, 364 onScroll: (delta: Float) -> Float, 365 source: NestedScrollSource, 366 ): Float { 367 // PreScroll phase 368 val consumedByPreScroll = 369 dispatcher 370 .dispatchPreScroll(available = availableOnPreScroll.toOffset(), source = source) 371 .toFloat() 372 373 // Scroll phase 374 val availableOnScroll = availableOnPreScroll - consumedByPreScroll 375 val consumedBySelfScroll = onScroll(availableOnScroll) 376 377 // PostScroll phase 378 val availableOnPostScroll = availableOnScroll - consumedBySelfScroll 379 val consumedByPostScroll = 380 dispatcher 381 .dispatchPostScroll( 382 consumed = consumedBySelfScroll.toOffset(), 383 available = availableOnPostScroll.toOffset(), 384 source = source, 385 ) 386 .toFloat() 387 388 return consumedByPreScroll + consumedBySelfScroll + consumedByPostScroll 389 } 390 391 /** 392 * Use the nested scroll system to fire fling events. This allows us to consume events from our 393 * ancestors during the pre-fling and post-fling phases. 394 * 395 * @param availableOnPreFling velocity available before the fling, this can be partially 396 * consumed by our ancestors. 397 * @param onFling function that returns the velocity consumed during the fling given the 398 * velocity available after the [NestedScrollConnection.onPreFling]. 399 * @return Total velocity consumed. 400 */ dispatchFlingEventsnull401 private suspend inline fun dispatchFlingEvents( 402 availableOnPreFling: Float, 403 onFling: suspend (velocity: Float) -> Float, 404 ): Float { 405 // PreFling phase 406 val consumedByPreFling = 407 dispatcher.dispatchPreFling(available = availableOnPreFling.toVelocity()).toFloat() 408 409 // Fling phase 410 val availableOnFling = availableOnPreFling - consumedByPreFling 411 val consumedBySelfFling = onFling(availableOnFling) 412 413 // PostFling phase 414 val availableOnPostFling = availableOnFling - consumedBySelfFling 415 val consumedByPostFling = 416 dispatcher 417 .dispatchPostFling( 418 consumed = consumedBySelfFling.toVelocity(), 419 available = availableOnPostFling.toVelocity(), 420 ) 421 .toFloat() 422 423 return consumedByPreFling + consumedBySelfFling + consumedByPostFling 424 } 425 426 /** 427 * Detect drag gestures in the given [orientation]. 428 * 429 * This function is a mix of [androidx.compose.foundation.gestures.awaitDownAndSlop] and 430 * [androidx.compose.foundation.gestures.detectVerticalDragGestures] to add support for passing 431 * the number of pointers down to [onDragStart]. 432 */ detectDragGesturesnull433 private suspend fun AwaitPointerEventScope.detectDragGestures( 434 orientation: Orientation, 435 onDragStart: (pointersDown: PointersInfo.PointersDown, overSlop: Float) -> DragController, 436 onDrag: (controller: DragController, dragAmount: Float) -> Unit, 437 onDragEnd: (controller: DragController) -> Unit, 438 onDragCancel: (controller: DragController) -> Unit, 439 swipeDetector: SwipeDetector, 440 ) { 441 val consumablePointer = 442 awaitConsumableEvent { 443 // We are searching for an event that can be used as the starting point for the 444 // drag gesture. Our options are: 445 // - Initial: These events should never be consumed by the MultiPointerDraggable 446 // since our ancestors can consume the gesture, but we would eliminate this 447 // possibility for our descendants. 448 // - Main: These events are consumed during the drag gesture, and they are a 449 // good place to start if the previous event has not been consumed. 450 // - Final: If the previous event has been consumed, we can wait for the Main 451 // pass to finish. If none of our ancestors were interested in the event, we 452 // can wait for an unconsumed event in the Final pass. 453 val previousConsumed = currentEvent.changes.fastAny { it.isConsumed } 454 if (previousConsumed) PointerEventPass.Final else PointerEventPass.Main 455 } 456 .changes 457 .first() 458 459 var overSlop = 0f 460 val onSlopReached = { change: PointerInputChange, over: Float -> 461 if (swipeDetector.detectSwipe(change)) { 462 change.consume() 463 overSlop = over 464 } 465 } 466 467 // TODO(b/291055080): Replace by await[Orientation]PointerSlopOrCancellation once it 468 // is public. 469 val drag = 470 when (orientation) { 471 Orientation.Horizontal -> 472 awaitHorizontalTouchSlopOrCancellation(consumablePointer.id, onSlopReached) 473 Orientation.Vertical -> 474 awaitVerticalTouchSlopOrCancellation(consumablePointer.id, onSlopReached) 475 } ?: return 476 477 val lastPointersDown = 478 checkNotNull(pointersInfo()) { 479 "We should have pointers down, last event: $currentEvent" 480 } 481 as PointersInfo.PointersDown 482 // Make sure that overSlop is not 0f. This can happen when the user drags by exactly 483 // the touch slop. However, the overSlop we pass to onDragStarted() is used to 484 // compute the direction we are dragging in, so overSlop should never be 0f. 485 if (overSlop == 0f) { 486 // If the user drags in the opposite direction, the delta becomes zero because 487 // we return to the original point. Therefore, we should use the previous event 488 // to calculate the direction. 489 val delta = (drag.position - drag.previousPosition).toFloat() 490 check(delta != 0f) { 491 buildString { 492 append("delta is equal to 0 ") 493 append("touchSlop ${currentValueOf(LocalViewConfiguration).touchSlop} ") 494 append("consumablePointer.position ${consumablePointer.position} ") 495 append("drag.position ${drag.position} ") 496 append("drag.previousPosition ${drag.previousPosition}") 497 } 498 } 499 overSlop = delta.sign 500 } 501 502 val controller = onDragStart(lastPointersDown, overSlop) 503 val successful: Boolean 504 try { 505 onDrag(controller, overSlop) 506 507 successful = 508 drag( 509 initialPointerId = drag.id, 510 hasDragged = { it.positionChangeIgnoreConsumed().toFloat() != 0f }, 511 onDrag = { 512 onDrag(controller, it.positionChange().toFloat()) 513 it.consume() 514 }, 515 onIgnoredEvent = { 516 // We are still dragging an object, but this event is not of interest to the 517 // caller. 518 // This event will not trigger the onDrag event, but we will consume the 519 // event to prevent another pointerInput from interrupting the current 520 // gesture just because the event was ignored. 521 it.consume() 522 }, 523 ) 524 } catch (t: Throwable) { 525 onDragCancel(controller) 526 throw t 527 } 528 529 if (successful) { 530 onDragEnd(controller) 531 } else { 532 onDragCancel(controller) 533 } 534 } 535 awaitConsumableEventnull536 private suspend fun AwaitPointerEventScope.awaitConsumableEvent( 537 pass: () -> PointerEventPass 538 ): PointerEvent { 539 fun canBeConsumed(changes: List<PointerInputChange>): Boolean { 540 // At least one pointer down AND 541 return changes.fastAny { it.pressed } && 542 // All pointers must be either: 543 changes.fastAll { 544 // A) unconsumed AND recently pressed 545 it.changedToDown() || 546 // B) unconsumed AND in a new position (on the current axis) 547 it.positionChange().toFloat() != 0f 548 } 549 } 550 551 var event: PointerEvent 552 do { 553 event = awaitPointerEvent(pass = pass()) 554 } while (!canBeConsumed(event.changes)) 555 556 // We found a consumable event in the Main pass 557 return event 558 } 559 560 /** 561 * Continues to read drag events until all pointers are up or the drag event is canceled. The 562 * initial pointer to use for driving the drag is [initialPointerId]. [hasDragged] passes the 563 * result whether a change was detected from the drag function or not. 564 * 565 * Whenever the pointer moves, if [hasDragged] returns true, [onDrag] is called; otherwise, 566 * [onIgnoredEvent] is called. 567 * 568 * @return true when gesture ended with all pointers up and false when the gesture was canceled. 569 * 570 * Note: Inspired by DragGestureDetector.kt 571 */ dragnull572 private suspend inline fun AwaitPointerEventScope.drag( 573 initialPointerId: PointerId, 574 hasDragged: (PointerInputChange) -> Boolean, 575 onDrag: (PointerInputChange) -> Unit, 576 onIgnoredEvent: (PointerInputChange) -> Unit, 577 ): Boolean { 578 val pointer = currentEvent.changes.fastFirstOrNull { it.id == initialPointerId } 579 val isPointerUp = pointer?.pressed != true 580 if (isPointerUp) { 581 return false // The pointer has already been lifted, so the gesture is canceled 582 } 583 var pointerId = initialPointerId 584 while (true) { 585 val change = awaitDragOrUp(pointerId, hasDragged, onIgnoredEvent) ?: return false 586 587 if (change.isConsumed) { 588 return false 589 } 590 591 if (change.changedToUpIgnoreConsumed()) { 592 return true 593 } 594 595 onDrag(change) 596 pointerId = change.id 597 } 598 } 599 600 /** 601 * Waits for a single drag in one axis, final pointer up, or all pointers are up. When 602 * [initialPointerId] has lifted, another pointer that is down is chosen to be the finger 603 * governing the drag. When the final pointer is lifted, that [PointerInputChange] is returned. 604 * When a drag is detected, that [PointerInputChange] is returned. A drag is only detected when 605 * [hasDragged] returns `true`. Events that should not be captured are passed to 606 * [onIgnoredEvent]. 607 * 608 * `null` is returned if there was an error in the pointer input stream and the pointer that was 609 * down was dropped before the 'up' was received. 610 * 611 * Note: Inspired by DragGestureDetector.kt 612 */ awaitDragOrUpnull613 private suspend inline fun AwaitPointerEventScope.awaitDragOrUp( 614 initialPointerId: PointerId, 615 hasDragged: (PointerInputChange) -> Boolean, 616 onIgnoredEvent: (PointerInputChange) -> Unit, 617 ): PointerInputChange? { 618 var pointerId = initialPointerId 619 while (true) { 620 val event = awaitPointerEvent() 621 val dragEvent = event.changes.fastFirstOrNull { it.id == pointerId } ?: return null 622 if (dragEvent.changedToUpIgnoreConsumed()) { 623 val otherDown = event.changes.fastFirstOrNull { it.pressed } 624 if (otherDown == null) { 625 // This is the last "up" 626 return dragEvent 627 } else { 628 pointerId = otherDown.id 629 } 630 } else if (hasDragged(dragEvent)) { 631 return dragEvent 632 } else { 633 onIgnoredEvent(dragEvent) 634 } 635 } 636 } 637 <lambda>null638 private fun List<PointerInputChange>.countDown() = fastSumBy { if (it.pressed) 1 else 0 } 639 } 640 641 internal fun interface PointersInfoOwner { 642 /** 643 * Provides information about the pointers interacting with this composable. 644 * 645 * @return A [PointersInfo] object containing details about the pointers, including the starting 646 * position and the number of pointers down, or `null` if there are no pointers down. 647 */ pointersInfonull648 fun pointersInfo(): PointersInfo? 649 } 650 651 internal sealed interface PointersInfo { 652 /** 653 * Holds information about pointer interactions within a composable. 654 * 655 * This class stores details such as the starting position of a gesture, the number of pointers 656 * down, and whether the last pointer event was a mouse wheel scroll. 657 * 658 * @param startedPosition The starting position of the gesture. This is the position where the 659 * first pointer touched the screen, not necessarily the point where dragging begins. This may 660 * be different from the initial touch position if a child composable intercepts the gesture 661 * before this one. 662 * @param count The number of pointers currently down. 663 * @param countByType Provide a map of pointer types to the count of pointers of that type 664 * currently down/pressed. 665 */ 666 data class PointersDown( 667 val startedPosition: Offset, 668 val count: Int, 669 val countByType: Map<PointerType, Int>, 670 ) : PointersInfo { 671 init { 672 check(count > 0) { "We should have at least 1 pointer down, $count instead" } 673 } 674 } 675 676 /** Indicates whether the last pointer event was a mouse wheel scroll. */ 677 data object MouseWheel : PointersInfo 678 } 679