1 /*
2 * Copyright 2018 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.testutils
18
19 import android.content.Context
20 import android.view.MotionEvent
21 import android.view.View
22 import android.view.ViewConfiguration
23 import java.util.ArrayList
24 import kotlin.math.ceil
25 import kotlin.math.floor
26
27 /**
28 * One [MotionEvent] approximately every 10 milliseconds. We care about this frequency because a
29 * standard touchscreen operates at 100 Hz and therefore produces about one touch event every 10
30 * milliseconds. We want to produce a similar frequency to emulate real world input events.
31 */
32 const val MOTION_EVENT_INTERVAL_MILLIS: Int = 10
33
34 /**
35 * Distance and time span necessary to produce a fling.
36 *
37 * Distance and time necessary to produce a fling for [MotionEvent]
38 *
39 * @property distance Distance between [MotionEvent]s in pixels for a fling.
40 * @property time Time between [MotionEvent]s in milliseconds for a fling.
41 */
42 data class FlingData(val distance: Float, val time: Int) {
43
44 /** @property velocity Velocity of fling in pixels per millisecond. */
45 val velocity: Float = distance / time
46 }
47
48 data class MotionEventData(
49 val eventTimeDelta: Int,
50 val action: Int,
51 val x: Float,
52 val y: Float,
53 val metaState: Int
54 )
55
56 enum class Direction {
57 UP,
58 DOWN,
59 LEFT,
60 RIGHT
61 }
62
toMotionEventnull63 fun MotionEventData.toMotionEvent(downTime: Long): MotionEvent =
64 MotionEvent.obtain(
65 downTime,
66 this.eventTimeDelta + downTime,
67 this.action,
68 this.x,
69 this.y,
70 this.metaState
71 )
72
73 /**
74 * Constructs a [FlingData] from a [Context] and [velocityPixelsPerSecond].
75 *
76 * [velocityPixelsPerSecond] must between [ViewConfiguration.getScaledMinimumFlingVelocity] * 1.1
77 * and [ViewConfiguration.getScaledMaximumFlingVelocity] * .9, inclusive. Losses of precision do not
78 * allow the simulated fling to be super precise.
79 */
80 @JvmOverloads
81 fun generateFlingData(context: Context, velocityPixelsPerSecond: Float? = null): FlingData {
82 val configuration = ViewConfiguration.get(context)
83 val touchSlop = configuration.scaledTouchSlop
84 val minimumVelocity = configuration.scaledMinimumFlingVelocity
85 val maximumVelocity = configuration.scaledMaximumFlingVelocity
86
87 val targetPixelsPerMilli =
88 if (velocityPixelsPerSecond != null) {
89 if (
90 velocityPixelsPerSecond < minimumVelocity * 1.1 - .001f ||
91 velocityPixelsPerSecond > maximumVelocity * .9 + .001f
92 ) {
93 throw IllegalArgumentException(
94 "velocityPixelsPerSecond must be between " +
95 "ViewConfiguration.scaledMinimumFlingVelocity * 1.1 and " +
96 "ViewConfiguration.scaledMinimumFlingVelocity * .9, inclusive"
97 )
98 }
99 velocityPixelsPerSecond / 1000f
100 } else {
101 ((maximumVelocity + minimumVelocity) / 2) / 1000f
102 }
103
104 val targetDistancePixels = touchSlop * 2
105 val targetMillisPassed = floor(targetDistancePixels / targetPixelsPerMilli).toInt()
106
107 if (targetMillisPassed < 1) {
108 throw IllegalArgumentException("Flings must require some time")
109 }
110
111 return FlingData(targetDistancePixels.toFloat(), targetMillisPassed)
112 }
113
114 /** Returns [value] rounded up to the closest [interval] * N, where N is a Integer. */
ceilToIntervalnull115 private fun ceilToInterval(value: Int, interval: Int): Int =
116 ceil(value.toFloat() / interval).toInt() * interval
117
118 /**
119 * Generates a [List] of [MotionEventData] starting from ([originX], [originY]) that will cause a
120 * fling in the finger direction [Direction].
121 */
122 fun FlingData.generateFlingMotionEventData(
123 originX: Float,
124 originY: Float,
125 fingerDirection: Direction
126 ): List<MotionEventData> {
127
128 // Ceiling the time and distance to match up with motion event intervals.
129 val time: Int = ceilToInterval(this.time, MOTION_EVENT_INTERVAL_MILLIS)
130 val distance: Float = velocity * time
131
132 val dx: Float =
133 when (fingerDirection) {
134 Direction.LEFT -> -distance
135 Direction.RIGHT -> distance
136 else -> 0f
137 }
138 val dy: Float =
139 when (fingerDirection) {
140 Direction.UP -> -distance
141 Direction.DOWN -> distance
142 else -> 0f
143 }
144 val toX = originX + dx
145 val toY = originY + dy
146
147 val numberOfInnerEvents = (time / MOTION_EVENT_INTERVAL_MILLIS) - 1
148 val dxIncrement = dx / (numberOfInnerEvents + 1)
149 val dyIncrement = dy / (numberOfInnerEvents + 1)
150
151 val motionEventData = ArrayList<MotionEventData>()
152 motionEventData.add(MotionEventData(0, MotionEvent.ACTION_DOWN, originX, originY, 0))
153 for (i in 1..(numberOfInnerEvents)) {
154 val timeDelta = i * MOTION_EVENT_INTERVAL_MILLIS
155 val x = originX + (i * dxIncrement)
156 val y = originY + (i * dyIncrement)
157 motionEventData.add(MotionEventData(timeDelta, MotionEvent.ACTION_MOVE, x, y, 0))
158 }
159 motionEventData.add(MotionEventData(time, MotionEvent.ACTION_MOVE, toX, toY, 0))
160 motionEventData.add(MotionEventData(time, MotionEvent.ACTION_UP, toX, toY, 0))
161
162 return motionEventData
163 }
164
165 /**
166 * Dispatches an array of [MotionEvent] to a [View].
167 *
168 * The MotionEvents will start at [downTime] and will be generated from the [motionEventData]. The
169 * MotionEvents will be dispatched synchronously, one after the other, with no gaps of time in
170 * between each [MotionEvent].
171 */
dispatchTouchEventsnull172 fun View.dispatchTouchEvents(downTime: Long, motionEventData: List<MotionEventData>) {
173 for (motionEventDataItem in motionEventData) {
174 dispatchTouchEvent(motionEventDataItem.toMotionEvent(downTime))
175 }
176 }
177
178 /**
179 * Simulates a fling on a [View].
180 *
181 * Convenience method that calls other public api. See documentation of those functions for more
182 * detail.
183 *
184 * @see [generateFlingData]
185 * @see [generateFlingMotionEventData]
186 * @see [dispatchTouchEvents]
187 */
188 @JvmOverloads
simulateFlingnull189 fun View.simulateFling(
190 downTime: Long,
191 originX: Float,
192 originY: Float,
193 direction: Direction,
194 velocityPixelsPerSecond: Float? = null
195 ) {
196 dispatchTouchEvents(
197 downTime,
198 generateFlingData(context, velocityPixelsPerSecond)
199 .generateFlingMotionEventData(originX, originY, direction)
200 )
201 }
202