Scrollable.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.
 */

package androidx.compose.foundation.gestures

import androidx.compose.animation.asDisposableClock
import androidx.compose.animation.core.AnimatedFloat
import androidx.compose.animation.core.AnimationClockObservable
import androidx.compose.animation.core.AnimationClockObserver
import androidx.compose.animation.core.AnimationEndReason
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Interaction
import androidx.compose.foundation.InteractionState
import androidx.compose.foundation.animation.FlingConfig
import androidx.compose.foundation.animation.defaultFlingConfig
import androidx.compose.foundation.animation.fling
import androidx.compose.runtime.AtomicReference
import androidx.compose.runtime.Composable
import androidx.compose.runtime.dispatch.withFrameMillis
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.onDispose
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.gesture.Direction
import androidx.compose.ui.gesture.ScrollCallback
import androidx.compose.ui.gesture.scrollorientationlocking.Orientation
import androidx.compose.ui.platform.AmbientAnimationClock
import androidx.compose.ui.platform.debugInspectorInfo
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

/**
 * Create and remember [ScrollableController] for [scrollable] with default [FlingConfig] and
 * [AnimationClockObservable]
 *
 * @param interactionState [InteractionState] that will be updated when this scrollable is
 * being scrolled by dragging, using [Interaction.Dragged]. If you want to know whether the fling
 * (or smooth scroll) is in progress, use [ScrollableController.isAnimationRunning].
 * @param consumeScrollDelta callback invoked when scrollable drag/fling/smooth scrolling occurs.
 * The callback receives the delta in pixels. Callers should update their state in this lambda
 * and return amount of delta consumed
 */
@Composable
fun rememberScrollableController(
    interactionState: InteractionState? = null,
    consumeScrollDelta: (Float) -> Float
): ScrollableController {
    val clocks = AmbientAnimationClock.current.asDisposableClock()
    val flingConfig = defaultFlingConfig()
    return remember(clocks, flingConfig, interactionState) {
        ScrollableController(consumeScrollDelta, flingConfig, clocks, interactionState)
    }
}

/**
 * Scope used for suspending scroll blocks
 */
interface ScrollScope {
    /**
     * Attempts to scroll forward by [pixels] px.
     *
     * @return the amount of the requested scroll that was consumed (that is, how far it scrolled)
     */
    fun scrollBy(pixels: Float): Float
}

/**
 * Controller to control the [scrollable] modifier with. Contains necessary information about the
 * ongoing fling and provides smooth scrolling capabilities.
 *
 * @param consumeScrollDelta callback invoked when drag/fling/smooth scrolling occurs. The
 * callback receives the delta in pixels. Callers should update their state in this lambda and
 * return the amount of delta consumed
 * @param flingConfig fling configuration to use for flinging
 * @param animationClock animation clock to run flinging and smooth scrolling on
 * @param interactionState [InteractionState] that will be updated when this scrollable is
 * being scrolled by dragging, using [Interaction.Dragged]. If you want to know whether the fling
 * (or smooth scroll) is in progress, use [ScrollableController.isAnimationRunning].
 */
