Merge changes Icf5b3bc0,I97ae15d8
* changes: Magnetic target library! Modifies PhysicsAnimator's cancellation methods to support tests.
This commit is contained in:
@@ -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())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user