1 /*
2  * Copyright (C) 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.systemui.unfold.progress
17 
18 import android.animation.Animator
19 import android.animation.AnimatorListenerAdapter
20 import android.animation.ObjectAnimator
21 import android.animation.ValueAnimator
22 import android.content.Context
23 import android.os.Trace
24 import android.util.FloatProperty
25 import android.util.Log
26 import android.view.animation.AnimationUtils.loadInterpolator
27 import androidx.dynamicanimation.animation.DynamicAnimation
28 import androidx.dynamicanimation.animation.FloatPropertyCompat
29 import androidx.dynamicanimation.animation.SpringAnimation
30 import androidx.dynamicanimation.animation.SpringForce
31 import com.android.systemui.unfold.UnfoldTransitionProgressProvider
32 import com.android.systemui.unfold.UnfoldTransitionProgressProvider.TransitionProgressListener
33 import com.android.systemui.unfold.updates.FOLD_UPDATE_FINISH_CLOSED
34 import com.android.systemui.unfold.updates.FOLD_UPDATE_FINISH_FULL_OPEN
35 import com.android.systemui.unfold.updates.FOLD_UPDATE_FINISH_HALF_OPEN
36 import com.android.systemui.unfold.updates.FOLD_UPDATE_START_CLOSING
37 import com.android.systemui.unfold.updates.FoldStateProvider
38 import com.android.systemui.unfold.updates.FoldStateProvider.FoldUpdate
39 import com.android.systemui.unfold.updates.FoldStateProvider.FoldUpdatesListener
40 import com.android.systemui.unfold.updates.name
41 import javax.inject.Inject
42 
43 /** Maps fold updates to unfold transition progress using DynamicAnimation. */
44 class PhysicsBasedUnfoldTransitionProgressProvider
45 @Inject
46 constructor(context: Context, private val foldStateProvider: FoldStateProvider) :
47     UnfoldTransitionProgressProvider, FoldUpdatesListener, DynamicAnimation.OnAnimationEndListener {
48 
49     private val emphasizedInterpolator =
50         loadInterpolator(context, android.R.interpolator.fast_out_extra_slow_in)
51 
52     private var cannedAnimator: ValueAnimator? = null
53 
54     private val springAnimation =
55         SpringAnimation(
56                 this,
57                 FloatPropertyCompat.createFloatPropertyCompat(AnimationProgressProperty)
58             )
59             .apply { addEndListener(this@PhysicsBasedUnfoldTransitionProgressProvider) }
60 
61     private var isTransitionRunning = false
62     private var isAnimatedCancelRunning = false
63 
64     private var transitionProgress: Float = 0.0f
65         set(value) {
66             if (isTransitionRunning) {
67                 listeners.forEach { it.onTransitionProgress(value) }
68             }
69             field = value
70         }
71 
72     private val listeners: MutableList<TransitionProgressListener> = mutableListOf()
73 
74     init {
75         foldStateProvider.addCallback(this)
76         foldStateProvider.start()
77     }
78 
79     override fun destroy() {
80         foldStateProvider.stop()
81     }
82 
83     override fun onHingeAngleUpdate(angle: Float) {
84         if (!isTransitionRunning || isAnimatedCancelRunning) return
85         val progress = saturate(angle / FINAL_HINGE_ANGLE_POSITION)
86         springAnimation.animateToFinalPosition(progress)
87     }
88 
89     private fun saturate(amount: Float, low: Float = 0f, high: Float = 1f): Float =
90         if (amount < low) low else if (amount > high) high else amount
91 
92     override fun onFoldUpdate(@FoldUpdate update: Int) {
93         when (update) {
94             FOLD_UPDATE_FINISH_FULL_OPEN,
95             FOLD_UPDATE_FINISH_HALF_OPEN -> {
96                 // Do not cancel if we haven't started the transition yet.
97                 // This could happen when we fully unfolded the device before the screen
98                 // became available. In this case we start and immediately cancel the animation
99                 // in onUnfoldedScreenAvailable event handler, so we don't need to cancel it here.
100                 if (isTransitionRunning) {
101                     cancelTransition(endValue = 1f, animate = true)
102                 }
103             }
104             FOLD_UPDATE_FINISH_CLOSED -> {
105                 cancelTransition(endValue = 0f, animate = false)
106             }
107             FOLD_UPDATE_START_CLOSING -> {
108                 // The transition might be already running as the device might start closing several
109                 // times before reaching an end state.
110                 if (isTransitionRunning) {
111                     // If we are cancelling the animation, reset that so we can resume it normally.
112                     // The animation could be 'cancelled' when the user stops folding/unfolding
113                     // for some period of time or fully unfolds the device. In this case,
114                     // it is forced to run to the end ignoring all further hinge angle events.
115                     // By resetting this flag we allow reacting to hinge angle events again, so
116                     // the transition continues running.
117                     if (isAnimatedCancelRunning) {
118                         isAnimatedCancelRunning = false
119 
120                         // Switching to spring animation, start the animation if it
121                         // is not running already
122                         springAnimation.animateToFinalPosition(1.0f)
123 
124                         cannedAnimator?.removeAllListeners()
125                         cannedAnimator?.cancel()
126                         cannedAnimator = null
127                     }
128                 } else {
129                     startTransition(startValue = 1f)
130                 }
131             }
132         }
133 
134         if (DEBUG) {
135             Log.d(TAG, "onFoldUpdate = ${update.name()}")
136             Trace.setCounter("fold_update", update.toLong())
137         }
138     }
139 
140     override fun onUnfoldedScreenAvailable() {
141         startTransition(startValue = 0f)
142 
143         // Stop the animation if the device has already opened by the time when
144         // the display is available as we won't receive the full open event anymore
145         if (foldStateProvider.isFinishedOpening) {
146             cancelTransition(endValue = 1f, animate = true)
147         }
148     }
149 
150     private fun cancelTransition(endValue: Float, animate: Boolean) {
151         if (isTransitionRunning && animate) {
152             if (endValue == 1.0f && !isAnimatedCancelRunning) {
153                 listeners.forEach { it.onTransitionFinishing() }
154             }
155 
156             isAnimatedCancelRunning = true
157 
158             if (USE_CANNED_ANIMATION) {
159                 startCannedCancelAnimation()
160             } else {
161                 springAnimation.animateToFinalPosition(endValue)
162             }
163         } else {
164             transitionProgress = endValue
165             isAnimatedCancelRunning = false
166             isTransitionRunning = false
167             springAnimation.cancel()
168 
169             cannedAnimator?.removeAllListeners()
170             cannedAnimator?.cancel()
171             cannedAnimator = null
172 
173             listeners.forEach { it.onTransitionFinished() }
174 
175             if (DEBUG) {
176                 Log.d(TAG, "onTransitionFinished")
177             }
178         }
179     }
180 
181     override fun onAnimationEnd(
182         animation: DynamicAnimation<out DynamicAnimation<*>>,
183         canceled: Boolean,
184         value: Float,
185         velocity: Float
186     ) {
187         if (isAnimatedCancelRunning) {
188             cancelTransition(value, animate = false)
189         }
190     }
191 
192     private fun onStartTransition() {
193         Trace.beginSection("$TAG#onStartTransition")
194         listeners.forEach { it.onTransitionStarted() }
195         Trace.endSection()
196 
197         isTransitionRunning = true
198 
199         if (DEBUG) {
200             Log.d(TAG, "onTransitionStarted")
201         }
202     }
203 
204     private fun startTransition(startValue: Float) {
205         if (!isTransitionRunning) onStartTransition()
206 
207         springAnimation.apply {
208             spring =
209                 SpringForce().apply {
210                     finalPosition = startValue
211                     dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY
212                     stiffness = SPRING_STIFFNESS
213                 }
214             minimumVisibleChange = MINIMAL_VISIBLE_CHANGE
215             setStartValue(startValue)
216             setMinValue(0f)
217             setMaxValue(1f)
218         }
219 
220         springAnimation.start()
221     }
222 
223     override fun addCallback(listener: TransitionProgressListener) {
224         listeners.add(listener)
225     }
226 
227     override fun removeCallback(listener: TransitionProgressListener) {
228         listeners.remove(listener)
229     }
230 
231     private fun startCannedCancelAnimation() {
232         cannedAnimator?.cancel()
233         cannedAnimator = null
234 
235         // Temporary remove listener to cancel the spring animation without
236         // finishing the transition
237         springAnimation.removeEndListener(this)
238         springAnimation.cancel()
239         springAnimation.addEndListener(this)
240 
241         cannedAnimator =
242             ObjectAnimator.ofFloat(this, AnimationProgressProperty, transitionProgress, 1f).apply {
243                 addListener(CannedAnimationListener())
244                 duration =
245                     (CANNED_ANIMATION_DURATION_MS.toFloat() * (1f - transitionProgress)).toLong()
246                 interpolator = emphasizedInterpolator
247                 start()
248             }
249     }
250 
251     private inner class CannedAnimationListener : AnimatorListenerAdapter() {
252         override fun onAnimationStart(animator: Animator) {
253             Trace.beginAsyncSection("$TAG#cannedAnimatorRunning", 0)
254         }
255 
256         override fun onAnimationEnd(animator: Animator) {
257             cancelTransition(1f, animate = false)
258             Trace.endAsyncSection("$TAG#cannedAnimatorRunning", 0)
259         }
260     }
261 
262     private object AnimationProgressProperty :
263         FloatProperty<PhysicsBasedUnfoldTransitionProgressProvider>("animation_progress") {
264 
265         override fun setValue(
266             provider: PhysicsBasedUnfoldTransitionProgressProvider,
267             value: Float
268         ) {
269             provider.transitionProgress = value
270         }
271 
272         override fun get(provider: PhysicsBasedUnfoldTransitionProgressProvider): Float =
273             provider.transitionProgress
274     }
275 }
276 
277 private const val TAG = "PhysicsBasedUnfoldTransitionProgressProvider"
278 private const val DEBUG = true
279 
280 private const val USE_CANNED_ANIMATION = true
281 private const val CANNED_ANIMATION_DURATION_MS = 1000
282 private const val SPRING_STIFFNESS = 600.0f
283 private const val MINIMAL_VISIBLE_CHANGE = 0.001f
284 private const val FINAL_HINGE_ANGLE_POSITION = 165f
285