All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.androidx.compose.foundation.gestures.TransformableState.kt Maven / Gradle / Ivy

/*
 * Copyright 2021 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.compose.foundation.gestures

import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.AnimationVector
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.AnimationVector2D
import androidx.compose.animation.core.AnimationVector4D
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.TwoWayConverter
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VectorizedAnimationSpec
import androidx.compose.animation.core.VectorizedFiniteAnimationSpec
import androidx.compose.animation.core.animateTo
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.internal.JvmDefaultWithCompatibility
import androidx.compose.foundation.internal.requirePrecondition
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.geometry.Offset
import kotlinx.coroutines.coroutineScope

/**
 * State of [transformable]. Allows for a granular control of how different gesture transformations
 * are consumed by the user as well as to write custom transformation methods using [transform]
 * suspend function.
 */
@JvmDefaultWithCompatibility
interface TransformableState {
    /**
     * Call this function to take control of transformations and gain the ability to send transform
     * events via [TransformScope.transformBy]. All actions that change zoom, pan or rotation values
     * must be performed within a [transform] block (even if they don't call any other methods on
     * this object) in order to guarantee that mutual exclusion is enforced.
     *
     * If [transform] is called from elsewhere with the [transformPriority] higher or equal to
     * ongoing transform, ongoing transform will be canceled.
     */
    suspend fun transform(
        transformPriority: MutatePriority = MutatePriority.Default,
        block: suspend TransformScope.() -> Unit
    )

    /**
     * Whether this [TransformableState] is currently transforming by gesture or programmatically or
     * not.
     */
    val isTransformInProgress: Boolean
}

/** Scope used for suspending transformation operations */
@JvmDefaultWithCompatibility
interface TransformScope {
    /**
     * Attempts to transform by [zoomChange] in relative multiplied value, by [panChange] in pixels
     * and by [rotationChange] in degrees.
     *
     * @param zoomChange scale factor multiplier change for zoom
     * @param panChange panning offset change, in [Offset] pixels
     * @param rotationChange change of the rotation in degrees
     */
    fun transformBy(
        zoomChange: Float = 1f,
        panChange: Offset = Offset.Zero,
        rotationChange: Float = 0f
    )
}

/**
 * Default implementation of [TransformableState] interface that contains necessary information
 * about the ongoing transformations and provides smooth transformation capabilities.
 *
 * This is the simplest way to set up a [transformable] modifier. When constructing this
 * [TransformableState], you must provide a [onTransformation] lambda, which will be invoked
 * whenever pan, zoom or rotation happens (by gesture input or any [TransformableState.transform]
 * call) with the deltas from the previous event.
 *
 * @param onTransformation callback invoked when transformation occurs. The callback receives the
 *   change from the previous event. It's relative scale multiplier for zoom, [Offset] in pixels for
 *   pan and degrees for rotation. Callers should update their state in this lambda.
 */
fun TransformableState(
    onTransformation: (zoomChange: Float, panChange: Offset, rotationChange: Float) -> Unit
): TransformableState = DefaultTransformableState(onTransformation)

/**
 * Create and remember default implementation of [TransformableState] interface that contains
 * necessary information about the ongoing transformations and provides smooth transformation
 * capabilities.
 *
 * This is the simplest way to set up a [transformable] modifier. When constructing this
 * [TransformableState], you must provide a [onTransformation] lambda, which will be invoked
 * whenever pan, zoom or rotation happens (by gesture input or any [TransformableState.transform]
 * call) with the deltas from the previous event.
 *
 * @param onTransformation callback invoked when transformation occurs. The callback receives the
 *   change from the previous event. It's relative scale multiplier for zoom, [Offset] in pixels for
 *   pan and degrees for rotation. Callers should update their state in this lambda.
 */
@Composable
fun rememberTransformableState(
    onTransformation: (zoomChange: Float, panChange: Offset, rotationChange: Float) -> Unit
): TransformableState {
    val lambdaState = rememberUpdatedState(onTransformation)
    return remember { TransformableState { z, p, r -> lambdaState.value.invoke(z, p, r) } }
}

