1 /*
<lambda>null2 * Copyright 2021 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.compose.ui.test
18
19 import androidx.compose.ui.geometry.Offset
20 import androidx.compose.ui.geometry.lerp
21 import androidx.compose.ui.platform.ViewConfiguration
22 import androidx.compose.ui.test.internal.JvmDefaultWithCompatibility
23 import androidx.compose.ui.util.lerp
24 import kotlin.math.ceil
25 import kotlin.math.max
26 import kotlin.math.roundToInt
27 import kotlin.math.roundToLong
28
29 /**
30 * The receiver scope of the touch input injection lambda from [performTouchInput].
31 *
32 * The functions in [TouchInjectionScope] can roughly be divided into two groups: full gestures and
33 * individual touch events. The individual touch events are: [down], [move] and friends, [up],
34 * [cancel] and [advanceEventTime]. Full gestures are all the other functions, like
35 * [click][TouchInjectionScope.click], [doubleClick][TouchInjectionScope.doubleClick],
36 * [swipe][TouchInjectionScope.swipe], etc. These are built on top of the individual events and
37 * serve as a good example on how you can build your own full gesture functions.
38 *
39 * A touch gesture is started with a [down] event, followed by a sequence of [move] events and
40 * finally an [up] event, optionally combined with more sets of [down] and [up] events for
41 * multi-touch gestures. Most methods accept a pointerId to specify which pointer (finger) the event
42 * applies to. Movement can be expressed absolutely with [moveTo] and [updatePointerTo], or relative
43 * to the current pointer position with [moveBy] and [updatePointerBy]. The `moveTo/By` methods
44 * enqueue an event immediately, while the `updatePointerTo/By` methods don't. This allows you to
45 * update the position of multiple pointers in a single [move] event for multi-touch gestures. Touch
46 * gestures can be cancelled with [cancel]. All events, regardless the method used, will always
47 * contain the current position of _all_ pointers.
48 *
49 * The entire event injection state is shared between all `perform.*Input` methods, meaning you can
50 * continue an unfinished touch gesture in a subsequent invocation of [performTouchInput] or
51 * [performMultiModalInput]. Note however that while the pointer positions are retained across
52 * invocation of `perform.*Input` methods, they are always manipulated in the current node's local
53 * coordinate system. That means that two subsequent invocations of [performTouchInput] on different
54 * nodes will report a different [currentPosition], even though it is actually the same position on
55 * the screen.
56 *
57 * All events sent by these methods are batched together and sent as a whole after
58 * [performTouchInput] has executed its code block. Because gestures don't have to be defined all in
59 * the same [performTouchInput] block, keep in mind that while the gesture is not complete, all code
60 * you execute in between these blocks will be executed while imaginary fingers are actively
61 * touching the screen. The events sent as part of the same batch will not be interrupted by
62 * recomposition, however, if a gesture spans multiple [performTouchInput] blocks it is important to
63 * remember that recomposition, layout and drawing could take place during the gesture, which may
64 * lead to events being injected into a moving target. As pointer positions are manipulated in the
65 * current node's local coordinate system, this could lead to issues caused by the fact that part of
66 * the gesture will take effect before the rest of the events have been enqueued.
67 *
68 * Example of performing a click:
69 *
70 * @sample androidx.compose.ui.test.samples.touchInputClick
71 *
72 * Example of performing a swipe up:
73 *
74 * @sample androidx.compose.ui.test.samples.touchInputSwipeUp
75 *
76 * Example of performing an L-shaped gesture:
77 *
78 * @sample androidx.compose.ui.test.samples.touchInputLShapedGesture
79 * @see InjectionScope
80 */
81 @JvmDefaultWithCompatibility
82 interface TouchInjectionScope : InjectionScope {
83 /**
84 * Returns the current position of the given [pointerId]. The default [pointerId] is 0. The
85 * position is returned in the local coordinate system of the node with which we're interacting.
86 * (0, 0) is the top left corner of the node.
87 */
88 fun currentPosition(pointerId: Int = 0): Offset?
89
90 /**
91 * Sends a down event for the pointer with the given [pointerId] at [position] on the associated
92 * node. The [position] is in the node's local coordinate system, where (0, 0) is the top left
93 * corner of the node.
94 *
95 * If no pointers are down yet, this will start a new touch gesture. If a gesture is already in
96 * progress, this event is sent at the same timestamp as the last event. If the given pointer is
97 * already down, an [IllegalArgumentException] will be thrown.
98 *
99 * @param pointerId The id of the pointer, can be any number not yet in use by another pointer
100 * @param position The position of the down event, in the node's local coordinate system
101 */
102 fun down(pointerId: Int, position: Offset)
103
104 /**
105 * Sends a down event for the default pointer at [position] on the associated node. The
106 * [position] is in the node's local coordinate system, where (0, 0) is the top left corner of
107 * the node. The default pointer has `pointerId = 0`.
108 *
109 * If no pointers are down yet, this will start a new touch gesture. If a gesture is already in
110 * progress, this event is sent at the same timestamp as the last event. If the default pointer
111 * is already down, an [IllegalArgumentException] will be thrown.
112 *
113 * @param position The position of the down event, in the node's local coordinate system
114 */
115 fun down(position: Offset) {
116 down(0, position)
117 }
118
119 /**
120 * Sends a move event [delayMillis] after the last sent event on the associated node, with the
121 * position of the pointer with the given [pointerId] updated to [position]. The [position] is
122 * in the node's local coordinate system, where (0, 0) is the top left corner of the node.
123 *
124 * If the pointer is not yet down, an [IllegalArgumentException] will be thrown.
125 *
126 * @param pointerId The id of the pointer to move, as supplied in [down]
127 * @param position The new position of the pointer, in the node's local coordinate system
128 * @param delayMillis The time between the last sent event and this event. [eventPeriodMillis]
129 * by default.
130 */
131 fun moveTo(pointerId: Int, position: Offset, delayMillis: Long = eventPeriodMillis) {
132 updatePointerTo(pointerId, position)
133 move(delayMillis)
134 }
135
136 /**
137 * Sends a move event [delayMillis] after the last sent event on the associated node, with the
138 * position of the default pointer updated to [position]. The [position] is in the node's local
139 * coordinate system, where (0, 0) is the top left corner of the node. The default pointer has
140 * `pointerId = 0`.
141 *
142 * If the default pointer is not yet down, an [IllegalArgumentException] will be thrown.
143 *
144 * @param position The new position of the pointer, in the node's local coordinate system
145 * @param delayMillis The time between the last sent event and this event. [eventPeriodMillis]
146 * by default.
147 */
148 fun moveTo(position: Offset, delayMillis: Long = eventPeriodMillis) {
149 moveTo(0, position, delayMillis)
150 }
151
152 /**
153 * Updates the position of the pointer with the given [pointerId] to the given [position], but
154 * does not send a move event. The move event can be sent with [move]. The [position] is in the
155 * node's local coordinate system, where (0.px, 0.px) is the top left corner of the node.
156 *
157 * If the pointer is not yet down, an [IllegalArgumentException] will be thrown.
158 *
159 * @param pointerId The id of the pointer to move, as supplied in [down]
160 * @param position The new position of the pointer, in the node's local coordinate system
161 */
162 fun updatePointerTo(pointerId: Int, position: Offset)
163
164 /**
165 * Sends a move event [delayMillis] after the last sent event on the associated node, with the
166 * position of the pointer with the given [pointerId] moved by the given [delta].
167 *
168 * If the pointer is not yet down, an [IllegalArgumentException] will be thrown.
169 *
170 * @param pointerId The id of the pointer to move, as supplied in [down]
171 * @param delta The position for this move event, relative to the current position of the
172 * pointer. For example, `delta = Offset(10.px, -10.px) will add 10.px to the pointer's
173 * x-position, and subtract 10.px from the pointer's y-position.
174 * @param delayMillis The time between the last sent event and this event. [eventPeriodMillis]
175 * by default.
176 */
177 fun moveBy(pointerId: Int, delta: Offset, delayMillis: Long = eventPeriodMillis) {
178 updatePointerBy(pointerId, delta)
179 move(delayMillis)
180 }
181
182 /**
183 * Sends a move event [delayMillis] after the last sent event on the associated node, with the
184 * position of the default pointer moved by the given [delta]. The default pointer has
185 * `pointerId = 0`.
186 *
187 * If the pointer is not yet down, an [IllegalArgumentException] will be thrown.
188 *
189 * @param delta The position for this move event, relative to the current position of the
190 * pointer. For example, `delta = Offset(10.px, -10.px) will add 10.px to the pointer's
191 * x-position, and subtract 10.px from the pointer's y-position.
192 * @param delayMillis The time between the last sent event and this event. [eventPeriodMillis]
193 * by default.
194 */
195 fun moveBy(delta: Offset, delayMillis: Long = eventPeriodMillis) {
196 moveBy(0, delta, delayMillis)
197 }
198
199 /**
200 * Updates the position of the pointer with the given [pointerId] by the given [delta], but does
201 * not send a move event. The move event can be sent with [move].
202 *
203 * If the pointer is not yet down, an [IllegalArgumentException] will be thrown.
204 *
205 * @param pointerId The id of the pointer to move, as supplied in [down]
206 * @param delta The position for this move event, relative to the last sent position of the
207 * pointer. For example, `delta = Offset(10.px, -10.px) will add 10.px to the pointer's
208 * x-position, and subtract 10.px from the pointer's y-position.
209 */
210 fun updatePointerBy(pointerId: Int, delta: Offset) {
211 // Ignore currentPosition of null here, let updatePointerTo generate the error
212 val currentPosition = currentPosition(pointerId) ?: Offset.Zero
213
214 val position =
215 if (currentPosition.isValid() && delta.isValid()) {
216 currentPosition + delta
217 } else {
218 // Allows invalid position to still pass back through Compose (for testing)
219 Offset.Unspecified
220 }
221
222 updatePointerTo(pointerId, position)
223 }
224
225 /**
226 * Sends a move event [delayMillis] after the last sent event without updating any of the
227 * pointer positions. This can be useful when batching movement of multiple pointers together,
228 * which can be done with [updatePointerTo] and [updatePointerBy].
229 *
230 * @param delayMillis The time between the last sent event and this event. [eventPeriodMillis]
231 * by default.
232 */
233 fun move(delayMillis: Long = eventPeriodMillis)
234
235 /**
236 * Sends a move event [delayMillis] after the last sent event without updating any of the
237 * pointer positions, while adding the [historicalCoordinates] at the [relativeHistoricalTimes]
238 * to the move event. This corresponds to the scenario where a touch screen generates touch
239 * events quicker than can be dispatched and batches them together.
240 *
241 * @sample androidx.compose.ui.test.samples.touchInputMultiTouchWithHistory
242 * @param relativeHistoricalTimes Time of each historical event, as a millisecond relative to
243 * the time the actual event is sent. For example, -10L means 10ms earlier.
244 * @param historicalCoordinates Coordinates of each historical event, in the same coordinate
245 * space as [moveTo]. The outer list must have the same size as the number of pointers in the
246 * event, and each inner list must have the same size as [relativeHistoricalTimes]. The `i`th
247 * pointer is assigned the `i`th history, with the pointers sorted on ascending pointerId.
248 * @param delayMillis The time between the last sent event and this event. [eventPeriodMillis]
249 * by default.
250 */
251 @ExperimentalTestApi
252 fun moveWithHistoryMultiPointer(
253 relativeHistoricalTimes: List<Long>,
254 historicalCoordinates: List<List<Offset>>,
255 delayMillis: Long = eventPeriodMillis
256 )
257
258 /**
259 * Sends a move event [delayMillis] after the last sent event without updating any of the
260 * pointer positions, while adding the [historicalCoordinates] at the [relativeHistoricalTimes]
261 * to the move event. This corresponds to the scenario where a touch screen generates touch
262 * events quicker than can be dispatched and batches them together.
263 *
264 * This overload is a convenience method for the common case where the gesture only has one
265 * pointer.
266 *
267 * @param relativeHistoricalTimes Time of each historical event, as a millisecond relative to
268 * the time the actual event is sent. For example, -10L means 10ms earlier.
269 * @param historicalCoordinates Coordinates of each historical event, in the same coordinate
270 * space as [moveTo]. The list must have the same size as [relativeHistoricalTimes].
271 * @param delayMillis The time between the last sent event and this event. [eventPeriodMillis]
272 * by default.
273 */
274 @ExperimentalTestApi
275 fun moveWithHistory(
276 relativeHistoricalTimes: List<Long>,
277 historicalCoordinates: List<Offset>,
278 delayMillis: Long = eventPeriodMillis
279 ) =
280 moveWithHistoryMultiPointer(
281 relativeHistoricalTimes,
282 listOf(historicalCoordinates),
283 delayMillis
284 )
285
286 /**
287 * Sends an up event for the pointer with the given [pointerId], or the default pointer if
288 * [pointerId] is omitted, on the associated node.
289 *
290 * @param pointerId The id of the pointer to lift up, as supplied in [down]
291 */
292 fun up(pointerId: Int = 0)
293
294 /**
295 * Sends a cancel event [delayMillis] after the last sent event to cancel the current gesture.
296 * The cancel event contains the current position of all active pointers.
297 *
298 * @param delayMillis The time between the last sent event and this event. [eventPeriodMillis]
299 * by default.
300 */
301 fun cancel(delayMillis: Long = eventPeriodMillis)
302 }
303
304 internal class TouchInjectionScopeImpl(private val baseScope: MultiModalInjectionScopeImpl) :
<lambda>null305 TouchInjectionScope, InjectionScope by baseScope {
306 private val inputDispatcher
307 get() = baseScope.inputDispatcher
308
309 private fun localToRoot(position: Offset) = baseScope.localToRoot(position)
310
311 override fun currentPosition(pointerId: Int): Offset? {
312 val positionInRoot = inputDispatcher.getCurrentTouchPosition(pointerId) ?: return null
313 return baseScope.rootToLocal(positionInRoot)
314 }
315
316 override fun down(pointerId: Int, position: Offset) {
317 val positionInRoot = localToRoot(position)
318 inputDispatcher.enqueueTouchDown(pointerId, positionInRoot)
319 }
320
321 override fun updatePointerTo(pointerId: Int, position: Offset) {
322 val positionInRoot = localToRoot(position)
323 inputDispatcher.updateTouchPointer(pointerId, positionInRoot)
324 }
325
326 override fun move(delayMillis: Long) {
327 advanceEventTime(delayMillis)
328 inputDispatcher.enqueueTouchMove()
329 }
330
331 @ExperimentalTestApi
332 override fun moveWithHistoryMultiPointer(
333 relativeHistoricalTimes: List<Long>,
334 historicalCoordinates: List<List<Offset>>,
335 delayMillis: Long
336 ) {
337 repeat(relativeHistoricalTimes.size) {
338 check(relativeHistoricalTimes[it] < 0) {
339 "Relative historical times should be negative, in order to be in the past" +
340 "(offset $it was: ${relativeHistoricalTimes[it]})"
341 }
342 check(relativeHistoricalTimes[it] >= -delayMillis) {
343 "Relative historical times should not be earlier than the previous event " +
344 "(offset $it was: ${relativeHistoricalTimes[it]}, ${-delayMillis})"
345 }
346 }
347
348 advanceEventTime(delayMillis)
349 inputDispatcher.enqueueTouchMoves(relativeHistoricalTimes, historicalCoordinates)
350 }
351
352 override fun up(pointerId: Int) {
353 inputDispatcher.enqueueTouchUp(pointerId)
354 }
355
356 override fun cancel(delayMillis: Long) {
357 advanceEventTime(delayMillis)
358 inputDispatcher.enqueueTouchCancel()
359 }
360 }
361
362 /**
363 * Performs a click gesture (aka a tap) on the associated node.
364 *
365 * The click is done at the given [position], or in the [center] if the [position] is omitted. The
366 * [position] is in the node's local coordinate system, where (0, 0) is the top left corner of the
367 * node.
368 *
369 * @param position The position where to click, in the node's local coordinate system. If omitted,
370 * the [center] of the node will be used.
371 */
TouchInjectionScopenull372 fun TouchInjectionScope.click(position: Offset = center) {
373 down(position)
374 move()
375 up()
376 }
377
378 /**
379 * Performs a long click gesture (aka a long press) on the associated node.
380 *
381 * The long click is done at the given [position], or in the [center] if the [position] is omitted.
382 * By default, the [durationMillis] of the press is 100ms longer than the minimum required duration
383 * for a long press. The [position] is in the node's local coordinate system, where (0, 0) is the
384 * top left corner of the node.
385 *
386 * @param position The position of the long click, in the node's local coordinate system. If
387 * omitted, the [center] of the node will be used.
388 * @param durationMillis The time between the down and the up event
389 */
TouchInjectionScopenull390 fun TouchInjectionScope.longClick(
391 position: Offset = center,
392 durationMillis: Long = viewConfiguration.longPressTimeoutMillis + 100
393 ) {
394 require(durationMillis >= viewConfiguration.longPressTimeoutMillis) {
395 "Long click must have a duration of at least ${viewConfiguration.longPressTimeoutMillis}ms"
396 }
397 swipe(position, position, durationMillis)
398 }
399
400 // The average of min and max is a safe default
401 private val ViewConfiguration.defaultDoubleTapDelayMillis: Long
402 get() = (doubleTapMinTimeMillis + doubleTapTimeoutMillis) / 2
403
404 /**
405 * Performs a double click gesture (aka a double tap) on the associated node.
406 *
407 * The double click is done at the given [position] or in the [center] if the [position] is omitted.
408 * By default, the [delayMillis] between the first and the second click is half way in between the
409 * minimum and maximum required delay for a double click. The [position] is in the node's local
410 * coordinate system, where (0, 0) is the top left corner of the node.
411 *
412 * @param position The position of the double click, in the node's local coordinate system. If
413 * omitted, the [center] position will be used.
414 * @param delayMillis The time between the up event of the first click and the down event of the
415 * second click
416 */
doubleClicknull417 fun TouchInjectionScope.doubleClick(
418 position: Offset = center,
419 delayMillis: Long = viewConfiguration.defaultDoubleTapDelayMillis
420 ) {
421 require(delayMillis >= viewConfiguration.doubleTapMinTimeMillis) {
422 "Time between clicks in double click must be at least " +
423 "${viewConfiguration.doubleTapMinTimeMillis}ms"
424 }
425 require(delayMillis < viewConfiguration.doubleTapTimeoutMillis) {
426 "Time between clicks in double click must be smaller than " +
427 "${viewConfiguration.doubleTapTimeoutMillis}ms"
428 }
429 click(position)
430 advanceEventTime(delayMillis)
431 click(position)
432 }
433
434 /**
435 * Performs a swipe gesture on the associated node.
436 *
437 * The motion events are linearly interpolated between [start] and [end]. The coordinates are in the
438 * node's local coordinate system, where (0, 0) is the top left corner of the node. The default
439 * duration is 200 milliseconds.
440 *
441 * @param start The start position of the gesture, in the node's local coordinate system
442 * @param end The end position of the gesture, in the node's local coordinate system
443 * @param durationMillis The duration of the gesture
444 */
TouchInjectionScopenull445 fun TouchInjectionScope.swipe(start: Offset, end: Offset, durationMillis: Long = 200) {
446 val durationFloat = durationMillis.toFloat()
447 swipe(curve = { lerp(start, end, it / durationFloat) }, durationMillis = durationMillis)
448 }
449
450 /**
451 * Performs a swipe gesture on the associated node.
452 *
453 * The swipe follows the [curve] from 0 till [durationMillis]. Will force sampling of an event at
454 * all times defined in [keyTimes]. The time between events is kept as close to
455 * [eventPeriodMillis][InjectionScope.eventPeriodMillis] as possible, given the constraints. The
456 * coordinates are in the node's local coordinate system, where (0, 0) is the top left corner of the
457 * node. The default duration is 200 milliseconds.
458 *
459 * @param curve The function that describes the gesture. The argument passed to the function is the
460 * time in milliseconds since the start of the swipe, and the return value is the location of the
461 * pointer at that point in time.
462 * @param durationMillis The duration of the gesture
463 * @param keyTimes An optional list of timestamps in milliseconds at which a move event must be
464 * sampled
465 */
TouchInjectionScopenull466 fun TouchInjectionScope.swipe(
467 curve: (timeMillis: Long) -> Offset,
468 durationMillis: Long = 200,
469 keyTimes: List<Long> = emptyList()
470 ) {
471 multiTouchSwipe(listOf(curve), durationMillis, keyTimes)
472 }
473
474 /**
475 * Performs a multi touch swipe gesture on the associated node.
476 *
477 * Each pointer follows [curves][i] from 0 till [durationMillis]. Sampling of an event is forced
478 * at all times defined in [keyTimes]. The time between events is kept as close to
479 * [eventPeriodMillis][InjectionScope.eventPeriodMillis] as possible, given the constraints. The
480 * coordinates are in the node's local coordinate system, where (0, 0) is the top left corner of the
481 * node. The default duration is 200 milliseconds.
482 *
483 * @param curves The functions that describe the gesture. Function _i_ defines the position over
484 * time for pointer id _i_. The argument passed to each function is the time in milliseconds since
485 * the start of the swipe, and the return value is the location of that pointer at that point in
486 * time.
487 * @param durationMillis The duration of the gesture
488 * @param keyTimes An optional list of timestamps in milliseconds at which a move event must be
489 * sampled
490 */
multiTouchSwipenull491 fun TouchInjectionScope.multiTouchSwipe(
492 curves: List<(timeMillis: Long) -> Offset>,
493 durationMillis: Long = 200,
494 keyTimes: List<Long> = emptyList()
495 ) {
496 val startTime = 0L
497 val endTime = durationMillis
498
499 // Validate input
500 require(durationMillis >= 1) { "duration must be at least 1 millisecond, not $durationMillis" }
501 val validRange = startTime..endTime
502 require(keyTimes.all { it in validRange }) {
503 "keyTimes contains timestamps out of range [$startTime..$endTime]: $keyTimes"
504 }
505 require(keyTimes.asSequence().zipWithNext { a, b -> a <= b }.all { it }) {
506 "keyTimes must be sorted: $keyTimes"
507 }
508
509 // Send down events
510 curves.forEachIndexed { i, curve -> down(i, curve(startTime)) }
511
512 // Send move events between each consecutive pair in [t0, ..keyTimes, tN]
513 var currTime = startTime
514 var key = 0
515 while (currTime < endTime) {
516 // advance key
517 while (key < keyTimes.size && keyTimes[key] <= currTime) {
518 key++
519 }
520 // send events between t and next keyTime
521 val tNext = if (key < keyTimes.size) keyTimes[key] else endTime
522 sendMultiTouchSwipeSegment(curves, currTime, tNext)
523 currTime = tNext
524 }
525
526 // And end with up events
527 repeat(curves.size) { up(it) }
528 }
529
530 /**
531 * Generates move events between `f([t0])` and `f([tN])` during the time window `(t0, tN]`, for each
532 * `f` in [fs], following the curves defined by each `f`. The number of events sent (#numEvents) is
533 * such that the time between each event is as close to
534 * [eventPeriodMillis][InputDispatcher.eventPeriodMillis] as possible, but at least 1. The first
535 * event is sent at time `downTime + (tN - t0) / #numEvents`, the last event is sent at time tN.
536 *
537 * @param fs The functions that define the coordinates of the respective gestures over time
538 * @param t0 The start time of this segment of the swipe, in milliseconds relative to downTime
539 * @param tN The end time of this segment of the swipe, in milliseconds relative to downTime
540 */
TouchInjectionScopenull541 private fun TouchInjectionScope.sendMultiTouchSwipeSegment(
542 fs: List<(Long) -> Offset>,
543 t0: Long,
544 tN: Long
545 ) {
546 var step = 0
547 // How many steps will we take between t0 and tN? At least 1, and a number that will
548 // bring as as close to eventPeriod as possible
549 val steps = max(1, ((tN - t0) / eventPeriodMillis.toFloat()).roundToInt())
550
551 var tPrev = t0
552 while (step++ < steps) {
553 val progress = step / steps.toFloat()
554 val t = lerp(t0, tN, progress)
555 fs.forEachIndexed { i, f -> updatePointerTo(i, f(t)) }
556 move(t - tPrev)
557 tPrev = t
558 }
559 }
560
561 /**
562 * Performs a pinch gesture on the associated node.
563 *
564 * For each pair of start and end [Offset]s, the motion events are linearly interpolated. The
565 * coordinates are in the node's local coordinate system where (0, 0) is the top left corner of the
566 * node. The default duration is 400 milliseconds.
567 *
568 * @param start0 The start position of the first gesture in the node's local coordinate system
569 * @param end0 The end position of the first gesture in the node's local coordinate system
570 * @param start1 The start position of the second gesture in the node's local coordinate system
571 * @param end1 The end position of the second gesture in the node's local coordinate system
572 * @param durationMillis the duration of the gesture
573 */
pinchnull574 fun TouchInjectionScope.pinch(
575 start0: Offset,
576 end0: Offset,
577 start1: Offset,
578 end1: Offset,
579 durationMillis: Long = 400
580 ) {
581 val durationFloat = durationMillis.toFloat()
582 multiTouchSwipe(
583 listOf(
584 { lerp(start0, end0, it / durationFloat) },
585 { lerp(start1, end1, it / durationFloat) }
586 ),
587 durationMillis
588 )
589 }
590
591 /**
592 * Performs a swipe gesture on the associated node such that it ends with the given [endVelocity].
593 *
594 * The swipe will go through [start] at t=0 and through [end] at t=[durationMillis]. In between, the
595 * swipe will go monotonically from [start] and [end], but not strictly. Due to imprecision, no
596 * guarantees can be made for the actual velocity at the end of the gesture, but generally it is
597 * within 0.1 of the desired velocity.
598 *
599 * When a swipe cannot be created that results in the desired velocity (because the input is too
600 * restrictive), an exception will be thrown with suggestions to fix the input.
601 *
602 * The coordinates are in the node's local coordinate system, where (0, 0) is the top left corner of
603 * the node. The default duration is calculated such that a feasible swipe can be created that ends
604 * in the given velocity.
605 *
606 * @param start The start position of the gesture, in the node's local coordinate system
607 * @param end The end position of the gesture, in the node's local coordinate system
608 * @param endVelocity The velocity of the gesture at the moment it ends in px/second. Must be
609 * positive.
610 * @param durationMillis The duration of the gesture in milliseconds. Must be long enough that at
611 * least 3 input events are generated, which happens with a duration of 40ms or more. If omitted,
612 * a duration is calculated such that a valid swipe with velocity can be created.
613 * @throws IllegalArgumentException When no swipe can be generated that will result in the desired
614 * velocity. The error message will suggest changes to the input parameters such that a swipe will
615 * become feasible.
616 */
TouchInjectionScopenull617 fun TouchInjectionScope.swipeWithVelocity(
618 start: Offset,
619 end: Offset,
620 /*@FloatRange(from = 0.0)*/
621 endVelocity: Float,
622 durationMillis: Long = VelocityPathFinder.calculateDefaultDuration(start, end, endVelocity)
623 ) {
624 require(endVelocity >= 0f) { "Velocity cannot be $endVelocity, it must be positive" }
625 require(eventPeriodMillis < 40) {
626 "InputDispatcher.eventPeriod must be smaller than 40ms in order to generate velocities"
627 }
628 val minimumDuration = ceil(2.5f * eventPeriodMillis).roundToLong()
629 require(durationMillis >= minimumDuration) {
630 "Duration must be at least ${minimumDuration}ms because " +
631 "velocity requires at least 3 input events"
632 }
633
634 val pathFinder = VelocityPathFinder(start, end, endVelocity, durationMillis)
635 val swipeFunction: (Long) -> Offset = { pathFinder.calculateOffsetForTime(it) }
636 swipe(swipeFunction, durationMillis)
637 }
638
639 /**
640 * Performs a swipe up gesture along `x = [centerX]` of the associated node, from [startY] till
641 * [endY], taking [durationMillis] milliseconds.
642 *
643 * @param startY The y-coordinate of the start of the swipe. Must be greater than or equal to the
644 * [endY]. By default the [bottom] of the node.
645 * @param endY The y-coordinate of the end of the swipe. Must be less than or equal to the [startY].
646 * By default the [top] of the node.
647 * @param durationMillis The duration of the swipe. By default 200 milliseconds.
648 */
TouchInjectionScopenull649 fun TouchInjectionScope.swipeUp(
650 startY: Float = bottom,
651 endY: Float = top,
652 durationMillis: Long = 200
653 ) {
654 require(startY >= endY) { "startY=$startY needs to be greater than or equal to endY=$endY" }
655 val start = Offset(centerX, startY)
656 val end = Offset(centerX, endY)
657 swipe(start, end, durationMillis)
658 }
659
660 /**
661 * Performs a swipe down gesture along `x = [centerX]` of the associated node, from [startY] till
662 * [endY], taking [durationMillis] milliseconds.
663 *
664 * @param startY The y-coordinate of the start of the swipe. Must be less than or equal to the
665 * [endY]. By default the [top] of the node.
666 * @param endY The y-coordinate of the end of the swipe. Must be greater than or equal to the
667 * [startY]. By default the [bottom] of the node.
668 * @param durationMillis The duration of the swipe. By default 200 milliseconds.
669 */
TouchInjectionScopenull670 fun TouchInjectionScope.swipeDown(
671 startY: Float = top,
672 endY: Float = bottom,
673 durationMillis: Long = 200
674 ) {
675 require(startY <= endY) { "startY=$startY needs to be less than or equal to endY=$endY" }
676 val start = Offset(centerX, startY)
677 val end = Offset(centerX, endY)
678 swipe(start, end, durationMillis)
679 }
680
681 /**
682 * Performs a swipe left gesture along `y = [centerY]` of the associated node, from [startX] till
683 * [endX], taking [durationMillis] milliseconds.
684 *
685 * @param startX The x-coordinate of the start of the swipe. Must be greater than or equal to the
686 * [endX]. By default the [right] of the node.
687 * @param endX The x-coordinate of the end of the swipe. Must be less than or equal to the [startX].
688 * By default the [left] of the node.
689 * @param durationMillis The duration of the swipe. By default 200 milliseconds.
690 */
TouchInjectionScopenull691 fun TouchInjectionScope.swipeLeft(
692 startX: Float = right,
693 endX: Float = left,
694 durationMillis: Long = 200
695 ) {
696 require(startX >= endX) { "startX=$startX needs to be greater than or equal to endX=$endX" }
697 val start = Offset(startX, centerY)
698 val end = Offset(endX, centerY)
699 swipe(start, end, durationMillis)
700 }
701
702 /**
703 * Performs a swipe right gesture along `y = [centerY]` of the associated node, from [startX] till
704 * [endX], taking [durationMillis] milliseconds.
705 *
706 * @param startX The x-coordinate of the start of the swipe. Must be less than or equal to the
707 * [endX]. By default the [left] of the node.
708 * @param endX The x-coordinate of the end of the swipe. Must be greater than or equal to the
709 * [startX]. By default the [right] of the node.
710 * @param durationMillis The duration of the swipe. By default 200 milliseconds.
711 */
swipeRightnull712 fun TouchInjectionScope.swipeRight(
713 startX: Float = left,
714 endX: Float = right,
715 durationMillis: Long = 200
716 ) {
717 require(startX <= endX) { "startX=$startX needs to be less than or equal to endX=$endX" }
718 val start = Offset(startX, centerY)
719 val end = Offset(endX, centerY)
720 swipe(start, end, durationMillis)
721 }
722