MotionLayoutState.kt

/*
 * Copyright (C) 2022 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 androidx.constraintlayout.compose

import androidx.annotation.FloatRange
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

/**
 * Class used to read and manipulate the state of a MotionLayout Composable.
 */
@Immutable
@ExperimentalMotionApi
interface MotionLayoutState {
    // TODO: Add API to listen to finished Transition animation
    // TODO: Add API to know if MotionLayout is on an ongoing animation

    /**
     * Observable value for the animation progress of the current MotionLayout Transition.
     *
     * Where 0.0f is the start of the Transition.
     *
     * And 1.0f is the end of the Transition.
     *
     * Beware that reading a 0 or 1 does not imply that a Transition animation has ended.
     */
    val currentProgress: Float

    /**
     * Observable value to indicate if MotionLayout is in a debugging mode.
     *
     * False by default.
     */
    val isInDebugMode: Boolean

    /**
     * Change the debugging mode.
     *
     * Note that this causes an internal recomposition of the MotionLayout modifiers, cancelling
     * events like swipe handling. Also, debugging may add overhead to measuring and/or drawing.
     *
     * Set [MotionLayoutDebugFlags.NONE] to deactivate any ongoing debugging.
     *
     * @see MotionLayoutDebugFlags
     */
    fun setDebugMode(motionDebugFlag: MotionLayoutDebugFlags)

    /**
     * Set the animation progress to the given [newProgress] without animating. The value change
     * will be instant.
     *
     * Calls to this method will cancel any ongoing animation.
     */
    fun snapTo(@FloatRange(from = 0.0, to = 1.0) newProgress: Float)

    /**
     * Animate the progress to the given [newProgress] using [animationSpec].
     *
     * Repeated calls to this method will cancel previous ongoing animations.
     */
    fun animateTo(
        @FloatRange(from = 0.0, to = 1.0) newProgress: Float,
        animationSpec: AnimationSpec<Float>
    )
}

/**
 * Implementation of [MotionLayoutState] with additional properties used by MotionLayout internals.
 */
@Immutable
@ExperimentalMotionApi
@PublishedApi
internal class MotionLayoutStateImpl(
    initialProgress: Float,
    initialDebugMode: MotionLayoutDebugFlags,
    private val motionCoroutineScope: CoroutineScope
) : MotionLayoutState {
    /**
     * The underlying object that holds the progress value for a [MotionLayout] Composable.
     *
     * Manipulated using the [Animatable] API, exposed internally with [progressState] and
     * [motionProgress]; and externally with [currentProgress], [animateTo] and [snapTo].
     */
    private val animatableProgress = Animatable(initialProgress)

    /**
     * Channel to allow scheduling Animation Commands into [motionCoroutineScope].
     */
    private val channel = Channel<MotionAnimationCommand>(capacity = Channel.UNLIMITED).also {
        motionCoroutineScope.launch {
            while (coroutineContext.isActive) {
                // Wait for the next Command
                val stateCommand = it.receive()

                // Handle the command with `launch` to avoid blocking this scope, when a new Command
                // is received and launched, Animatable will cancel any running animations from
                // previous Commands
                launch {
                    when (stateCommand) {
                        is MotionAnimationCommand.Animate -> {
                            animatableProgress.animateTo(
                                targetValue = stateCommand.newProgress,
                                animationSpec = stateCommand.animationSpec
                            )
                        }
                        is MotionAnimationCommand.Snap -> {
                            animatableProgress.snapTo(targetValue = stateCommand.newProgress)
                        }
                    }
                }
            }
        }
    }

    /**
     * [MutableState] for the debug mode.
     */
    private val debugModeState: MutableState<MotionLayoutDebugFlags> =
        mutableStateOf(initialDebugMode)

    /**
     * Internal observable debug mode.
     *
     * @see MotionLayoutDebugFlags
     */
    @PublishedApi
    internal val debugMode: MotionLayoutDebugFlags
        get() = debugModeState.value

    // TODO: Remove once we substitute uses of `progressState` with MotionProgress
    /**
     * Internal [State] for the progress.
     *
     * Prefer to use [motionProgress] instead.
     */
    @PublishedApi
    internal val progressState: State<Float> = animatableProgress.asState()

    /**
     * Object used by MotionLayout internals to read and update the progress.
     */
    @PublishedApi
    internal val motionProgress: MotionProgress = object : MotionProgress {
        override val progress: Float
            get() = progressState.value

        override suspend fun updateProgress(newProgress: Float) {
            animatableProgress.snapTo(newProgress)
        }
    }

    override val currentProgress: Float
        get() = animatableProgress.value

    override val isInDebugMode: Boolean
        get() = debugModeState.value == MotionLayoutDebugFlags.SHOW_ALL

    override fun setDebugMode(motionDebugFlag: MotionLayoutDebugFlags) {
        debugModeState.value = motionDebugFlag
    }

    override fun snapTo(newProgress: Float) {
        channel.trySend(MotionAnimationCommand.Snap(newProgress))
    }

    override fun animateTo(newProgress: Float, animationSpec: AnimationSpec<Float>) {
        channel.trySend(MotionAnimationCommand.Animate(newProgress, animationSpec))
    }
}