/**
 * Animate zoom by a ratio of [zoomFactor] over the current size and suspend until its finished.
 *
 * @param zoomFactor ratio over the current size by which to zoom. For example, if [zoomFactor] is
 *   `3f`, zoom will be increased 3 fold from the current value.
 * @param animationSpec [AnimationSpec] to be used for animation
 */
suspend fun TransformableState.animateZoomBy(
    zoomFactor: Float,
    animationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow)
) {
    requirePrecondition(zoomFactor > 0) { "zoom value should be greater than 0" }
    var previous = 1f
    transform {
        AnimationState(initialValue = previous).animateTo(zoomFactor, animationSpec) {
            val scaleFactor = if (previous == 0f) 1f else this.value / previous
            transformBy(zoomChange = scaleFactor)
            previous = this.value
        }
    }
}

/**
 * Animate rotate by a ratio of [degrees] clockwise and suspend until its finished.
 *
 * @param degrees the degrees by which to rotate clockwise
 * @param animationSpec [AnimationSpec] to be used for animation
 */
suspend fun TransformableState.animateRotateBy(
    degrees: Float,
    animationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow)
) {
    var previous = 0f
    transform {
        AnimationState(initialValue = previous).animateTo(degrees, animationSpec) {
            val delta = this.value - previous
            transformBy(rotationChange = delta)
            previous = this.value
        }
    }
}

/**
 * Animate pan by [offset] Offset in pixels and suspend until its finished
 *
 * @param offset offset to pan, in pixels
 * @param animationSpec [AnimationSpec] to be used for pan animation
 */
suspend fun TransformableState.animatePanBy(
    offset: Offset,
    animationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow)
) {
    var previous = Offset.Zero
    transform {
        AnimationState(typeConverter = Offset.VectorConverter, initialValue = previous).animateTo(
            offset,
            animationSpec
        ) {
            val delta = this.value - previous
            transformBy(panChange = delta)
            previous = this.value
        }
    }
}

/**
 * Animate zoom, pan, and rotation simultaneously and suspend until the animation is finished.
 *
 * Zoom is animated by a ratio of [zoomFactor] over the current size. Pan is animated by [panOffset]
 * in pixels. Rotation is animated by the value of [rotationDegrees] clockwise. Any of these
 * parameters can be set to a no-op value that will result in no animation of that parameter. The
 * no-op values are the following: `1f` for [zoomFactor], `Offset.Zero` for [panOffset], and `0f`
 * for [rotationDegrees].
 *
 * @sample androidx.compose.foundation.samples.TransformableAnimateBySample
 * @param zoomFactor ratio over the current size by which to zoom. For example, if [zoomFactor] is
 *   `3f`, zoom will be increased 3 fold from the current value.
 * @param panOffset offset to pan, in pixels
 * @param rotationDegrees the degrees by which to rotate clockwise
 * @param zoomAnimationSpec [AnimationSpec] to be used for animating zoom
 * @param panAnimationSpec [AnimationSpec] to be used for animating offset
 * @param rotationAnimationSpec [AnimationSpec] to be used for animating rotation
 */
suspend fun TransformableState.animateBy(
    zoomFactor: Float,
    panOffset: Offset,
    rotationDegrees: Float,
    zoomAnimationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow),
    panAnimationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow),
    rotationAnimationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow)
) {
    requirePrecondition(zoomFactor > 0) { "zoom value should be greater than 0" }
    var previousState = AnimationData(zoom = 1f, offset = Offset.Zero, degrees = 0f)
    val targetState = AnimationData(zoomFactor, panOffset, rotationDegrees)
    val animationSpec =
        DelegatingAnimationSpec(zoomAnimationSpec, panAnimationSpec, rotationAnimationSpec)
    transform {
        AnimationState(
                typeConverter = AnimationDataConverter,
                initialValue = previousState,
                initialVelocity = ZeroAnimationVelocity
            )
            .animateTo(targetState, animationSpec) {
                transformBy(
                    zoomChange =
                        if (previousState.zoom == 0f) 1f else value.zoom / previousState.zoom,
                    rotationChange = value.degrees - previousState.degrees,
                    panChange = value.offset - previousState.offset
                )
                previousState = value
            }
    }
}

