blob: 812a1e4bc121c47b8c371bf6a37a80a375a80412 [file] [log] [blame]
Joshua Tsujicb9312f2020-02-13 03:22:56 -05001/*
2 * Copyright (C) 2020 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 */
16package com.android.systemui.util.magnetictarget
17
18import android.annotation.SuppressLint
19import android.content.Context
20import android.database.ContentObserver
21import android.graphics.PointF
22import android.os.Handler
23import android.os.UserHandle
24import android.os.VibrationEffect
25import android.os.Vibrator
26import android.provider.Settings
27import android.view.MotionEvent
28import android.view.VelocityTracker
29import android.view.View
30import androidx.dynamicanimation.animation.DynamicAnimation
31import androidx.dynamicanimation.animation.FloatPropertyCompat
32import androidx.dynamicanimation.animation.SpringForce
33import com.android.systemui.util.animation.PhysicsAnimator
34import kotlin.math.hypot
35
36/**
37 * Utility class for creating 'magnetized' objects that are attracted to one or more magnetic
38 * targets. Magnetic targets attract objects that are dragged near them, and hold them there unless
39 * they're moved away or released. Releasing objects inside a magnetic target typically performs an
40 * action on the object.
41 *
42 * MagnetizedObject also supports flinging to targets, which will result in the object being pulled
43 * into the target and released as if it was dragged into it.
44 *
45 * To use this class, either construct an instance with an object of arbitrary type, or use the
46 * [MagnetizedObject.magnetizeView] shortcut method if you're magnetizing a view. Then, set
47 * [magnetListener] to receive event callbacks. In your touch handler, pass all MotionEvents
48 * that move this object to [maybeConsumeMotionEvent]. If that method returns true, consider the
49 * event consumed by the MagnetizedObject and don't move the object unless it begins returning false
50 * again.
51 *
52 * @param context Context, used to retrieve a Vibrator instance for vibration effects.
53 * @param underlyingObject The actual object that we're magnetizing.
54 * @param xProperty Property that sets the x value of the object's position.
55 * @param yProperty Property that sets the y value of the object's position.
56 */
57abstract class MagnetizedObject<T : Any>(
58 val context: Context,
59
60 /** The actual object that is animated. */
61 val underlyingObject: T,
62
63 /** Property that gets/sets the object's X value. */
64 val xProperty: FloatPropertyCompat<in T>,
65
66 /** Property that gets/sets the object's Y value. */
67 val yProperty: FloatPropertyCompat<in T>
68) {
69
70 /** Return the width of the object. */
71 abstract fun getWidth(underlyingObject: T): Float
72
73 /** Return the height of the object. */
74 abstract fun getHeight(underlyingObject: T): Float
75
76 /**
77 * Fill the provided array with the location of the top-left of the object, relative to the
78 * entire screen. Compare to [View.getLocationOnScreen].
79 */
80 abstract fun getLocationOnScreen(underlyingObject: T, loc: IntArray)
81
82 /** Methods for listening to events involving a magnetized object. */
83 interface MagnetListener {
84
85 /**
86 * Called when touch events move within the magnetic field of a target, causing the
87 * object to animate to the target and become 'stuck' there. The animation happens
88 * automatically here - you should not move the object. You can, however, change its state
89 * to indicate to the user that it's inside the target and releasing it will have an effect.
90 *
91 * [maybeConsumeMotionEvent] is now returning true and will continue to do so until a call
92 * to [onUnstuckFromTarget] or [onReleasedInTarget].
93 *
94 * @param target The target that the object is now stuck to.
95 */
96 fun onStuckToTarget(target: MagneticTarget)
97
98 /**
99 * Called when the object is no longer stuck to a target. This means that either touch
100 * events moved outside of the magnetic field radius, or that a forceful fling out of the
101 * target was detected.
102 *
103 * The object won't be automatically animated out of the target, since you're responsible
104 * for moving the object again. You should move it (or animate it) using your own
105 * movement/animation logic.
106 *
107 * Reverse any effects applied in [onStuckToTarget] here.
108 *
109 * If [wasFlungOut] is true, [maybeConsumeMotionEvent] returned true for the ACTION_UP event
110 * that concluded the fling. If [wasFlungOut] is false, that means a drag gesture is ongoing
111 * and [maybeConsumeMotionEvent] is now returning false.
112 *
113 * @param target The target that this object was just unstuck from.
114 * @param velX The X velocity of the touch gesture when it exited the magnetic field.
115 * @param velY The Y velocity of the touch gesture when it exited the magnetic field.
116 * @param wasFlungOut Whether the object was unstuck via a fling gesture. This means that
117 * an ACTION_UP event was received, and that the gesture velocity was sufficient to conclude
118 * that the user wants to un-stick the object despite no touch events occurring outside of
119 * the magnetic field radius.
120 */
121 fun onUnstuckFromTarget(
122 target: MagneticTarget,
123 velX: Float,
124 velY: Float,
125 wasFlungOut: Boolean
126 )
127
128 /**
129 * Called when the object is released inside a target, or flung towards it with enough
130 * velocity to reach it.
131 *
132 * @param target The target that the object was released in.
133 */
134 fun onReleasedInTarget(target: MagneticTarget)
135 }
136
137 private val animator: PhysicsAnimator<T> = PhysicsAnimator.getInstance(underlyingObject)
138 private val objectLocationOnScreen = IntArray(2)
139
140 /**
141 * Targets that have been added to this object. These will all be considered when determining
142 * magnetic fields and fling trajectories.
143 */
144 private val associatedTargets = ArrayList<MagneticTarget>()
145
146 private val velocityTracker: VelocityTracker = VelocityTracker.obtain()
147 private val vibrator: Vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
148
149 /** Whether touch events are presently occurring within the magnetic field area of a target. */
150 val objectStuckToTarget: Boolean
151 get() = targetObjectIsStuckTo != null
152
153 /** The target the object is stuck to, or null if the object is not stuck to any target. */
154 private var targetObjectIsStuckTo: MagneticTarget? = null
155
156 /**
157 * Sets the listener to receive events. This must be set, or [maybeConsumeMotionEvent]
158 * will always return false and no magnetic effects will occur.
159 */
160 lateinit var magnetListener: MagnetizedObject.MagnetListener
161
162 /**
163 * Sets whether forcefully flinging the object vertically towards a target causes it to be
164 * attracted to the target and then released immediately, despite never being dragged within the
165 * magnetic field.
166 */
167 var flingToTargetEnabled = true
168
169 /**
170 * If fling to target is enabled, forcefully flinging the object towards a target will cause
171 * it to be attracted to the target and then released immediately, despite never being dragged
172 * within the magnetic field.
173 *
174 * This sets the width of the area considered 'near' enough a target to be considered a fling,
175 * in terms of percent of the target view's width. For example, setting this to 3f means that
176 * flings towards a 100px-wide target will be considered 'near' enough if they're towards the
177 * 300px-wide area around the target.
178 *
179 * Flings whose trajectory intersects the area will be attracted and released - even if the
180 * target view itself isn't intersected:
181 *
182 * | |
183 * | 0 |
184 * | / |
185 * | / |
186 * | X / |
187 * |.....###.....|
188 *
189 *
190 * Flings towards the target whose trajectories do not intersect the area will be treated as
191 * normal flings and the magnet will leave the object alone:
192 *
193 * | |
194 * | |
195 * | 0 |
196 * | / |
197 * | / X |
198 * |.....###.....|
199 *
200 */
201 var flingToTargetWidthPercent = 3f
202
203 /**
204 * Sets the minimum velocity (in pixels per second) required to fling an object to the target
205 * without dragging it into the magnetic field.
206 */
207 var flingToTargetMinVelocity = 4000f
208
209 /**
210 * Sets the minimum velocity (in pixels per second) required to fling un-stuck an object stuck
211 * to the target. If this velocity is reached, the object will be freed even if it wasn't moved
212 * outside the magnetic field radius.
213 */
214 var flingUnstuckFromTargetMinVelocity = 1000f
215
216 /**
217 * Sets the maximum velocity above which the object will not stick to the target. Even if the
218 * object is dragged through the magnetic field, it will not stick to the target until the
219 * velocity is below this value.
220 */
221 var stickToTargetMaxVelocity = 2000f
222
223 /**
224 * Enable or disable haptic vibration effects when the object interacts with the magnetic field.
225 *
226 * If you're experiencing crashes when the object enters targets, ensure that you have the
227 * android.permission.VIBRATE permission!
228 */
229 var hapticsEnabled = true
230
231 /** Whether the HAPTIC_FEEDBACK_ENABLED setting is true. */
232 private var systemHapticsEnabled = false
233
234 /** Default spring configuration to use for animating the object into a target. */
235 var springConfig = PhysicsAnimator.SpringConfig(
236 SpringForce.STIFFNESS_MEDIUM, SpringForce.DAMPING_RATIO_NO_BOUNCY)
237
238 /**
239 * Spring configuration to use to spring the object into a target specifically when it's flung
240 * towards (rather than dragged near) it.
241 */
242 var flungIntoTargetSpringConfig = springConfig
243
244 init {
245 val hapticSettingObserver =
246 object : ContentObserver(Handler.getMain()) {
247 override fun onChange(selfChange: Boolean) {
248 systemHapticsEnabled =
249 Settings.System.getIntForUser(
250 context.contentResolver,
251 Settings.System.HAPTIC_FEEDBACK_ENABLED,
252 0,
253 UserHandle.USER_CURRENT) != 0
254 }
255 }
256
257 context.contentResolver.registerContentObserver(
258 Settings.System.getUriFor(Settings.System.HAPTIC_FEEDBACK_ENABLED),
259 true /* notifyForDescendants */, hapticSettingObserver)
260
261 // Trigger the observer once to initialize systemHapticsEnabled.
262 hapticSettingObserver.onChange(false /* selfChange */)
263 }
264
265 /**
266 * Adds the provided MagneticTarget to this object. The object will now be attracted to the
267 * target if it strays within its magnetic field or is flung towards it.
268 *
269 * If this target (or its magnetic field) overlaps another target added to this object, the
270 * prior target will take priority.
271 */
272 fun addTarget(target: MagneticTarget) {
273 associatedTargets.add(target)
274 target.updateLocationOnScreen()
275 }
276
277 /**
278 * Shortcut that accepts a View and a magnetic field radius and adds it as a magnetic target.
279 *
280 * @return The MagneticTarget instance for the given View. This can be used to change the
281 * target's magnetic field radius after it's been added. It can also be added to other
282 * magnetized objects.
283 */
284 fun addTarget(target: View, magneticFieldRadiusPx: Int): MagneticTarget {
285 return MagneticTarget(target, magneticFieldRadiusPx).also { addTarget(it) }
286 }
287
288 /**
289 * Removes the given target from this object. The target will no longer attract the object.
290 */
291 fun removeTarget(target: MagneticTarget) {
292 associatedTargets.remove(target)
293 }
294
295 /**
296 * Provide this method with all motion events that move the magnetized object. If the
297 * location of the motion events moves within the magnetic field of a target, or indicate a
298 * fling-to-target gesture, this method will return true and you should not move the object
299 * yourself until it returns false again.
300 *
301 * Note that even when this method returns true, you should continue to pass along new motion
302 * events so that we know when the events move back outside the magnetic field area.
303 *
304 * This method will always return false if you haven't set a [magnetListener].
305 */
306 fun maybeConsumeMotionEvent(ev: MotionEvent): Boolean {
307 // Short-circuit if we don't have a listener or any targets, since those are required.
308 if (associatedTargets.size == 0) {
309 return false
310 }
311
312 // When a gesture begins, recalculate target views' positions on the screen in case they
313 // have changed. Also, clear state.
314 if (ev.action == MotionEvent.ACTION_DOWN) {
315 updateTargetViewLocations()
316
317 // Clear the velocity tracker and assume we're not stuck to a target yet.
318 velocityTracker.clear()
319 targetObjectIsStuckTo = null
320 }
321
322 addMovement(ev)
323
324 val targetObjectIsInMagneticFieldOf = associatedTargets.firstOrNull { target ->
325 val distanceFromTargetCenter = hypot(
326 ev.rawX - target.centerOnScreen.x,
327 ev.rawY - target.centerOnScreen.y)
328 distanceFromTargetCenter < target.magneticFieldRadiusPx
329 }
330
331 // If we aren't currently stuck to a target, and we're in the magnetic field of a target,
332 // we're newly stuck.
333 val objectNewlyStuckToTarget =
334 !objectStuckToTarget && targetObjectIsInMagneticFieldOf != null
335
336 // If we are currently stuck to a target, we're in the magnetic field of a target, and that
337 // target isn't the one we're currently stuck to, then touch events have moved into a
338 // adjacent target's magnetic field.
339 val objectMovedIntoDifferentTarget =
340 objectStuckToTarget &&
341 targetObjectIsInMagneticFieldOf != null &&
342 targetObjectIsStuckTo != targetObjectIsInMagneticFieldOf
343
344 if (objectNewlyStuckToTarget || objectMovedIntoDifferentTarget) {
345 velocityTracker.computeCurrentVelocity(1000)
346 val velX = velocityTracker.xVelocity
347 val velY = velocityTracker.yVelocity
348
349 // If the object is moving too quickly within the magnetic field, do not stick it. This
350 // only applies to objects newly stuck to a target. If the object is moved into a new
351 // target, it wasn't moving at all (since it was stuck to the previous one).
352 if (objectNewlyStuckToTarget && hypot(velX, velY) > stickToTargetMaxVelocity) {
353 return false
354 }
355
356 // This touch event is newly within the magnetic field - let the listener know, and
357 // animate sticking to the magnet.
358 targetObjectIsStuckTo = targetObjectIsInMagneticFieldOf
359 cancelAnimations()
360 magnetListener.onStuckToTarget(targetObjectIsInMagneticFieldOf!!)
Joshua Tsuji2091c402020-03-09 13:51:31 -0400361 animateStuckToTarget(targetObjectIsInMagneticFieldOf, velX, velY, false)
Joshua Tsujicb9312f2020-02-13 03:22:56 -0500362
363 vibrateIfEnabled(VibrationEffect.EFFECT_HEAVY_CLICK)
364 } else if (targetObjectIsInMagneticFieldOf == null && objectStuckToTarget) {
365 velocityTracker.computeCurrentVelocity(1000)
366
367 // This touch event is newly outside the magnetic field - let the listener know. It will
368 // move the object out of the target using its own movement logic.
369 cancelAnimations()
370 magnetListener.onUnstuckFromTarget(
371 targetObjectIsStuckTo!!, velocityTracker.xVelocity, velocityTracker.yVelocity,
372 wasFlungOut = false)
373 targetObjectIsStuckTo = null
374
375 vibrateIfEnabled(VibrationEffect.EFFECT_TICK)
376 }
377
378 // First, check for relevant gestures concluding with an ACTION_UP.
379 if (ev.action == MotionEvent.ACTION_UP) {
380
381 velocityTracker.computeCurrentVelocity(1000 /* units */)
382 val velX = velocityTracker.xVelocity
383 val velY = velocityTracker.yVelocity
384
385 // Cancel the magnetic animation since we might still be springing into the magnetic
386 // target, but we're about to fling away or release.
387 cancelAnimations()
388
389 if (objectStuckToTarget) {
390 if (hypot(velX, velY) > flingUnstuckFromTargetMinVelocity) {
391 // If the object is stuck, but it was forcefully flung away from the target,
392 // tell the listener so the object can be animated out of the target.
393 magnetListener.onUnstuckFromTarget(
394 targetObjectIsStuckTo!!, velX, velY, wasFlungOut = true)
395 } else {
396 // If the object is stuck and not flung away, it was released inside the target.
397 magnetListener.onReleasedInTarget(targetObjectIsStuckTo!!)
398 vibrateIfEnabled(VibrationEffect.EFFECT_HEAVY_CLICK)
399 }
400
401 // Either way, we're no longer stuck.
402 targetObjectIsStuckTo = null
403 return true
404 }
405
406 // The target we're flinging towards, or null if we're not flinging towards any target.
407 val flungToTarget = associatedTargets.firstOrNull { target ->
408 isForcefulFlingTowardsTarget(target, ev.rawX, ev.rawY, velX, velY)
409 }
410
411 if (flungToTarget != null) {
412 // If this is a fling-to-target, animate the object to the magnet and then release
413 // it.
414 magnetListener.onStuckToTarget(flungToTarget)
415 targetObjectIsStuckTo = flungToTarget
416
417 animateStuckToTarget(flungToTarget, velX, velY, true) {
418 targetObjectIsStuckTo = null
419 magnetListener.onReleasedInTarget(flungToTarget)
420 vibrateIfEnabled(VibrationEffect.EFFECT_HEAVY_CLICK)
421 }
422
423 return true
424 }
425
426 // If it's not either of those things, we are not interested.
427 return false
428 }
429
430 return objectStuckToTarget // Always consume touch events if the object is stuck.
431 }
432
433 /** Plays the given vibration effect if haptics are enabled. */
434 @SuppressLint("MissingPermission")
435 private fun vibrateIfEnabled(effect: Int) {
436 if (hapticsEnabled && systemHapticsEnabled) {
437 vibrator.vibrate(effect.toLong())
438 }
439 }
440
441 /** Adds the movement to the velocity tracker using raw coordinates. */
442 private fun addMovement(event: MotionEvent) {
443 // Add movement to velocity tracker using raw screen X and Y coordinates instead
444 // of window coordinates because the window frame may be moving at the same time.
445 val deltaX = event.rawX - event.x
446 val deltaY = event.rawY - event.y
447 event.offsetLocation(deltaX, deltaY)
448 velocityTracker.addMovement(event)
449 event.offsetLocation(-deltaX, -deltaY)
450 }
451
452 /** Animates sticking the object to the provided target with the given start velocities. */
453 private fun animateStuckToTarget(
454 target: MagneticTarget,
455 velX: Float,
456 velY: Float,
457 flung: Boolean,
458 after: (() -> Unit)? = null
459 ) {
460 target.updateLocationOnScreen()
461 getLocationOnScreen(underlyingObject, objectLocationOnScreen)
462
463 // Calculate the difference between the target's center coordinates and the object's.
464 // Animating the object's x/y properties by these values will center the object on top
465 // of the magnetic target.
466 val xDiff = target.centerOnScreen.x -
467 getWidth(underlyingObject) / 2f - objectLocationOnScreen[0]
468 val yDiff = target.centerOnScreen.y -
469 getHeight(underlyingObject) / 2f - objectLocationOnScreen[1]
470
471 val springConfig = if (flung) flungIntoTargetSpringConfig else springConfig
472
473 cancelAnimations()
474
475 // Animate to the center of the target.
476 animator
477 .spring(xProperty, xProperty.getValue(underlyingObject) + xDiff, velX,
478 springConfig)
479 .spring(yProperty, yProperty.getValue(underlyingObject) + yDiff, velY,
480 springConfig)
481
482 if (after != null) {
483 animator.withEndActions(after)
484 }
485
486 animator.start()
487 }
488
489 /**
490 * Whether or not the provided values match a 'fast fling' towards the provided target. If it
491 * does, we consider it a fling-to-target gesture.
492 */
493 private fun isForcefulFlingTowardsTarget(
494 target: MagneticTarget,
495 rawX: Float,
496 rawY: Float,
497 velX: Float,
498 velY: Float
499 ): Boolean {
500 if (!flingToTargetEnabled) {
501 return false
502 }
503
504 // Whether velocity is sufficient, depending on whether we're flinging into a target at the
505 // top or the bottom of the screen.
506 val velocitySufficient =
507 if (rawY < target.centerOnScreen.y) velY > flingToTargetMinVelocity
508 else velY < flingToTargetMinVelocity
509
510 if (!velocitySufficient) {
511 return false
512 }
513
514 // Whether the trajectory of the fling intersects the target area.
515 var targetCenterXIntercept = rawX
516
517 // Only do math if the X velocity is non-zero, otherwise X won't change.
518 if (velX != 0f) {
519 // Rise over run...
520 val slope = velY / velX
521 // ...y = mx + b, b = y / mx...
522 val yIntercept = rawY - slope * rawX
523
524 // ...calculate the x value when y = the target's y-coordinate.
525 targetCenterXIntercept = (target.centerOnScreen.y - yIntercept) / slope
526 }
527
528 // The width of the area we're looking for a fling towards.
529 val targetAreaWidth = target.targetView.width * flingToTargetWidthPercent
530
531 // Velocity was sufficient, so return true if the intercept is within the target area.
532 return targetCenterXIntercept > target.centerOnScreen.x - targetAreaWidth / 2 &&
533 targetCenterXIntercept < target.centerOnScreen.x + targetAreaWidth / 2
534 }
535
536 /** Cancel animations on this object's x/y properties. */
537 internal fun cancelAnimations() {
538 animator.cancel(xProperty, yProperty)
539 }
540
541 /** Updates the locations on screen of all of the [associatedTargets]. */
542 internal fun updateTargetViewLocations() {
543 associatedTargets.forEach { it.updateLocationOnScreen() }
544 }
545
546 /**
547 * Represents a target view with a magnetic field radius and cached center-on-screen
548 * coordinates.
549 *
550 * Instances of MagneticTarget are passed to a MagnetizedObject's [addTarget], and can then
551 * attract the object if it's dragged near or flung towards it. MagneticTargets can be added to
552 * multiple objects.
553 */
554 class MagneticTarget(
555 internal val targetView: View,
556 var magneticFieldRadiusPx: Int
557 ) {
558 internal val centerOnScreen = PointF()
559
560 private val tempLoc = IntArray(2)
561
562 fun updateLocationOnScreen() {
563 targetView.getLocationOnScreen(tempLoc)
564
565 // Add half of the target size to get the center, and subtract translation since the
566 // target could be animating in while we're doing this calculation.
567 centerOnScreen.set(
568 tempLoc[0] + targetView.width / 2f - targetView.translationX,
569 tempLoc[1] + targetView.height / 2f - targetView.translationY)
570 }
571 }
572
573 companion object {
574
575 /**
576 * Magnetizes the given view. Magnetized views are attracted to one or more magnetic
577 * targets. Magnetic targets attract objects that are dragged near them, and hold them there
578 * unless they're moved away or released. Releasing objects inside a magnetic target
579 * typically performs an action on the object.
580 *
581 * Magnetized views can also be flung to targets, which will result in the view being pulled
582 * into the target and released as if it was dragged into it.
583 *
584 * To use the returned MagnetizedObject<View> instance, first set [magnetListener] to
585 * receive event callbacks. In your touch handler, pass all MotionEvents that move this view
586 * to [maybeConsumeMotionEvent]. If that method returns true, consider the event consumed by
587 * MagnetizedObject and don't move the view unless it begins returning false again.
588 *
589 * The view will be moved via translationX/Y properties, and its
590 * width/height will be determined via getWidth()/getHeight(). If you are animating
591 * something other than a view, or want to position your view using properties other than
592 * translationX/Y, implement an instance of [MagnetizedObject].
593 *
594 * Note that the magnetic library can't re-order your view automatically. If the view
595 * renders on top of the target views, it will obscure the target when it sticks to it.
596 * You'll want to bring the view to the front in [MagnetListener.onStuckToTarget].
597 */
598 @JvmStatic
599 fun <T : View> magnetizeView(view: T): MagnetizedObject<T> {
600 return object : MagnetizedObject<T>(
601 view.context,
602 view,
603 DynamicAnimation.TRANSLATION_X,
604 DynamicAnimation.TRANSLATION_Y) {
605 override fun getWidth(underlyingObject: T): Float {
606 return underlyingObject.width.toFloat()
607 }
608
609 override fun getHeight(underlyingObject: T): Float {
610 return underlyingObject.height.toFloat() }
611
612 override fun getLocationOnScreen(underlyingObject: T, loc: IntArray) {
613 underlyingObject.getLocationOnScreen(loc)
614 }
615 }
616 }
617 }
618}