class ScrollableController(
    internal val consumeScrollDelta: (Float) -> Float,
    internal val flingConfig: FlingConfig,
    animationClock: AnimationClockObservable,
    internal val interactionState: InteractionState? = null
) {
    /**
     * Smooth scroll by [value] amount of pixels
     *
     * @param value delta to scroll by
     * @param spec [AnimationSpec] to be used for this smooth scrolling
     * @param onEnd lambda to be called when smooth scrolling has ended
     */
    fun smoothScrollBy(
        value: Float,
        spec: AnimationSpec<Float> = SpringSpec(),
        onEnd: (endReason: AnimationEndReason, finishValue: Float) -> Unit = { _, _ -> }
    ) {
        val to = animatedFloat.value + value
        animatedFloat.animateTo(to, anim = spec, onEnd = onEnd)
    }

    /**
     * Smooth scroll by [value] pixels.
     *
     * Cancels the currently running scroll, if any, and suspends until the cancellation is
     * complete.
     *
     * @param value delta to scroll by
     * @param spec [AnimationSpec] to be used for this smooth scrolling
     *
     * @return the amount of scroll consumed
     */
    @OptIn(ExperimentalFoundationApi::class)
    suspend fun smoothScrollBy(
        value: Float,
        spec: AnimationSpec<Float> = spring()
    ): Float {
        val animSpec = spec.vectorize(Float.VectorConverter)
        val conv = Float.VectorConverter
        val zeroVector = conv.convertToVector(0f)
        val targetVector = conv.convertToVector(value)
        var previousValue = 0f

        scroll {
            val startTimeMillis = withFrameMillis { it }
            do {
                val finished = withFrameMillis { frameTimeMillis ->
                    val newValue = conv.convertFromVector(
                        animSpec.getValue(
                            playTime = frameTimeMillis - startTimeMillis,
                            start = zeroVector,
                            end = targetVector,
                            // TODO: figure out if/how we should incorporate existing velocity
                            startVelocity = zeroVector
                        )
                    )
                    val delta = newValue - previousValue
                    val consumed = scrollBy(delta)

                    if (consumed != delta) {
                        previousValue += consumed
                        true
                    } else {
                        previousValue = newValue
                        previousValue == value
                    }
                }
            } while (!finished)
        }
        return previousValue
    }

    private val scrollControlJob = AtomicReference<Job?>(null)
    private val scrollControlMutex = Mutex()

    private val scrollScope: ScrollScope = object : ScrollScope {
        override fun scrollBy(pixels: Float): Float = consumeScrollDelta(pixels)
    }

    /**
     * Call this function to take control of scrolling and gain the ability to send scroll events
     * via [ScrollScope.scrollBy]. All actions that change the logical scroll position must be
     * performed within a [scroll] block (even if they don't call any other methods on this
     * object) in order to guarantee that mutual exclusion is enforced.
     *
     * Cancels the currently running scroll, if any, and suspends until the cancellation is
     * complete.
     *
     * If [scroll] is called from elsewhere, this will be canceled.
     */
    suspend fun scroll(
        block: suspend ScrollScope.() -> Unit
    ): Unit = coroutineScope {
        stopFlingAnimation()
        val currentJob = coroutineContext[Job]
        scrollControlJob.getAndSet(currentJob)?.cancel()
        scrollControlMutex.withLock(currentJob) {
            // TODO: this is a workaround to make isAnimationRunning work for now by considering all
            //  suspend scrolls to be animations
            isAnimationRunningState.value = true
            scrollScope.block()
            isAnimationRunningState.value = false
        }
    }

    private val isAnimationRunningState = mutableStateOf(false)

    private val clocksProxy: AnimationClockObservable = object : AnimationClockObservable {
        override fun subscribe(observer: AnimationClockObserver) {
            isAnimationRunningState.value = true
            animationClock.subscribe(observer)
        }

        override fun unsubscribe(observer: AnimationClockObserver) {
            isAnimationRunningState.value = false
            animationClock.unsubscribe(observer)
        }
    }

    /**
     * whether this [ScrollableController] is currently scrolling via [scroll].
     *
     * Note: **all** scrolls initiated via [scroll] are considered to be animations, regardless of
     * whether they are actually performing an animation.  For instance, gestures that perform
     * scrolls via `scroll
     */
    val isAnimationRunning
        get() = isAnimationRunningState.value

    /**
     * The current velocity of the fling animation.
     *
     * Useful for handoff between animations
     */
    internal val velocity: Float
        get() = animatedFloat.velocity

    /**
     * Stop any ongoing animation, smooth scrolling or fling
     *
     * Call this to stop receiving scrollable deltas in [consumeScrollDelta]
     */
    internal fun stopFlingAnimation() {
        animatedFloat.stop()
    }

    /**
     * Stop any ongoing animation, smooth scrolling, fling, or any other scroll occurring via
     * [scroll].
     *
     * Call this to stop receiving scrollable deltas in [consumeScrollDelta]
     */
    fun stopAnimation() {
        stopFlingAnimation()
        scrollControlJob.getAndSet(null)?.cancel()
    }

    private val animatedFloat =
        DeltaAnimatedFloat(0f, clocksProxy, consumeScrollDelta)

    /**
     * current position for scrollable
     */
    internal var value: Float
        get() = animatedFloat.value
        set(value) = animatedFloat.snapTo(value)

    internal fun fling(velocity: Float, onScrollEnd: (Float) -> Unit) {
        animatedFloat.fling(
            config = flingConfig,
            startVelocity = velocity,
            onAnimationEnd = { _, _, velocityLeft ->
                onScrollEnd(velocityLeft)
            }
        )
    }
}