private val ZeroAnimationVelocity = AnimationData(zoom = 0f, offset = Offset.Zero, degrees = 0f)

private class DelegatingAnimationSpec(
    private val zoomAnimationSpec: AnimationSpec,
    private val offsetAnimationSpec: AnimationSpec,
    private val rotationAnimationSpec: AnimationSpec
) : AnimationSpec {
    override fun  vectorize(
        converter: TwoWayConverter
    ): VectorizedAnimationSpec {
        val vectorizedZoomAnimationSpec = zoomAnimationSpec.vectorize(Float.VectorConverter)
        val vectorizedOffsetAnimationSpec = offsetAnimationSpec.vectorize(Offset.VectorConverter)
        val vectorizedRotationAnimationSpec = rotationAnimationSpec.vectorize(Float.VectorConverter)
        return object : VectorizedFiniteAnimationSpec {
            override fun getDurationNanos(
                initialValue: V,
                targetValue: V,
                initialVelocity: V
            ): Long {
                val initialAnimationData = converter.convertFromVector(initialValue)
                val targetAnimationData = converter.convertFromVector(targetValue)
                val initialVelocityAnimationData = converter.convertFromVector(initialVelocity)

                return maxOf(
                    vectorizedZoomAnimationSpec.getDurationNanos(
                        initialAnimationData.zoomVector(),
                        targetAnimationData.zoomVector(),
                        initialVelocityAnimationData.zoomVector()
                    ),
                    vectorizedOffsetAnimationSpec.getDurationNanos(
                        initialAnimationData.offsetVector(),
                        targetAnimationData.offsetVector(),
                        initialVelocityAnimationData.offsetVector()
                    ),
                    vectorizedRotationAnimationSpec.getDurationNanos(
                        initialAnimationData.degreesVector(),
                        targetAnimationData.degreesVector(),
                        initialVelocityAnimationData.degreesVector()
                    )
                )
            }

            override fun getVelocityFromNanos(
                playTimeNanos: Long,
                initialValue: V,
                targetValue: V,
                initialVelocity: V
            ): V {
                val initialAnimationData = converter.convertFromVector(initialValue)
                val targetAnimationData = converter.convertFromVector(targetValue)
                val initialVelocityAnimationData = converter.convertFromVector(initialVelocity)

                val zoomVelocity =
                    vectorizedZoomAnimationSpec.getVelocityFromNanos(
                        playTimeNanos,
                        initialAnimationData.zoomVector(),
                        targetAnimationData.zoomVector(),
                        initialVelocityAnimationData.zoomVector()
                    )
                val offsetVelocity =
                    vectorizedOffsetAnimationSpec.getVelocityFromNanos(
                        playTimeNanos,
                        initialAnimationData.offsetVector(),
                        targetAnimationData.offsetVector(),
                        initialVelocityAnimationData.offsetVector()
                    )
                val rotationVelocity =
                    vectorizedRotationAnimationSpec.getVelocityFromNanos(
                        playTimeNanos,
                        initialAnimationData.degreesVector(),
                        targetAnimationData.degreesVector(),
                        initialVelocityAnimationData.degreesVector()
                    )

                return packToAnimationVector(zoomVelocity, offsetVelocity, rotationVelocity)
            }

            override fun getValueFromNanos(
                playTimeNanos: Long,
                initialValue: V,
                targetValue: V,
                initialVelocity: V
            ): V {
                val initialAnimationData = converter.convertFromVector(initialValue)
                val targetAnimationData = converter.convertFromVector(targetValue)
                val initialVelocityAnimationData = converter.convertFromVector(initialVelocity)

                val zoomValue =
                    vectorizedZoomAnimationSpec.getValueFromNanos(
                        playTimeNanos,
                        initialAnimationData.zoomVector(),
                        targetAnimationData.zoomVector(),
                        initialVelocityAnimationData.zoomVector()
                    )
                val offsetValue =
                    vectorizedOffsetAnimationSpec.getValueFromNanos(
                        playTimeNanos,
                        initialAnimationData.offsetVector(),
                        targetAnimationData.offsetVector(),
                        initialVelocityAnimationData.offsetVector()
                    )
                val rotationValue =
                    vectorizedRotationAnimationSpec.getValueFromNanos(
                        playTimeNanos,
                        initialAnimationData.degreesVector(),
                        targetAnimationData.degreesVector(),
                        initialVelocityAnimationData.degreesVector()
                    )

                return packToAnimationVector(zoomValue, offsetValue, rotationValue)
            }

            private fun AnimationData.zoomVector() =
                Float.VectorConverter.convertToVector(this.zoom)

            private fun AnimationData.offsetVector() =
                Offset.VectorConverter.convertToVector(Offset(this.offset.x, this.offset.y))

            private fun AnimationData.degreesVector() =
                Float.VectorConverter.convertToVector(this.degrees)

            private fun packToAnimationVector(
                zoom: AnimationVector1D,
                offset: AnimationVector2D,
                rotation: AnimationVector1D
            ): V =
                converter.convertToVector(
                    AnimationData(zoom.value, Offset(offset.v1, offset.v2), rotation.value)
                )
        }
    }
}

