From cb9312f82f57e3004d8455326af7f24c06f3cbcf Mon Sep 17 00:00:00 2001 From: Joshua Tsuji Date: Thu, 13 Feb 2020 03:22:56 -0500 Subject: [PATCH] Magnetic target library! This adds the core library and tests. See design doc at go/magnetic-target-design. Upcoming CLs add animations for the target enveloping the object, and support for horizontally flinging towards targets (it currently assumes top/bottom targets). Test: atest SystemUITests Bug: 138116784 Change-Id: Icf5b3bc0359e53c137718738e6b800b7bca04770 --- .../util/magnetictarget/MagnetizedObject.kt | 618 ++++++++++++++++++ .../magnetictarget/MagnetizedObjectTest.kt | 442 +++++++++++++ 2 files changed, 1060 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/util/magnetictarget/MagnetizedObject.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/util/magnetictarget/MagnetizedObjectTest.kt diff --git a/packages/SystemUI/src/com/android/systemui/util/magnetictarget/MagnetizedObject.kt b/packages/SystemUI/src/com/android/systemui/util/magnetictarget/MagnetizedObject.kt new file mode 100644 index 0000000000000..2276ba19e432f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/util/magnetictarget/MagnetizedObject.kt @@ -0,0 +1,618 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.systemui.util.magnetictarget + +import android.annotation.SuppressLint +import android.content.Context +import android.database.ContentObserver +import android.graphics.PointF +import android.os.Handler +import android.os.UserHandle +import android.os.VibrationEffect +import android.os.Vibrator +import android.provider.Settings +import android.view.MotionEvent +import android.view.VelocityTracker +import android.view.View +import androidx.dynamicanimation.animation.DynamicAnimation +import androidx.dynamicanimation.animation.FloatPropertyCompat +import androidx.dynamicanimation.animation.SpringForce +import com.android.systemui.util.animation.PhysicsAnimator +import kotlin.math.hypot + +/** + * Utility class for creating 'magnetized' objects that are attracted to one or more magnetic + * targets. Magnetic targets attract objects that are dragged near them, and hold them there unless + * they're moved away or released. Releasing objects inside a magnetic target typically performs an + * action on the object. + * + * MagnetizedObject also supports flinging to targets, which will result in the object being pulled + * into the target and released as if it was dragged into it. + * + * To use this class, either construct an instance with an object of arbitrary type, or use the + * [MagnetizedObject.magnetizeView] shortcut method if you're magnetizing a view. Then, set + * [magnetListener] to receive event callbacks. In your touch handler, pass all MotionEvents + * that move this object to [maybeConsumeMotionEvent]. If that method returns true, consider the + * event consumed by the MagnetizedObject and don't move the object unless it begins returning false + * again. + * + * @param context Context, used to retrieve a Vibrator instance for vibration effects. + * @param underlyingObject The actual object that we're magnetizing. + * @param xProperty Property that sets the x value of the object's position. + * @param yProperty Property that sets the y value of the object's position. + */ +abstract class MagnetizedObject( + val context: Context, + + /** The actual object that is animated. */ + val underlyingObject: T, + + /** Property that gets/sets the object's X value. */ + val xProperty: FloatPropertyCompat, + + /** Property that gets/sets the object's Y value. */ + val yProperty: FloatPropertyCompat +) { + + /** Return the width of the object. */ + abstract fun getWidth(underlyingObject: T): Float + + /** Return the height of the object. */ + abstract fun getHeight(underlyingObject: T): Float + + /** + * Fill the provided array with the location of the top-left of the object, relative to the + * entire screen. Compare to [View.getLocationOnScreen]. + */ + abstract fun getLocationOnScreen(underlyingObject: T, loc: IntArray) + + /** Methods for listening to events involving a magnetized object. */ + interface MagnetListener { + + /** + * Called when touch events move within the magnetic field of a target, causing the + * object to animate to the target and become 'stuck' there. The animation happens + * automatically here - you should not move the object. You can, however, change its state + * to indicate to the user that it's inside the target and releasing it will have an effect. + * + * [maybeConsumeMotionEvent] is now returning true and will continue to do so until a call + * to [onUnstuckFromTarget] or [onReleasedInTarget]. + * + * @param target The target that the object is now stuck to. + */ + fun onStuckToTarget(target: MagneticTarget) + + /** + * Called when the object is no longer stuck to a target. This means that either touch + * events moved outside of the magnetic field radius, or that a forceful fling out of the + * target was detected. + * + * The object won't be automatically animated out of the target, since you're responsible + * for moving the object again. You should move it (or animate it) using your own + * movement/animation logic. + * + * Reverse any effects applied in [onStuckToTarget] here. + * + * If [wasFlungOut] is true, [maybeConsumeMotionEvent] returned true for the ACTION_UP event + * that concluded the fling. If [wasFlungOut] is false, that means a drag gesture is ongoing + * and [maybeConsumeMotionEvent] is now returning false. + * + * @param target The target that this object was just unstuck from. + * @param velX The X velocity of the touch gesture when it exited the magnetic field. + * @param velY The Y velocity of the touch gesture when it exited the magnetic field. + * @param wasFlungOut Whether the object was unstuck via a fling gesture. This means that + * an ACTION_UP event was received, and that the gesture velocity was sufficient to conclude + * that the user wants to un-stick the object despite no touch events occurring outside of + * the magnetic field radius. + */ + fun onUnstuckFromTarget( + target: MagneticTarget, + velX: Float, + velY: Float, + wasFlungOut: Boolean + ) + + /** + * Called when the object is released inside a target, or flung towards it with enough + * velocity to reach it. + * + * @param target The target that the object was released in. + */ + fun onReleasedInTarget(target: MagneticTarget) + } + + private val animator: PhysicsAnimator = PhysicsAnimator.getInstance(underlyingObject) + private val objectLocationOnScreen = IntArray(2) + + /** + * Targets that have been added to this object. These will all be considered when determining + * magnetic fields and fling trajectories. + */ + private val associatedTargets = ArrayList() + + private val velocityTracker: VelocityTracker = VelocityTracker.obtain() + private val vibrator: Vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + + /** Whether touch events are presently occurring within the magnetic field area of a target. */ + val objectStuckToTarget: Boolean + get() = targetObjectIsStuckTo != null + + /** The target the object is stuck to, or null if the object is not stuck to any target. */ + private var targetObjectIsStuckTo: MagneticTarget? = null + + /** + * Sets the listener to receive events. This must be set, or [maybeConsumeMotionEvent] + * will always return false and no magnetic effects will occur. + */ + lateinit var magnetListener: MagnetizedObject.MagnetListener + + /** + * Sets whether forcefully flinging the object vertically towards a target causes it to be + * attracted to the target and then released immediately, despite never being dragged within the + * magnetic field. + */ + var flingToTargetEnabled = true + + /** + * If fling to target is enabled, forcefully flinging the object towards a target will cause + * it to be attracted to the target and then released immediately, despite never being dragged + * within the magnetic field. + * + * This sets the width of the area considered 'near' enough a target to be considered a fling, + * in terms of percent of the target view's width. For example, setting this to 3f means that + * flings towards a 100px-wide target will be considered 'near' enough if they're towards the + * 300px-wide area around the target. + * + * Flings whose trajectory intersects the area will be attracted and released - even if the + * target view itself isn't intersected: + * + * | | + * | 0 | + * | / | + * | / | + * | X / | + * |.....###.....| + * + * + * Flings towards the target whose trajectories do not intersect the area will be treated as + * normal flings and the magnet will leave the object alone: + * + * | | + * | | + * | 0 | + * | / | + * | / X | + * |.....###.....| + * + */ + var flingToTargetWidthPercent = 3f + + /** + * Sets the minimum velocity (in pixels per second) required to fling an object to the target + * without dragging it into the magnetic field. + */ + var flingToTargetMinVelocity = 4000f + + /** + * Sets the minimum velocity (in pixels per second) required to fling un-stuck an object stuck + * to the target. If this velocity is reached, the object will be freed even if it wasn't moved + * outside the magnetic field radius. + */ + var flingUnstuckFromTargetMinVelocity = 1000f + + /** + * Sets the maximum velocity above which the object will not stick to the target. Even if the + * object is dragged through the magnetic field, it will not stick to the target until the + * velocity is below this value. + */ + var stickToTargetMaxVelocity = 2000f + + /** + * Enable or disable haptic vibration effects when the object interacts with the magnetic field. + * + * If you're experiencing crashes when the object enters targets, ensure that you have the + * android.permission.VIBRATE permission! + */ + var hapticsEnabled = true + + /** Whether the HAPTIC_FEEDBACK_ENABLED setting is true. */ + private var systemHapticsEnabled = false + + /** Default spring configuration to use for animating the object into a target. */ + var springConfig = PhysicsAnimator.SpringConfig( + SpringForce.STIFFNESS_MEDIUM, SpringForce.DAMPING_RATIO_NO_BOUNCY) + + /** + * Spring configuration to use to spring the object into a target specifically when it's flung + * towards (rather than dragged near) it. + */ + var flungIntoTargetSpringConfig = springConfig + + init { + val hapticSettingObserver = + object : ContentObserver(Handler.getMain()) { + override fun onChange(selfChange: Boolean) { + systemHapticsEnabled = + Settings.System.getIntForUser( + context.contentResolver, + Settings.System.HAPTIC_FEEDBACK_ENABLED, + 0, + UserHandle.USER_CURRENT) != 0 + } + } + + context.contentResolver.registerContentObserver( + Settings.System.getUriFor(Settings.System.HAPTIC_FEEDBACK_ENABLED), + true /* notifyForDescendants */, hapticSettingObserver) + + // Trigger the observer once to initialize systemHapticsEnabled. + hapticSettingObserver.onChange(false /* selfChange */) + } + + /** + * Adds the provided MagneticTarget to this object. The object will now be attracted to the + * target if it strays within its magnetic field or is flung towards it. + * + * If this target (or its magnetic field) overlaps another target added to this object, the + * prior target will take priority. + */ + fun addTarget(target: MagneticTarget) { + associatedTargets.add(target) + target.updateLocationOnScreen() + } + + /** + * Shortcut that accepts a View and a magnetic field radius and adds it as a magnetic target. + * + * @return The MagneticTarget instance for the given View. This can be used to change the + * target's magnetic field radius after it's been added. It can also be added to other + * magnetized objects. + */ + fun addTarget(target: View, magneticFieldRadiusPx: Int): MagneticTarget { + return MagneticTarget(target, magneticFieldRadiusPx).also { addTarget(it) } + } + + /** + * Removes the given target from this object. The target will no longer attract the object. + */ + fun removeTarget(target: MagneticTarget) { + associatedTargets.remove(target) + } + + /** + * Provide this method with all motion events that move the magnetized object. If the + * location of the motion events moves within the magnetic field of a target, or indicate a + * fling-to-target gesture, this method will return true and you should not move the object + * yourself until it returns false again. + * + * Note that even when this method returns true, you should continue to pass along new motion + * events so that we know when the events move back outside the magnetic field area. + * + * This method will always return false if you haven't set a [magnetListener]. + */ + fun maybeConsumeMotionEvent(ev: MotionEvent): Boolean { + // Short-circuit if we don't have a listener or any targets, since those are required. + if (associatedTargets.size == 0) { + return false + } + + // When a gesture begins, recalculate target views' positions on the screen in case they + // have changed. Also, clear state. + if (ev.action == MotionEvent.ACTION_DOWN) { + updateTargetViewLocations() + + // Clear the velocity tracker and assume we're not stuck to a target yet. + velocityTracker.clear() + targetObjectIsStuckTo = null + } + + addMovement(ev) + + val targetObjectIsInMagneticFieldOf = associatedTargets.firstOrNull { target -> + val distanceFromTargetCenter = hypot( + ev.rawX - target.centerOnScreen.x, + ev.rawY - target.centerOnScreen.y) + distanceFromTargetCenter < target.magneticFieldRadiusPx + } + + // If we aren't currently stuck to a target, and we're in the magnetic field of a target, + // we're newly stuck. + val objectNewlyStuckToTarget = + !objectStuckToTarget && targetObjectIsInMagneticFieldOf != null + + // If we are currently stuck to a target, we're in the magnetic field of a target, and that + // target isn't the one we're currently stuck to, then touch events have moved into a + // adjacent target's magnetic field. + val objectMovedIntoDifferentTarget = + objectStuckToTarget && + targetObjectIsInMagneticFieldOf != null && + targetObjectIsStuckTo != targetObjectIsInMagneticFieldOf + + if (objectNewlyStuckToTarget || objectMovedIntoDifferentTarget) { + velocityTracker.computeCurrentVelocity(1000) + val velX = velocityTracker.xVelocity + val velY = velocityTracker.yVelocity + + // If the object is moving too quickly within the magnetic field, do not stick it. This + // only applies to objects newly stuck to a target. If the object is moved into a new + // target, it wasn't moving at all (since it was stuck to the previous one). + if (objectNewlyStuckToTarget && hypot(velX, velY) > stickToTargetMaxVelocity) { + return false + } + + // This touch event is newly within the magnetic field - let the listener know, and + // animate sticking to the magnet. + targetObjectIsStuckTo = targetObjectIsInMagneticFieldOf + cancelAnimations() + magnetListener.onStuckToTarget(targetObjectIsInMagneticFieldOf!!) + animateStuckToTarget(targetObjectIsInMagneticFieldOf!!, velX, velY, false) + + vibrateIfEnabled(VibrationEffect.EFFECT_HEAVY_CLICK) + } else if (targetObjectIsInMagneticFieldOf == null && objectStuckToTarget) { + velocityTracker.computeCurrentVelocity(1000) + + // This touch event is newly outside the magnetic field - let the listener know. It will + // move the object out of the target using its own movement logic. + cancelAnimations() + magnetListener.onUnstuckFromTarget( + targetObjectIsStuckTo!!, velocityTracker.xVelocity, velocityTracker.yVelocity, + wasFlungOut = false) + targetObjectIsStuckTo = null + + vibrateIfEnabled(VibrationEffect.EFFECT_TICK) + } + + // First, check for relevant gestures concluding with an ACTION_UP. + if (ev.action == MotionEvent.ACTION_UP) { + + velocityTracker.computeCurrentVelocity(1000 /* units */) + val velX = velocityTracker.xVelocity + val velY = velocityTracker.yVelocity + + // Cancel the magnetic animation since we might still be springing into the magnetic + // target, but we're about to fling away or release. + cancelAnimations() + + if (objectStuckToTarget) { + if (hypot(velX, velY) > flingUnstuckFromTargetMinVelocity) { + // If the object is stuck, but it was forcefully flung away from the target, + // tell the listener so the object can be animated out of the target. + magnetListener.onUnstuckFromTarget( + targetObjectIsStuckTo!!, velX, velY, wasFlungOut = true) + } else { + // If the object is stuck and not flung away, it was released inside the target. + magnetListener.onReleasedInTarget(targetObjectIsStuckTo!!) + vibrateIfEnabled(VibrationEffect.EFFECT_HEAVY_CLICK) + } + + // Either way, we're no longer stuck. + targetObjectIsStuckTo = null + return true + } + + // The target we're flinging towards, or null if we're not flinging towards any target. + val flungToTarget = associatedTargets.firstOrNull { target -> + isForcefulFlingTowardsTarget(target, ev.rawX, ev.rawY, velX, velY) + } + + if (flungToTarget != null) { + // If this is a fling-to-target, animate the object to the magnet and then release + // it. + magnetListener.onStuckToTarget(flungToTarget) + targetObjectIsStuckTo = flungToTarget + + animateStuckToTarget(flungToTarget, velX, velY, true) { + targetObjectIsStuckTo = null + magnetListener.onReleasedInTarget(flungToTarget) + vibrateIfEnabled(VibrationEffect.EFFECT_HEAVY_CLICK) + } + + return true + } + + // If it's not either of those things, we are not interested. + return false + } + + return objectStuckToTarget // Always consume touch events if the object is stuck. + } + + /** Plays the given vibration effect if haptics are enabled. */ + @SuppressLint("MissingPermission") + private fun vibrateIfEnabled(effect: Int) { + if (hapticsEnabled && systemHapticsEnabled) { + vibrator.vibrate(effect.toLong()) + } + } + + /** Adds the movement to the velocity tracker using raw coordinates. */ + private fun addMovement(event: MotionEvent) { + // Add movement to velocity tracker using raw screen X and Y coordinates instead + // of window coordinates because the window frame may be moving at the same time. + val deltaX = event.rawX - event.x + val deltaY = event.rawY - event.y + event.offsetLocation(deltaX, deltaY) + velocityTracker.addMovement(event) + event.offsetLocation(-deltaX, -deltaY) + } + + /** Animates sticking the object to the provided target with the given start velocities. */ + private fun animateStuckToTarget( + target: MagneticTarget, + velX: Float, + velY: Float, + flung: Boolean, + after: (() -> Unit)? = null + ) { + target.updateLocationOnScreen() + getLocationOnScreen(underlyingObject, objectLocationOnScreen) + + // Calculate the difference between the target's center coordinates and the object's. + // Animating the object's x/y properties by these values will center the object on top + // of the magnetic target. + val xDiff = target.centerOnScreen.x - + getWidth(underlyingObject) / 2f - objectLocationOnScreen[0] + val yDiff = target.centerOnScreen.y - + getHeight(underlyingObject) / 2f - objectLocationOnScreen[1] + + val springConfig = if (flung) flungIntoTargetSpringConfig else springConfig + + cancelAnimations() + + // Animate to the center of the target. + animator + .spring(xProperty, xProperty.getValue(underlyingObject) + xDiff, velX, + springConfig) + .spring(yProperty, yProperty.getValue(underlyingObject) + yDiff, velY, + springConfig) + + if (after != null) { + animator.withEndActions(after) + } + + animator.start() + } + + /** + * Whether or not the provided values match a 'fast fling' towards the provided target. If it + * does, we consider it a fling-to-target gesture. + */ + private fun isForcefulFlingTowardsTarget( + target: MagneticTarget, + rawX: Float, + rawY: Float, + velX: Float, + velY: Float + ): Boolean { + if (!flingToTargetEnabled) { + return false + } + + // Whether velocity is sufficient, depending on whether we're flinging into a target at the + // top or the bottom of the screen. + val velocitySufficient = + if (rawY < target.centerOnScreen.y) velY > flingToTargetMinVelocity + else velY < flingToTargetMinVelocity + + if (!velocitySufficient) { + return false + } + + // Whether the trajectory of the fling intersects the target area. + var targetCenterXIntercept = rawX + + // Only do math if the X velocity is non-zero, otherwise X won't change. + if (velX != 0f) { + // Rise over run... + val slope = velY / velX + // ...y = mx + b, b = y / mx... + val yIntercept = rawY - slope * rawX + + // ...calculate the x value when y = the target's y-coordinate. + targetCenterXIntercept = (target.centerOnScreen.y - yIntercept) / slope + } + + // The width of the area we're looking for a fling towards. + val targetAreaWidth = target.targetView.width * flingToTargetWidthPercent + + // Velocity was sufficient, so return true if the intercept is within the target area. + return targetCenterXIntercept > target.centerOnScreen.x - targetAreaWidth / 2 && + targetCenterXIntercept < target.centerOnScreen.x + targetAreaWidth / 2 + } + + /** Cancel animations on this object's x/y properties. */ + internal fun cancelAnimations() { + animator.cancel(xProperty, yProperty) + } + + /** Updates the locations on screen of all of the [associatedTargets]. */ + internal fun updateTargetViewLocations() { + associatedTargets.forEach { it.updateLocationOnScreen() } + } + + /** + * Represents a target view with a magnetic field radius and cached center-on-screen + * coordinates. + * + * Instances of MagneticTarget are passed to a MagnetizedObject's [addTarget], and can then + * attract the object if it's dragged near or flung towards it. MagneticTargets can be added to + * multiple objects. + */ + class MagneticTarget( + internal val targetView: View, + var magneticFieldRadiusPx: Int + ) { + internal val centerOnScreen = PointF() + + private val tempLoc = IntArray(2) + + fun updateLocationOnScreen() { + targetView.getLocationOnScreen(tempLoc) + + // Add half of the target size to get the center, and subtract translation since the + // target could be animating in while we're doing this calculation. + centerOnScreen.set( + tempLoc[0] + targetView.width / 2f - targetView.translationX, + tempLoc[1] + targetView.height / 2f - targetView.translationY) + } + } + + companion object { + + /** + * Magnetizes the given view. Magnetized views are attracted to one or more magnetic + * targets. Magnetic targets attract objects that are dragged near them, and hold them there + * unless they're moved away or released. Releasing objects inside a magnetic target + * typically performs an action on the object. + * + * Magnetized views can also be flung to targets, which will result in the view being pulled + * into the target and released as if it was dragged into it. + * + * To use the returned MagnetizedObject instance, first set [magnetListener] to + * receive event callbacks. In your touch handler, pass all MotionEvents that move this view + * to [maybeConsumeMotionEvent]. If that method returns true, consider the event consumed by + * MagnetizedObject and don't move the view unless it begins returning false again. + * + * The view will be moved via translationX/Y properties, and its + * width/height will be determined via getWidth()/getHeight(). If you are animating + * something other than a view, or want to position your view using properties other than + * translationX/Y, implement an instance of [MagnetizedObject]. + * + * Note that the magnetic library can't re-order your view automatically. If the view + * renders on top of the target views, it will obscure the target when it sticks to it. + * You'll want to bring the view to the front in [MagnetListener.onStuckToTarget]. + */ + @JvmStatic + fun magnetizeView(view: T): MagnetizedObject { + return object : MagnetizedObject( + view.context, + view, + DynamicAnimation.TRANSLATION_X, + DynamicAnimation.TRANSLATION_Y) { + override fun getWidth(underlyingObject: T): Float { + return underlyingObject.width.toFloat() + } + + override fun getHeight(underlyingObject: T): Float { + return underlyingObject.height.toFloat() } + + override fun getLocationOnScreen(underlyingObject: T, loc: IntArray) { + underlyingObject.getLocationOnScreen(loc) + } + } + } + } +} \ No newline at end of file diff --git a/packages/SystemUI/tests/src/com/android/systemui/util/magnetictarget/MagnetizedObjectTest.kt b/packages/SystemUI/tests/src/com/android/systemui/util/magnetictarget/MagnetizedObjectTest.kt new file mode 100644 index 0000000000000..f1672b1c644d2 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/util/magnetictarget/MagnetizedObjectTest.kt @@ -0,0 +1,442 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.systemui.util.magnetictarget + +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import android.view.MotionEvent +import android.view.View +import androidx.dynamicanimation.animation.FloatPropertyCompat +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.util.animation.PhysicsAnimatorTestUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.ArgumentMatchers.anyFloat +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions + +@TestableLooper.RunWithLooper +@RunWith(AndroidTestingRunner::class) +@SmallTest +class MagnetizedObjectTest : SysuiTestCase() { + /** Incrementing value for fake MotionEvent timestamps. */ + private var time = 0L + + /** Value to add to each new MotionEvent's timestamp. */ + private var timeStep = 100 + + private val underlyingObject = this + + private lateinit var targetView: View + + private val targetSize = 200 + private val targetCenterX = 500 + private val targetCenterY = 900 + private val magneticFieldRadius = 200 + + private var objectX = 0f + private var objectY = 0f + private val objectSize = 50f + + private lateinit var magneticTarget: MagnetizedObject.MagneticTarget + private lateinit var magnetizedObject: MagnetizedObject<*> + private lateinit var magnetListener: MagnetizedObject.MagnetListener + + private val xProperty = object : FloatPropertyCompat("") { + override fun setValue(target: MagnetizedObjectTest?, value: Float) { + objectX = value + } + override fun getValue(target: MagnetizedObjectTest?): Float { + return objectX + } + } + + private val yProperty = object : FloatPropertyCompat("") { + override fun setValue(target: MagnetizedObjectTest?, value: Float) { + objectY = value + } + + override fun getValue(target: MagnetizedObjectTest?): Float { + return objectY + } + } + + @Before + fun setup() { + PhysicsAnimatorTestUtils.prepareForTest() + + // Mock the view since a real view's getLocationOnScreen() won't work unless it's attached + // to a real window (it'll always return x = 0, y = 0). + targetView = mock(View::class.java) + `when`(targetView.context).thenReturn(context) + + // The mock target view will pretend that it's 200x200, and at (400, 800). This means it's + // occupying the bounds (400, 800, 600, 1000) and it has a center of (500, 900). + `when`(targetView.width).thenReturn(targetSize) // width = 200 + `when`(targetView.height).thenReturn(targetSize) // height = 200 + doAnswer { invocation -> + (invocation.arguments[0] as IntArray).also { location -> + // Return the top left of the target. + location[0] = targetCenterX - targetSize / 2 // x = 400 + location[1] = targetCenterY - targetSize / 2 // y = 800 + } + }.`when`(targetView).getLocationOnScreen(ArgumentMatchers.any()) + `when`(targetView.context).thenReturn(context) + + magneticTarget = MagnetizedObject.MagneticTarget(targetView, magneticFieldRadius) + + magnetListener = mock(MagnetizedObject.MagnetListener::class.java) + magnetizedObject = object : MagnetizedObject( + context, underlyingObject, xProperty, yProperty) { + override fun getWidth(underlyingObject: MagnetizedObjectTest): Float { + return objectSize + } + + override fun getHeight(underlyingObject: MagnetizedObjectTest): Float { + return objectSize + } + + override fun getLocationOnScreen( + underlyingObject: MagnetizedObjectTest, + loc: IntArray + ) { + loc[0] = objectX.toInt() + loc[1] = objectY.toInt() } + } + + magnetizedObject.magnetListener = magnetListener + magnetizedObject.addTarget(magneticTarget) + + timeStep = 100 + } + + @Test + fun testMotionEventConsumption() { + // Start at (0, 0). No magnetic field here. + assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( + x = 0, y = 0, action = MotionEvent.ACTION_DOWN))) + + // Move to (400, 400), which is solidly outside the magnetic field. + assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( + x = 200, y = 200))) + + // Move to (305, 705). This would be in the magnetic field radius if magnetic fields were + // square. It's not, because they're not. + assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( + x = targetCenterX - magneticFieldRadius + 5, + y = targetCenterY - magneticFieldRadius + 5))) + + // Move to (400, 800). That's solidly in the radius so the magnetic target should begin + // consuming events. + assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( + x = targetCenterX - 100, + y = targetCenterY - 100))) + + // Release at (400, 800). Since we're in the magnetic target, it should return true and + // consume the ACTION_UP. + assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( + x = 400, y = 800, action = MotionEvent.ACTION_UP))) + + // ACTION_DOWN outside the field. + assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( + x = 200, y = 200, action = MotionEvent.ACTION_DOWN))) + + // Move to the center. We absolutely should consume events there. + assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( + x = targetCenterX, + y = targetCenterY))) + + // Drag out to (0, 0) and we should be returning false again. + assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( + x = 0, y = 0))) + + // The ACTION_UP event shouldn't be consumed either since it's outside the field. + assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( + x = 0, y = 0, action = MotionEvent.ACTION_UP))) + } + + @Test + fun testMotionEventConsumption_downInMagneticField() { + // We should consume DOWN events if they occur in the field. + assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( + x = targetCenterX, y = targetCenterY, action = MotionEvent.ACTION_DOWN))) + } + + @Test + fun testMoveIntoAroundAndOutOfMagneticField() { + // Move around but don't touch the magnetic field. + dispatchMotionEvents( + getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN), + getMotionEvent(x = 100, y = 100), + getMotionEvent(x = 200, y = 200)) + + // You can't become unstuck if you were never stuck in the first place. + verify(magnetListener, never()).onStuckToTarget(magneticTarget) + verify(magnetListener, never()).onUnstuckFromTarget( + eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(), + eq(false)) + + // Move into and then around inside the magnetic field. + dispatchMotionEvents( + getMotionEvent(x = targetCenterX - 100, y = targetCenterY - 100), + getMotionEvent(x = targetCenterX, y = targetCenterY), + getMotionEvent(x = targetCenterX + 100, y = targetCenterY + 100)) + + // We should only have received one call to onStuckToTarget and none to unstuck. + verify(magnetListener, times(1)).onStuckToTarget(magneticTarget) + verify(magnetListener, never()).onUnstuckFromTarget( + eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(), + eq(false)) + + // Move out of the field and then release. + dispatchMotionEvents( + getMotionEvent(x = 100, y = 100), + getMotionEvent(x = 100, y = 100, action = MotionEvent.ACTION_UP)) + + // We should have received one unstuck call and no more stuck calls. We also should never + // have received an onReleasedInTarget call. + verify(magnetListener, times(1)).onUnstuckFromTarget( + eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(), + eq(false)) + verifyNoMoreInteractions(magnetListener) + } + + @Test + fun testMoveIntoOutOfAndBackIntoMagneticField() { + // Move into the field + dispatchMotionEvents( + getMotionEvent( + x = targetCenterX - magneticFieldRadius, + y = targetCenterY - magneticFieldRadius, + action = MotionEvent.ACTION_DOWN), + getMotionEvent( + x = targetCenterX, y = targetCenterY)) + + verify(magnetListener, times(1)).onStuckToTarget(magneticTarget) + verify(magnetListener, never()).onReleasedInTarget(magneticTarget) + + // Move back out. + dispatchMotionEvents( + getMotionEvent( + x = targetCenterX - magneticFieldRadius, + y = targetCenterY - magneticFieldRadius)) + + verify(magnetListener, times(1)).onUnstuckFromTarget( + eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(), + eq(false)) + verify(magnetListener, never()).onReleasedInTarget(magneticTarget) + + // Move in again and release in the magnetic field. + dispatchMotionEvents( + getMotionEvent(x = targetCenterX - 100, y = targetCenterY - 100), + getMotionEvent(x = targetCenterX + 50, y = targetCenterY + 50), + getMotionEvent(x = targetCenterX, y = targetCenterY), + getMotionEvent( + x = targetCenterX, y = targetCenterY, action = MotionEvent.ACTION_UP)) + + verify(magnetListener, times(2)).onStuckToTarget(magneticTarget) + verify(magnetListener).onReleasedInTarget(magneticTarget) + verifyNoMoreInteractions(magnetListener) + } + + @Test + fun testFlingTowardsTarget_towardsTarget() { + timeStep = 10 + + // Forcefully fling the object towards the target (but never touch the magnetic field). + dispatchMotionEvents( + getMotionEvent( + x = targetCenterX, + y = 0, + action = MotionEvent.ACTION_DOWN), + getMotionEvent( + x = targetCenterX, + y = targetCenterY / 2), + getMotionEvent( + x = targetCenterX, + y = targetCenterY - magneticFieldRadius * 2, + action = MotionEvent.ACTION_UP)) + + // Nevertheless it should have ended up stuck to the target. + verify(magnetListener, times(1)).onStuckToTarget(magneticTarget) + } + + @Test + fun testFlingTowardsTarget_towardsButTooSlow() { + // Very, very slowly fling the object towards the target (but never touch the magnetic + // field). This value is only used to create MotionEvent timestamps, it will not block the + // test for 10 seconds. + timeStep = 10000 + dispatchMotionEvents( + getMotionEvent( + x = targetCenterX, + y = 0, + action = MotionEvent.ACTION_DOWN), + getMotionEvent( + x = targetCenterX, + y = targetCenterY / 2), + getMotionEvent( + x = targetCenterX, + y = targetCenterY - magneticFieldRadius * 2, + action = MotionEvent.ACTION_UP)) + + // No sticking should have occurred. + verifyNoMoreInteractions(magnetListener) + } + + @Test + fun testFlingTowardsTarget_missTarget() { + timeStep = 10 + // Forcefully fling the object down, but not towards the target. + dispatchMotionEvents( + getMotionEvent( + x = 0, + y = 0, + action = MotionEvent.ACTION_DOWN), + getMotionEvent( + x = 0, + y = targetCenterY / 2), + getMotionEvent( + x = 0, + y = targetCenterY - magneticFieldRadius * 2, + action = MotionEvent.ACTION_UP)) + + verifyNoMoreInteractions(magnetListener) + } + + @Test + fun testMagnetAnimation() { + // Make sure the object starts at (0, 0). + assertEquals(0f, objectX) + assertEquals(0f, objectY) + + // Trigger the magnet animation, and block the test until it ends. + PhysicsAnimatorTestUtils.setAllAnimationsBlock(true) + magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( + x = targetCenterX, + y = targetCenterY, + action = MotionEvent.ACTION_DOWN)) + + // The object's (top-left) position should now position it centered over the target. + assertEquals(targetCenterX - objectSize / 2, objectX) + assertEquals(targetCenterY - objectSize / 2, objectY) + } + + @Test + fun testMultipleTargets() { + val secondMagneticTarget = getSecondMagneticTarget() + + // Drag into the second target. + dispatchMotionEvents( + getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN), + getMotionEvent(x = 100, y = 900)) + + // Verify that we received an onStuck for the second target, and no others. + verify(magnetListener).onStuckToTarget(secondMagneticTarget) + verifyNoMoreInteractions(magnetListener) + + // Drag into the original target. + dispatchMotionEvents( + getMotionEvent(x = 0, y = 0), + getMotionEvent(x = 500, y = 900)) + + // We should have unstuck from the second one and stuck into the original one. + verify(magnetListener).onUnstuckFromTarget( + eq(secondMagneticTarget), anyFloat(), anyFloat(), eq(false)) + verify(magnetListener).onStuckToTarget(magneticTarget) + verifyNoMoreInteractions(magnetListener) + } + + @Test + fun testMultipleTargets_flingIntoSecond() { + val secondMagneticTarget = getSecondMagneticTarget() + + timeStep = 10 + + // Fling towards the second target. + dispatchMotionEvents( + getMotionEvent(x = 100, y = 0, action = MotionEvent.ACTION_DOWN), + getMotionEvent(x = 100, y = 350), + getMotionEvent(x = 100, y = 650, action = MotionEvent.ACTION_UP)) + + // Verify that we received an onStuck for the second target. + verify(magnetListener).onStuckToTarget(secondMagneticTarget) + + // Fling towards the first target. + dispatchMotionEvents( + getMotionEvent(x = 300, y = 0, action = MotionEvent.ACTION_DOWN), + getMotionEvent(x = 400, y = 350), + getMotionEvent(x = 500, y = 650, action = MotionEvent.ACTION_UP)) + + // Verify that we received onStuck for the original target. + verify(magnetListener).onStuckToTarget(magneticTarget) + } + + private fun getSecondMagneticTarget(): MagnetizedObject.MagneticTarget { + // The first target view is at bounds (400, 800, 600, 1000) and it has a center of + // (500, 900). We'll add a second one at bounds (0, 800, 200, 1000) with center (100, 900). + val secondTargetView = mock(View::class.java) + var secondTargetCenterX = 100 + var secondTargetCenterY = 900 + + `when`(secondTargetView.context).thenReturn(context) + `when`(secondTargetView.width).thenReturn(targetSize) // width = 200 + `when`(secondTargetView.height).thenReturn(targetSize) // height = 200 + doAnswer { invocation -> + (invocation.arguments[0] as IntArray).also { location -> + // Return the top left of the target. + location[0] = secondTargetCenterX - targetSize / 2 // x = 0 + location[1] = secondTargetCenterY - targetSize / 2 // y = 800 + } + }.`when`(secondTargetView).getLocationOnScreen(ArgumentMatchers.any()) + + return magnetizedObject.addTarget(secondTargetView, magneticFieldRadius) + } + + /** + * Return a MotionEvent at the given coordinates, with the given action (or MOVE by default). + * The event's time fields will be incremented by 10ms each time this is called, so tha + * VelocityTracker works. + */ + private fun getMotionEvent( + x: Int, + y: Int, + action: Int = MotionEvent.ACTION_MOVE + ): MotionEvent { + return MotionEvent.obtain(time, time, action, x.toFloat(), y.toFloat(), 0) + .also { time += timeStep } + } + + /** Dispatch all of the provided events to the target view. */ + private fun dispatchMotionEvents(vararg events: MotionEvent) { + events.forEach { magnetizedObject.maybeConsumeMotionEvent(it) } + } + + /** Prevents Kotlin from being mad that eq() is nullable. */ + private fun eq(value: T): T = Mockito.eq(value) ?: value +} \ No newline at end of file