[Unfold transition] Add haptics effect
Adds haptics effect when the animation is about to end. The effect is played when the animation is cancelled because of timeout or when the device is unfolded quickly. Bug: 200555479 Test: atest com.android.systemui.unfold.progress.PhysicsBasedUnfoldTransitionProgressProviderTest Test: manual test Change-Id: I0d6c8098b1c86e37547793644bf450d69c166c50
This commit is contained in:
@@ -125,10 +125,10 @@ public interface SysUIComponent {
|
||||
default void init() {
|
||||
// Initialize components that have no direct tie to the dagger dependency graph,
|
||||
// but are critical to this component's operation
|
||||
// TODO(b/205034537): I think this is a good idea?
|
||||
getSysUIUnfoldComponent().ifPresent(c -> {
|
||||
c.getUnfoldLightRevealOverlayAnimation().init();
|
||||
c.getUnfoldTransitionWallpaperController().init();
|
||||
c.getUnfoldHapticsPlayer();
|
||||
});
|
||||
getNaturalRotationUnfoldProgressProvider().ifPresent(o -> o.init());
|
||||
// No init method needed, just needs to be gotten so that it's created.
|
||||
|
||||
@@ -92,5 +92,7 @@ interface SysUIUnfoldComponent {
|
||||
|
||||
fun getUnfoldTransitionWallpaperController(): UnfoldTransitionWallpaperController
|
||||
|
||||
fun getUnfoldHapticsPlayer(): UnfoldHapticsPlayer
|
||||
|
||||
fun getUnfoldLightRevealOverlayAnimation(): UnfoldLightRevealOverlayAnimation
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.android.systemui.unfold
|
||||
|
||||
import android.os.SystemProperties
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import com.android.systemui.unfold.UnfoldTransitionProgressProvider.TransitionProgressListener
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Class that plays a haptics effect during unfolding a foldable device
|
||||
*/
|
||||
@SysUIUnfoldScope
|
||||
class UnfoldHapticsPlayer
|
||||
@Inject
|
||||
constructor(
|
||||
unfoldTransitionProgressProvider: UnfoldTransitionProgressProvider,
|
||||
private val vibrator: Vibrator?
|
||||
) : TransitionProgressListener {
|
||||
|
||||
init {
|
||||
if (vibrator != null) {
|
||||
// We don't need to remove the callback because we should listen to it
|
||||
// the whole time when SystemUI process is alive
|
||||
unfoldTransitionProgressProvider.addCallback(this)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastTransitionProgress = TRANSITION_PROGRESS_FULL_OPEN
|
||||
|
||||
override fun onTransitionStarted() {
|
||||
lastTransitionProgress = TRANSITION_PROGRESS_CLOSED
|
||||
}
|
||||
|
||||
override fun onTransitionProgress(progress: Float) {
|
||||
lastTransitionProgress = progress
|
||||
}
|
||||
|
||||
override fun onTransitionFinishing() {
|
||||
// Run haptics only if the animation is long enough to notice
|
||||
if (lastTransitionProgress < TRANSITION_NOTICEABLE_THRESHOLD) {
|
||||
playHaptics()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTransitionFinished() {
|
||||
lastTransitionProgress = TRANSITION_PROGRESS_FULL_OPEN
|
||||
}
|
||||
|
||||
private fun playHaptics() {
|
||||
vibrator?.vibrate(effect)
|
||||
}
|
||||
|
||||
private val hapticsScale: Float
|
||||
get() {
|
||||
val intensityString = SystemProperties.get("persist.unfold.haptics_scale", "0.1")
|
||||
return intensityString.toFloatOrNull() ?: 0.1f
|
||||
}
|
||||
|
||||
private val hapticsScaleTick: Float
|
||||
get() {
|
||||
val intensityString =
|
||||
SystemProperties.get("persist.unfold.haptics_scale_end_tick", "0.6")
|
||||
return intensityString.toFloatOrNull() ?: 0.6f
|
||||
}
|
||||
|
||||
private val primitivesCount: Int
|
||||
get() {
|
||||
val count = SystemProperties.get("persist.unfold.primitives_count", "18")
|
||||
return count.toIntOrNull() ?: 18
|
||||
}
|
||||
|
||||
private val effect: VibrationEffect by lazy {
|
||||
val composition =
|
||||
VibrationEffect.startComposition()
|
||||
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_TICK, 0F, 0)
|
||||
|
||||
repeat(primitivesCount) {
|
||||
composition.addPrimitive(
|
||||
VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
|
||||
hapticsScale,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
composition
|
||||
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_TICK, hapticsScaleTick)
|
||||
.compose()
|
||||
}
|
||||
}
|
||||
|
||||
private const val TRANSITION_PROGRESS_CLOSED = 0f
|
||||
private const val TRANSITION_PROGRESS_FULL_OPEN = 1f
|
||||
private const val TRANSITION_NOTICEABLE_THRESHOLD = 0.9f
|
||||
@@ -68,6 +68,22 @@ class PhysicsBasedUnfoldTransitionProgressProviderTest : SysuiTestCase() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnfold_emitsFinishingEvent() {
|
||||
runOnMainThreadWithInterval(
|
||||
{ foldStateProvider.sendFoldUpdate(FOLD_UPDATE_START_OPENING) },
|
||||
{ foldStateProvider.sendHingeAngleUpdate(10f) },
|
||||
{ foldStateProvider.sendFoldUpdate(FOLD_UPDATE_UNFOLDED_SCREEN_AVAILABLE) },
|
||||
{ foldStateProvider.sendHingeAngleUpdate(90f) },
|
||||
{ foldStateProvider.sendHingeAngleUpdate(180f) },
|
||||
{ foldStateProvider.sendFoldUpdate(FOLD_UPDATE_FINISH_FULL_OPEN) },
|
||||
)
|
||||
|
||||
with(listener.ensureTransitionFinished()) {
|
||||
assertHasSingleFinishingEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnfold_screenAvailableOnlyAfterFullUnfold_emitsIncreasingTransitionEvents() {
|
||||
runOnMainThreadWithInterval(
|
||||
@@ -157,6 +173,12 @@ class PhysicsBasedUnfoldTransitionProgressProviderTest : SysuiTestCase() {
|
||||
currentRecording!!.addProgress(progress)
|
||||
}
|
||||
|
||||
override fun onTransitionFinishing() {
|
||||
assertWithMessage("Received transition finishing event when it's not started")
|
||||
.that(currentRecording).isNotNull()
|
||||
currentRecording!!.onFinishing()
|
||||
}
|
||||
|
||||
override fun onTransitionFinished() {
|
||||
assertWithMessage("Received transition finish event when it's not started")
|
||||
.that(currentRecording).isNotNull()
|
||||
@@ -171,6 +193,7 @@ class PhysicsBasedUnfoldTransitionProgressProviderTest : SysuiTestCase() {
|
||||
|
||||
class UnfoldTransitionRecording {
|
||||
private val progressHistory: MutableList<Float> = arrayListOf()
|
||||
private var finishingInvocations: Int = 0
|
||||
|
||||
fun addProgress(progress: Float) {
|
||||
assertThat(progress).isAtMost(1.0f)
|
||||
@@ -179,6 +202,10 @@ class PhysicsBasedUnfoldTransitionProgressProviderTest : SysuiTestCase() {
|
||||
progressHistory += progress
|
||||
}
|
||||
|
||||
fun onFinishing() {
|
||||
finishingInvocations++
|
||||
}
|
||||
|
||||
fun assertIncreasingProgress() {
|
||||
assertThat(progressHistory.size).isGreaterThan(MIN_ANIMATION_EVENTS)
|
||||
assertThat(progressHistory).isInOrder()
|
||||
@@ -206,6 +233,11 @@ class PhysicsBasedUnfoldTransitionProgressProviderTest : SysuiTestCase() {
|
||||
.isInOrder(Comparator.reverseOrder<Float>())
|
||||
assertThat(progressHistory.last()).isEqualTo(0.0f)
|
||||
}
|
||||
|
||||
fun assertHasSingleFinishingEvent() {
|
||||
assertWithMessage("onTransitionFinishing callback should be invoked exactly " +
|
||||
"one time").that(finishingInvocations).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -33,6 +33,7 @@ android_library {
|
||||
"dagger2",
|
||||
"jsr330",
|
||||
],
|
||||
kotlincflags: ["-Xjvm-default=enable"],
|
||||
java_version: "1.8",
|
||||
min_sdk_version: "current",
|
||||
plugins: ["dagger2-compiler"],
|
||||
|
||||
@@ -34,8 +34,28 @@ interface UnfoldTransitionProgressProvider : CallbackController<TransitionProgre
|
||||
fun destroy()
|
||||
|
||||
interface TransitionProgressListener {
|
||||
/** Called when transition is started */
|
||||
@JvmDefault
|
||||
fun onTransitionStarted() {}
|
||||
fun onTransitionFinished() {}
|
||||
|
||||
/**
|
||||
* Called whenever transition progress is updated, [progress] is a value of the animation
|
||||
* where 0 is fully folded, 1 is fully unfolded
|
||||
*/
|
||||
@JvmDefault
|
||||
fun onTransitionProgress(@FloatRange(from = 0.0, to = 1.0) progress: Float) {}
|
||||
|
||||
/**
|
||||
* Called when the progress provider determined that the transition is about to finish soon.
|
||||
*
|
||||
* For example, in [PhysicsBasedUnfoldTransitionProgressProvider] this could happen when the
|
||||
* animation is not tied to the hinge angle anymore and it is about to run fixed animation.
|
||||
*/
|
||||
@JvmDefault
|
||||
fun onTransitionFinishing() {}
|
||||
|
||||
/** Called when transition is completely finished */
|
||||
@JvmDefault
|
||||
fun onTransitionFinished() {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ internal class FixedTimingTransitionProgressProvider(
|
||||
|
||||
override fun onAnimationStart(animator: Animator) {
|
||||
listeners.forEach { it.onTransitionStarted() }
|
||||
listeners.forEach { it.onTransitionFinishing() }
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animator: Animator) {
|
||||
|
||||
@@ -124,6 +124,10 @@ class PhysicsBasedUnfoldTransitionProgressProvider(
|
||||
|
||||
private fun cancelTransition(endValue: Float, animate: Boolean) {
|
||||
if (isTransitionRunning && animate) {
|
||||
if (endValue == 1.0f && !isAnimatedCancelRunning) {
|
||||
listeners.forEach { it.onTransitionFinishing() }
|
||||
}
|
||||
|
||||
isAnimatedCancelRunning = true
|
||||
springAnimation.animateToFinalPosition(endValue)
|
||||
} else {
|
||||
|
||||
@@ -110,6 +110,12 @@ constructor(source: UnfoldTransitionProgressProvider? = null) :
|
||||
lastTransitionProgress = progress
|
||||
}
|
||||
|
||||
override fun onTransitionFinishing() {
|
||||
if (isReadyToHandleTransition) {
|
||||
listeners.forEach { it.onTransitionFinishing() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTransitionFinished() {
|
||||
if (isReadyToHandleTransition) {
|
||||
listeners.forEach { it.onTransitionFinished() }
|
||||
|
||||
Reference in New Issue
Block a user