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