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