[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:
Nick Chameyev
2022-11-07 12:11:23 +00:00
parent 67c61bf59b
commit 61e549a232
9 changed files with 161 additions and 2 deletions

View File

@@ -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.

View File

@@ -92,5 +92,7 @@ interface SysUIUnfoldComponent {
fun getUnfoldTransitionWallpaperController(): UnfoldTransitionWallpaperController
fun getUnfoldHapticsPlayer(): UnfoldHapticsPlayer
fun getUnfoldLightRevealOverlayAnimation(): UnfoldLightRevealOverlayAnimation
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -33,6 +33,7 @@ android_library {
"dagger2",
"jsr330",
],
kotlincflags: ["-Xjvm-default=enable"],
java_version: "1.8",
min_sdk_version: "current",
plugins: ["dagger2-compiler"],

View File

@@ -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() {}
}
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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() }