/**
 * Configure touch scrolling and flinging for the UI element in a single [Orientation].
 *
 * Users should update their state via [ScrollableController.consumeScrollDelta] and reflect
 * their own state in UI when using this component.
 *
 * [ScrollableController] is required for this modifier to work correctly. When constructing
 * [ScrollableController], you must provide a [ScrollableController.consumeScrollDelta] lambda,
 * which will be invoked whenever scroll happens (by gesture input, by smooth scrolling or
 * flinging) with the delta in pixels. The amount of scrolling delta consumed must be returned
 * from this lambda to ensure proper nested scrolling.
 *
 * @sample androidx.compose.foundation.samples.ScrollableSample
 *
 * @param orientation orientation of the scrolling
 * @param controller [ScrollableController] object that is responsible for redirecting scroll
 * deltas to [ScrollableController.consumeScrollDelta] callback and provides smooth scrolling
 * capabilities
 * @param enabled whether or not scrolling in enabled
 * @param reverseDirection reverse the direction of the scroll, so top to bottom scroll will
 * behave like bottom to top and left to right will behave like right to left.
 * @param canScroll callback to indicate whether or not scroll is allowed for given [Direction]
 * @param onScrollStarted callback to be invoked when scroll has started from the certain
 * position on the screen
 * @param onScrollStopped callback to be invoked when scroll stops with amount of velocity
 * unconsumed provided
 */
fun Modifier.scrollable(
    orientation: Orientation,
    controller: ScrollableController,
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    canScroll: (Direction) -> Boolean = { enabled },
    onScrollStarted: (startedPosition: Offset) -> Unit = {},
    onScrollStopped: (velocity: Float) -> Unit = {}
): Modifier = composed(
    factory = {
        onDispose {
            controller.stopAnimation()
            controller.interactionState?.removeInteraction(Interaction.Dragged)
        }

        val scrollCallback = object : ScrollCallback {

            override fun onStart(downPosition: Offset) {
                if (enabled) {
                    controller.stopFlingAnimation()
                    controller.interactionState?.addInteraction(Interaction.Dragged)
                    onScrollStarted(downPosition)
                }
            }

            override fun onScroll(scrollDistance: Float): Float {
                if (!enabled) return 0f
                controller.stopFlingAnimation()
                val toConsume = if (reverseDirection) scrollDistance * -1 else scrollDistance
                val consumed = controller.consumeScrollDelta(toConsume)
                controller.value = controller.value + consumed
                return if (reverseDirection) consumed * -1 else consumed
            }

            override fun onCancel() {
                controller.interactionState?.removeInteraction(Interaction.Dragged)
                if (enabled) {
                    onScrollStopped(0f)
                }
            }

            override fun onStop(velocity: Float) {
                controller.interactionState?.removeInteraction(Interaction.Dragged)
                if (enabled) {
                    controller.fling(
                        velocity = if (reverseDirection) velocity * -1 else velocity,
                        onScrollEnd = onScrollStopped
                    )
                }
            }
        }

        touchScrollable(
            scrollCallback = scrollCallback,
            orientation = orientation,
            canScroll = canScroll,
            startScrollImmediately = controller.isAnimationRunning
        ).mouseScrollable(
            scrollCallback,
            orientation
        )
    },
    inspectorInfo = debugInspectorInfo {
        name = "scrollable"
        properties["orientation"] = orientation
        properties["controller"] = controller
        properties["enabled"] = enabled
        properties["reverseDirection"] = reverseDirection
        properties["canScroll"] = canScroll
        properties["onScrollStarted"] = onScrollStarted
        properties["onScrollStopped"] = onScrollStopped
    }
)

internal expect fun Modifier.touchScrollable(
    scrollCallback: ScrollCallback,
    orientation: Orientation,
    canScroll: ((Direction) -> Boolean)?,
    startScrollImmediately: Boolean
): Modifier

// TODO(demin): think how we can move touchScrollable/mouseScrollable into commonMain,
//  so Android can support mouse wheel scrolling, and desktop can support touch scrolling.
//  For this we need first to implement different types of PointerInputEvent
//  (to differentiate mouse and touch)
internal expect fun Modifier.mouseScrollable(
    scrollCallback: ScrollCallback,
    orientation: Orientation
): Modifier

private class DeltaAnimatedFloat(
    initial: Float,
    clock: AnimationClockObservable,
    private val onDelta: (Float) -> Float
) : AnimatedFloat(clock, Spring.DefaultDisplacementThreshold) {

    override var value = initial
        set(value) {
            if (isRunning) {
                val delta = value - field
                onDelta(delta)
            }
            field = value
        }
}