Merge changes Icf5b3bc0,I97ae15d8

* changes:
  Magnetic target library!
  Modifies PhysicsAnimator's cancellation methods to support tests.
This commit is contained in:
Josh Tsuji
2020-02-20 16:04:26 +00:00
committed by Android (Google) Code Review
4 changed files with 1117 additions and 6 deletions

View File

@@ -125,6 +125,13 @@ class PhysicsAnimator<T> private constructor (val target: T) {
*/
internal var startAction: () -> Unit = ::startInternal
/**
* Action to run when [cancel] is called. This can be changed by
* [PhysicsAnimatorTestUtils.prepareForTest] to cancel animations from the main thread, which
* is required.
*/
internal var cancelAction: (Set<FloatPropertyCompat<in T>>) -> Unit = ::cancelInternal
/**
* Springs a property to the given value, using the provided configuration settings.
*
@@ -429,10 +436,13 @@ class PhysicsAnimator<T> private constructor (val target: T) {
max = max(currentValue, this.max)
}
// Apply the configuration and start the animation. Since flings can't be
// redirected while in motion, cancel it first.
// Flings can't be updated to a new position while maintaining velocity, because
// we're using the explicitly provided start velocity. Cancel any flings (or
// springs) on this property before flinging.
cancel(animatedProperty)
// Apply the configuration and start the animation.
getFlingAnimation(animatedProperty)
.also { it.cancel() }
.also { flingConfig.applyToAnimation(it) }
.start()
}
@@ -707,11 +717,26 @@ class PhysicsAnimator<T> private constructor (val target: T) {
return springConfigs.keys.union(flingConfigs.keys)
}
/**
* Cancels the given properties. This is typically called immediately by [cancel], unless this
* animator is under test.
*/
internal fun cancelInternal(properties: Set<FloatPropertyCompat<in T>>) {
for (property in properties) {
flingAnimations[property]?.cancel()
springAnimations[property]?.cancel()
}
}
/** Cancels all in progress animations on all properties. */
fun cancel() {
for (dynamicAnim in flingAnimations.values.union(springAnimations.values)) {
dynamicAnim.cancel()
}
cancelAction(flingAnimations.keys)
cancelAction(springAnimations.keys)
}
/** Cancels in progress animations on the provided properties only. */
fun cancel(vararg properties: FloatPropertyCompat<in T>) {
cancelAction(properties.toSet())
}
/**

View File

@@ -363,8 +363,12 @@ object PhysicsAnimatorTestUtils {
private val testEndListeners = ArrayList<PhysicsAnimator.EndListener<T>>()
private val testUpdateListeners = ArrayList<PhysicsAnimator.UpdateListener<T>>()
/** Whether we're currently in the middle of executing startInternal(). */
private var currentlyRunningStartInternal = false
init {
animator.startAction = ::startForTest
animator.cancelAction = ::cancelForTest
}
internal fun addTestEndListener(listener: PhysicsAnimator.EndListener<T>) {
@@ -437,7 +441,29 @@ object PhysicsAnimatorTestUtils {
}
})
currentlyRunningStartInternal = true
animator.startInternal()
currentlyRunningStartInternal = false
unblockLatch.countDown()
}
unblockLatch.await(timeoutMs, TimeUnit.MILLISECONDS)
}
private fun cancelForTest(properties: Set<FloatPropertyCompat<in T>>) {
// If this was called from startInternal, we are already on the animation thread, and
// should just call cancelInternal rather than posting it. If we post it, the
// cancellation will occur after the rest of startInternal() and we'll immediately
// cancel the animation we worked so hard to start!
if (currentlyRunningStartInternal) {
animator.cancelInternal(properties)
return
}
val unblockLatch = CountDownLatch(1)
animationThreadHandler.post {
animator.cancelInternal(properties)
unblockLatch.countDown()
}

View File

@@ -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<T : Any>(
val context: Context,
/** The actual object that is animated. */
val underlyingObject: T,
/** Property that gets/sets the object's X value. */
val xProperty: FloatPropertyCompat<in T>,
/** Property that gets/sets the object's Y value. */
val yProperty: FloatPropertyCompat<in T>
) {
/** 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<T> = 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<MagneticTarget>()
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<View> 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 <T : View> magnetizeView(view: T): MagnetizedObject<T> {
return object : MagnetizedObject<T>(
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)
}
}
}
}
}

View File

@@ -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<MagnetizedObjectTest>("") {
override fun setValue(target: MagnetizedObjectTest?, value: Float) {
objectX = value
}
override fun getValue(target: MagnetizedObjectTest?): Float {
return objectX
}
}
private val yProperty = object : FloatPropertyCompat<MagnetizedObjectTest>("") {
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<MagnetizedObjectTest>(
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 <T> eq(value: T): T = Mockito.eq(value) ?: value
}