PreviewAnimationClock.kt
/*
* Copyright 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.
*/
@file:Suppress("DEPRECATION")
package androidx.compose.ui.tooling.preview.animation
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.core.AnimationClockObservable
import androidx.compose.animation.core.AnimationClockObserver
import androidx.compose.animation.core.InternalAnimationApi
import androidx.compose.animation.core.ManualAnimationClock
import androidx.compose.animation.core.SeekableAnimation
import androidx.compose.animation.core.TransitionAnimation
import androidx.compose.animation.core.createSeekableAnimation
import androidx.compose.animation.tooling.ComposeAnimatedProperty
import androidx.compose.animation.tooling.ComposeAnimation
import androidx.compose.animation.tooling.ComposeAnimationType
/**
* [AnimationClockObservable] used to control animations in the context of Compose Previews. This
* clock is expected to be controlled by the Animation Inspector in Android Studio, and most of
* its methods will be called via reflection, either directly from Android Studio or through
* `ComposeViewAdapter`.
*
* It uses an underlying [ManualAnimationClock], as users will be able to select specific frames
* of subscribed animations when inspecting them in Android Studio.
*
* @suppress
*/
@OptIn(InternalAnimationApi::class)
internal open class PreviewAnimationClock(
private val initialTimeMs: Long = 0L,
private val setClockTimeCallback: () -> Unit = {}
) :
AnimationClockObservable {
private val TAG = "PreviewAnimationClock"
private val DEBUG = false
/**
* Maps subscribed [AnimationClockObserver]s to [ComposeAnimation]s. We parse the observers
* into [ComposeAnimation]s to make them better to handle, but we still need to hold the
* original objects in order to clean everything up on [unsubscribe].
*/
@VisibleForTesting
internal val observersToAnimations = hashMapOf<AnimationClockObserver, ComposeAnimation>()
/**
* Maps [ComposeAnimation]s representing [TransitionAnimation]s to their corresponding
* [SeekableAnimation], which we use to obtain the animated properties from. Since updating
* the clock will happen way more often than changing the transition states, we cache one
* [SeekableAnimation] per animation and call `getAnimValuesAt` on it when the clock changes
* instead of creating a new [SeekableAnimation] each time. Instead, we create it when `from`
* or `to` states change.
*/
@VisibleForTesting
internal val seekableAnimations = hashMapOf<ComposeAnimation, SeekableAnimation<*>>()
/**
* [AnimationClockObserver]s should be added to this set while their corresponding animations
* are having their `from` and `to` states updated. The animation framework unsubscribes and
* re-subscribes the animation in the process, and we need to keep track of that to ignore
* unsubscriptions that are caused by the states update process.
*/
private val pendingObservers = hashSetOf<AnimationClockObserver>()
private val pendingObserversLock = Any()
@VisibleForTesting
internal val clock = ManualAnimationClock(initialTimeMs)
override fun subscribe(observer: AnimationClockObserver) {
// Ignore subscriptions of observers already subscribed.
if (observersToAnimations.containsKey(observer)) return
if (DEBUG) {
Log.d(TAG, "AnimationClockObserver $observer subscribed")
}
clock.subscribe(observer)
when (observer) {
is TransitionAnimation<*>.TransitionAnimationClockObserver -> {
observer.animation.monotonic = false
observer.parse()
}
// TODO(b/160126628): support other animation types, e.g. AnimatedValue
else -> null
}?.let {
observersToAnimations[observer] = it
notifySubscribe(it)
}
}
override fun unsubscribe(observer: AnimationClockObserver) {
synchronized(pendingObserversLock) {
// unsubscribe is expected to be called once per state update. If There is another
// call, the animation actually is trying to unsubscribe and we need to process it.
if (pendingObservers.remove(observer)) {
return
}
}
if (DEBUG) {
Log.d(TAG, "AnimationClockObserver $observer unsubscribed")
}
clock.unsubscribe(observer)
observersToAnimations.remove(observer)?.let {
notifyUnsubscribe(it)
seekableAnimations.remove(it)
}
}
@VisibleForTesting
protected open fun notifySubscribe(animation: ComposeAnimation) {
// This method is expected to be no-op. It is intercepted in Android Studio using bytecode
// manipulation, in order for the tools to be aware that the animation was subscribed.
}
@VisibleForTesting
protected open fun notifyUnsubscribe(animation: ComposeAnimation) {
// This method is expected to be no-op. It is intercepted in Android Studio using bytecode
// manipulation, in order for the tools to be aware that the animation was unsubscribed.
}
/**
* Updates the [SeekableAnimation] corresponding to the given [ComposeAnimation], creating it
* with the given `from` and `to` states/
*/
fun updateSeekableAnimation(composeAnimation: ComposeAnimation, fromState: Any, toState: Any) {
if (composeAnimation.type != ComposeAnimationType.TRANSITION_ANIMATION) return
@Suppress("UNCHECKED_CAST")
val animation = composeAnimation.animationObject as TransitionAnimation<Any>
seekableAnimations[composeAnimation] = animation.createSeekableAnimation(fromState, toState)
}
/**
* Updates all the [TransitionAnimation]s `from` and `to` states. Since we're calling the
* `snapToState` and `toState` APIs, which respectively unsubscribes and subscribes
* animations, we also reset the [clock] to make sure all the animations are re-subscribed at
* the initial time. As we would be unsubscribing and re-subscribing the same animations, we
* add their corresponding observers to [pendingObservers] while updating the animation states.
*/
fun updateAnimationStates() {
observersToAnimations.forEach { (observer, composeAnimation) ->
seekableAnimations[composeAnimation]?.let { seekableAnimation ->
synchronized(pendingObserversLock) {
pendingObservers.add(observer)
}
@Suppress("UNCHECKED_CAST")
val animation = composeAnimation.animationObject as TransitionAnimation<Any>
animation.snapToState(seekableAnimation.fromState!!)
animation.toState(seekableAnimation.toState!!)
}
}
synchronized(pendingObserversLock) {
pendingObservers.clear()
}
// Reset the clock time so all the animations have it as the start time.
clock.clockTimeMillis = initialTimeMs
}
/**
* Returns the duration of the longest animation being tracked.
*/
fun getMaxDuration(): Long {
// TODO(b/160126628): support other animation types, e.g. AnimatedValue
return seekableAnimations.map { it.value.duration }.maxOrNull() ?: -1
}
/**
* Returns the longest duration per iteration among the animations being tracked. This can be
* different from [getMaxDuration], for instance, when there is one or more repeatable
* animations with multiple iterations.
*/
fun getMaxDurationPerIteration(): Long {
// TODO(b/160126628): support other animation types, e.g. AnimatedValue
return seekableAnimations.map { it.value.maxDurationPerIteration }.maxOrNull() ?: -1
}
/**
* Returns a list of the given [TransitionAnimation]'s animated properties. The properties
* are wrapped into a [Pair] of property label and the corresponding value at the current time.
*/
fun getAnimatedProperties(animation: ComposeAnimation): List<ComposeAnimatedProperty> {
if (animation.type != ComposeAnimationType.TRANSITION_ANIMATION) return emptyList()
seekableAnimations[animation]?.let { seekableAnimation ->
val time = clock.clockTimeMillis - initialTimeMs
return seekableAnimation.getAnimValuesAt(time).entries.map {
ComposeAnimatedProperty(it.key.label, it.value)
}
}
return emptyList()
}
/**
* Sets [clock] time to the given [animationTimeMs], relative to [initialTimeMs]. Expected to
* be called via reflection from Android Studio.
*/
fun setClockTime(animationTimeMs: Long) {
clock.clockTimeMillis = initialTimeMs + animationTimeMs
setClockTimeCallback.invoke()
}
fun dispose() {
observersToAnimations.clear()
seekableAnimations.clear()
}
}