1 /*
<lambda>null2  * Copyright (C) 2022 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.constraintlayout.compose
18 
19 import androidx.compose.foundation.gestures.awaitEachGesture
20 import androidx.compose.foundation.gestures.awaitFirstDown
21 import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation
22 import androidx.compose.foundation.gestures.drag
23 import androidx.compose.runtime.LaunchedEffect
24 import androidx.compose.runtime.MutableFloatState
25 import androidx.compose.runtime.remember
26 import androidx.compose.ui.Modifier
27 import androidx.compose.ui.composed
28 import androidx.compose.ui.geometry.Offset
29 import androidx.compose.ui.input.pointer.PointerInputChange
30 import androidx.compose.ui.input.pointer.PointerInputScope
31 import androidx.compose.ui.input.pointer.pointerInput
32 import androidx.compose.ui.input.pointer.positionChange
33 import androidx.compose.ui.input.pointer.util.VelocityTracker
34 import androidx.compose.ui.input.pointer.util.addPointerInputChange
35 import androidx.compose.ui.platform.debugInspectorInfo
36 import androidx.compose.ui.unit.Velocity
37 import kotlinx.coroutines.channels.Channel
38 import kotlinx.coroutines.ensureActive
39 import kotlinx.coroutines.isActive
40 
41 /**
42  * Helper modifier for MotionLayout to support OnSwipe in Transitions.
43  *
44  * @see Modifier.pointerInput
45  * @see TransitionHandler
46  */
47 @ExperimentalMotionApi
48 internal fun Modifier.motionPointerInput(
49     key: Any,
50     motionProgress: MutableFloatState,
51     measurer: MotionMeasurer
52 ): Modifier =
53     composed(
54         inspectorInfo =
55             debugInspectorInfo {
56                 name = "motionPointerInput"
57                 properties["key"] = key
58                 properties["motionProgress"] = motionProgress
59                 properties["measurer"] = measurer
60             }
<lambda>null61     ) {
62         if (!measurer.transition.hasOnSwipe()) {
63             return@composed this
64         }
65         val swipeHandler =
66             remember(key) {
67                 TransitionHandler(motionMeasurer = measurer, motionProgress = motionProgress)
68             }
69         val dragChannel = remember(key) { Channel<MotionDragState>(Channel.CONFLATED) }
70 
71         LaunchedEffect(key1 = key) effectScope@{
72             var isTouchUp = false
73             var dragState: MotionDragState? = null
74             while (coroutineContext.isActive) {
75                 if (isTouchUp && swipeHandler.pendingProgressWhileTouchUp()) {
76                     // Loop until there's no need to update the progress or the there's a touch down
77                     swipeHandler.updateProgressWhileTouchUp()
78                 } else {
79                     if (dragState == null) {
80                         dragState = dragChannel.receive()
81                     }
82                     coroutineContext.ensureActive()
83                     isTouchUp = !dragState.isDragging
84                     if (isTouchUp) {
85                         swipeHandler.onTouchUp(velocity = dragState.velocity)
86                     } else {
87                         swipeHandler.updateProgressOnDrag(dragAmount = dragState.dragAmount)
88                     }
89                     dragState = null
90                 }
91 
92                 // To be able to interrupt the free-form progress of 'isUp', check if there's
93                 // another
94                 // dragState that initiated a new drag
95                 val channelResult = dragChannel.tryReceive()
96                 if (channelResult.isSuccess) {
97                     val receivedState = channelResult.getOrThrow()
98                     if (receivedState.isDragging) {
99                         // If another drag is initiated, switching 'isUp' interrupts the
100                         // 'getTouchUpProgress' loop
101                         isTouchUp = false
102                     }
103                     // Just save the received state, don't 'consume' it
104                     dragState = receivedState
105                 }
106             }
107         }
108         return@composed this.pointerInput(key) {
109             val velocityTracker = VelocityTracker()
110             detectDragGesturesWhenNeeded(
111                 onAcceptFirstDown = { offset -> swipeHandler.onAcceptFirstDownForOnSwipe(offset) },
112                 onDragStart = { _ -> velocityTracker.resetTracking() },
113                 onDragEnd = {
114                     dragChannel.trySend(
115                         // Indicate that the swipe has ended, MotionLayout should animate the rest.
116                         MotionDragState.onDragEnd(velocityTracker.calculateVelocity())
117                     )
118                 },
119                 onDragCancel = {
120                     dragChannel.trySend(
121                         // Indicate that the swipe has ended, MotionLayout should animate the rest.
122                         MotionDragState.onDragEnd(velocityTracker.calculateVelocity())
123                     )
124                 },
125                 onDrag = { change, dragAmount ->
126                     velocityTracker.addPointerInputChange(change)
127                     // As dragging is done, pass the dragAmount to update the MotionLayout progress.
128                     dragChannel.trySend(MotionDragState.onDrag(dragAmount))
129                 }
130             )
131         }
132     }
133 
134 /** Data class with the relevant values of a touch input event used for OnSwipe support. */
135 internal data class MotionDragState(
136     val isDragging: Boolean,
137     val dragAmount: Offset,
138     val velocity: Velocity
139 ) {
140     companion object {
141 
onDragnull142         fun onDrag(dragAmount: Offset) =
143             MotionDragState(isDragging = true, dragAmount = dragAmount, velocity = Velocity.Zero)
144 
145         fun onDragEnd(velocity: Velocity) =
146             MotionDragState(
147                 isDragging = false,
148                 dragAmount = Offset.Unspecified,
149                 velocity = velocity
150             )
151     }
152 }
153 
154 /**
155  * Copy of [androidx.compose.foundation.gestures.detectDragGestures] with the opportunity to decide
156  * whether we consume the rest of the drag with [onAcceptFirstDown].
157  */
158 private suspend fun PointerInputScope.detectDragGesturesWhenNeeded(
159     onAcceptFirstDown: (Offset) -> Boolean,
160     onDragStart: (Offset) -> Unit,
161     onDragEnd: () -> Unit,
162     onDragCancel: () -> Unit,
163     onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
164 ) {
165     awaitEachGesture {
166         val down = awaitFirstDown(requireUnconsumed = true)
167         if (!onAcceptFirstDown(down.position)) {
168             return@awaitEachGesture
169         }
170         var drag: PointerInputChange?
171         var overSlop = Offset.Zero
172         do {
173             drag =
174                 awaitTouchSlopOrCancellation(pointerId = down.id) { change, over ->
175                     change.consume()
176                     overSlop = over
177                 }
178         } while (drag != null && !drag.isConsumed)
179         if (drag != null) {
180             onDragStart.invoke(drag.position)
181             onDrag(drag, overSlop)
182             if (
183                 !drag(drag.id) {
184                     onDrag(it, it.positionChange())
185                     it.consume()
186                 }
187             ) {
188                 onDragCancel()
189             } else {
190                 onDragEnd()
191             }
192         }
193     }
194 }
195