/**
 * Returns a [MotionLayoutState], when passed to a [MotionLayout] Composable it can be used to
 * observe and animate its internal progress value.
 *
 * - To animate on click:
 * ```
 * @Composable
 * fun MyComposable() {
 *   val motionState = rememberMotionLayoutState()
 *   Column {
 *     MotionLayout(motionLayoutState = motionState, motionScene = MotionScene(<your-json>)) {
 *       <your-composables>
 *     }
 *     Button(
 *       // Animate the associated MotionLayout to end (progress = 1f)
 *       onClick = { motionState.animateTo(1f, spring()) }
 *     ) {
 *       Text(text = "Send")
 *     }
 *   }
 * }
 * ```
 * - Use the current progress value:
 * ```
 * @Composable
 * fun MyComposable() {
 *   val motionState = rememberMotionLayoutState()
 *   Column {
 *     MotionLayout(motionLayoutState = motionState, motionScene = MotionScene(<your-json>)) {
 *       <your-composables>
 *     }
 *     // Text will recompose during MotionLayout animation with the current progress value
 *     Text(text = "Value: ${motionState.currentProgress}")
 *   }
 * }
 * ```
 *
 * Returns the same instance if [key] is equal to the previous composition, otherwise produces and
 * remembers a new instance (with the given initial values).
 */
@ExperimentalMotionApi
@Composable
fun rememberMotionLayoutState(
    key: Any = Unit,
    initialProgress: Float = 0f,
    initialDebugMode: MotionLayoutDebugFlags = MotionLayoutDebugFlags.NONE
): MotionLayoutState {
    val coroutineScope = rememberCoroutineScope()
    return remember(key) {
        MotionLayoutStateImpl(
            initialProgress = initialProgress,
            initialDebugMode = initialDebugMode,
            motionCoroutineScope = coroutineScope
        )
    }
}

/**
 * Convenience interface used for [MotionLayoutStateImpl.channel], to handle calls to [Animatable].
 */
@PublishedApi
internal interface MotionAnimationCommand {

    /**
     * Required parameters used for [Animatable.animateTo].
     */
    class Animate(
        val newProgress: Float,
        val animationSpec: AnimationSpec<Float>
    ) : MotionAnimationCommand

    /**
     * Required parameters used for [Animatable.snapTo].
     */
    class Snap(val newProgress: Float) : MotionAnimationCommand
}

/**
 * Internal representation to read and set values for the progress.
 */
@PublishedApi
internal interface MotionProgress {
    val progress: Float

    suspend fun updateProgress(newProgress: Float)
}