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.foundation.animation.FlingConfig
import androidx.compose.foundation.animation.defaultFlingConfig
import androidx.compose.foundation.animation.fling
import androidx.compose.runtime.Composable
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.AnimationClockAmbient

/**
 * Create and remember [ScrollableController] for [scrollable] with default [FlingConfig] and
 * [AnimationClockObservable]
 *
 * @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(
    consumeScrollDelta: (Float) -> Float
): ScrollableController {
    val clocks = AnimationClockAmbient.current.asDisposableClock()
    val flingConfig = defaultFlingConfig()
    return remember(clocks, flingConfig) {
        ScrollableController(consumeScrollDelta, flingConfig, clocks)
    }
}

/**
 * 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
 */
class ScrollableController(
    val consumeScrollDelta: (Float) -> Float,
    val flingConfig: FlingConfig,
    animationClock: AnimationClockObservable
) {
    /**
     * 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)
    }

    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 animating/flinging
     */
    val isAnimationRunning
        get() = isAnimationRunningState.value

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

    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 {
    onDispose {
        controller.stopAnimation()
    }

    val scrollCallback = object : ScrollCallback {

        override fun onStart(downPosition: Offset) {
            if (enabled) {
                controller.stopAnimation()
                onScrollStarted(downPosition)
            }
        }

        override fun onScroll(scrollDistance: Float): Float {
            if (!enabled) return 0f
            controller.stopAnimation()
            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() {
            if (enabled) onScrollStopped(0f)
        }

        override fun onStop(velocity: Float) {
            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
    )
}

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
        }
}