private object AnimationDataConverter : TwoWayConverter {
    override val convertToVector: (AnimationData) -> AnimationVector4D
        get() = { AnimationVector4D(it.zoom, it.offset.x, it.offset.y, it.degrees) }

    override val convertFromVector: (AnimationVector4D) -> AnimationData
        get() = { AnimationData(zoom = it.v1, offset = Offset(it.v2, it.v3), degrees = it.v4) }
}

private data class AnimationData(val zoom: Float, val offset: Offset, val degrees: Float)

/**
 * Zoom without animation by a ratio of [zoomFactor] over the current size and suspend until it's
 * set.
 *
 * @param zoomFactor ratio over the current size by which to zoom
 */
suspend fun TransformableState.zoomBy(zoomFactor: Float) = transform {
    transformBy(zoomFactor, Offset.Zero, 0f)
}

/**
 * Rotate without animation by a [degrees] degrees and suspend until it's set.
 *
 * @param degrees degrees by which to rotate
 */
suspend fun TransformableState.rotateBy(degrees: Float) = transform {
    transformBy(1f, Offset.Zero, degrees)
}

/**
 * Pan without animation by a [offset] Offset in pixels and suspend until it's set.
 *
 * @param offset offset in pixels by which to pan
 */
suspend fun TransformableState.panBy(offset: Offset) = transform { transformBy(1f, offset, 0f) }

/**
 * Stop and suspend until any ongoing [TransformableState.transform] with priority
 * [terminationPriority] or lower is terminated.
 *
 * @param terminationPriority transformation that runs with this priority or lower will be stopped
 */
suspend fun TransformableState.stopTransformation(
    terminationPriority: MutatePriority = MutatePriority.Default
) {
    this.transform(terminationPriority) {
        // do nothing, just lock the mutex so other scroll actors are cancelled
    }
}

private class DefaultTransformableState(
    val onTransformation: (zoomChange: Float, panChange: Offset, rotationChange: Float) -> Unit
) : TransformableState {

    private val transformScope: TransformScope =
        object : TransformScope {
            override fun transformBy(zoomChange: Float, panChange: Offset, rotationChange: Float) =
                onTransformation(zoomChange, panChange, rotationChange)
        }

    private val transformMutex = MutatorMutex()

    private val isTransformingState = mutableStateOf(false)

    override suspend fun transform(
        transformPriority: MutatePriority,
        block: suspend TransformScope.() -> Unit
    ): Unit = coroutineScope {
        transformMutex.mutateWith(transformScope, transformPriority) {
            isTransformingState.value = true
            try {
                block()
            } finally {
                isTransformingState.value = false
            }
        }
    }

    override val isTransformInProgress: Boolean
        get() = isTransformingState.value
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy