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.core.AnimationState
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.animateDecay
import androidx.compose.animation.defaultDecayAnimationSpec
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.Orientation.Horizontal
import androidx.compose.foundation.gestures.Orientation.Vertical
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.consumePositionChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlin.math.abs

/**
 * Configure touch scrolling and flinging for the UI element in a single [Orientation].
 *
 * Users should update their state themselves using default [ScrollableState] and its
 * `consumeScrollDelta` callback or by implementing [ScrollableState] interface manually and reflect
 * their own state in UI when using this component.
 *
 * If you don't need to have fling or nested scroll support, but want to make component simply
 * draggable, consider using [draggable].
 *
 * @sample androidx.compose.foundation.samples.ScrollableSample
 *
 * @param state [ScrollableState] state of the scrollable. Defines how scroll events will be
 * interpreted by the user land logic and contains useful information about on-going events.
 * @param orientation orientation of the scrolling
 * @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 flingBehavior logic describing fling behavior when drag has finished with velocity. If
 * `null`, default from [ScrollableDefaults.flingBehavior] will be used.
 * @param interactionSource [MutableInteractionSource] that will be used to emit
 * drag events when this scrollable is being dragged.
 */
fun Modifier.scrollable(
    state: ScrollableState,
    orientation: Orientation,
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    flingBehavior: FlingBehavior? = null,
    interactionSource: MutableInteractionSource? = null
): Modifier = composed(
    inspectorInfo = debugInspectorInfo {
        name = "scrollable"
        properties["orientation"] = orientation
        properties["state"] = state
        properties["enabled"] = enabled
        properties["reverseDirection"] = reverseDirection
        properties["flingBehavior"] = flingBehavior
        properties["interactionSource"] = interactionSource
    },
    factory = {
        fun Float.reverseIfNeeded(): Float = if (reverseDirection) this * -1 else this
        touchScrollImplementation(
            interactionSource,
            orientation,
            reverseDirection,
            state,
            flingBehavior,
            enabled
        ).mouseScrollable(orientation) {
            state.dispatchRawDelta(it.reverseIfNeeded())
        }
    }
)

/**
 * Contains the default values used by [scrollable]
 */
object ScrollableDefaults {

    /**
     * Create and remember default [FlingBehavior] that will represent natural fling curve.
     */
    @Composable
    fun flingBehavior(): FlingBehavior {
        val flingSpec = defaultDecayAnimationSpec()
        return remember(flingSpec) {
            DefaultFlingBehavior(flingSpec)
        }
    }
}

// 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(
    orientation: Orientation,
    onScroll: (Float) -> Unit
): Modifier

@Suppress("ComposableModifierFactory")
@Composable
private fun Modifier.touchScrollImplementation(
    interactionSource: MutableInteractionSource?,
    orientation: Orientation,
    reverseDirection: Boolean,
    controller: ScrollableState,
    flingBehavior: FlingBehavior?,
    enabled: Boolean
): Modifier {
    val draggedInteraction = remember { mutableStateOf<DragInteraction.Start?>(null) }
    DisposableEffect(interactionSource) {
        onDispose {
            draggedInteraction.value?.let { interaction ->
                interactionSource?.tryEmit(DragInteraction.Cancel(interaction))
                draggedInteraction.value = null
            }
        }
    }

    val nestedScrollDispatcher = remember { mutableStateOf(NestedScrollDispatcher()) }
    val scrollLogic = rememberUpdatedState(
        ScrollingLogic(
            orientation,
            reverseDirection,
            nestedScrollDispatcher,
            controller,
            flingBehavior ?: ScrollableDefaults.flingBehavior()
        )
    )
    val nestedScrollConnection = remember { scrollableNestedScrollConnection(scrollLogic) }
    val orientationState = rememberUpdatedState(orientation)
    val enabledState = rememberUpdatedState(enabled)
    val controllerState = rememberUpdatedState(controller)
    val interactionSourceState = rememberUpdatedState(interactionSource)
    return dragForEachGesture(
        orientation = orientationState,
        enabled = enabledState,
        scrollableState = controllerState,
        nestedScrollDispatcher = nestedScrollDispatcher,
        interactionSource = interactionSourceState,
        dragStartInteraction = draggedInteraction,
        scrollLogic = scrollLogic
    ).nestedScroll(nestedScrollConnection, nestedScrollDispatcher.value)
}

@Suppress("ComposableModifierFactory")
@Composable
private fun Modifier.dragForEachGesture(
    orientation: State<Orientation>,
    enabled: State<Boolean>,
    scrollableState: State<ScrollableState>,
    nestedScrollDispatcher: State<NestedScrollDispatcher>,
    interactionSource: State<MutableInteractionSource?>,
    dragStartInteraction: MutableState<DragInteraction.Start?>,
    scrollLogic: State<ScrollingLogic>
): Modifier {
    fun isVertical() = orientation.value == Vertical

    fun Offset.axisValue() = this.run { if (isVertical()) y else x }

    suspend fun PointerInputScope.initialDown(): Pair<PointerInputChange?, Float> {
        var initialDelta = 0f
        return awaitPointerEventScope {
            val down = awaitFirstDown(requireUnconsumed = false)
            if (!enabled.value || down.type == PointerType.Mouse) {
                null to initialDelta
            } else if (scrollableState.value.isScrollInProgress) {
                // since we start immediately we don't wait for slop and set initial delta to 0
                initialDelta = 0f
                down to initialDelta
            } else {
                val onSlopPassed = { event: PointerInputChange, overSlop: Float ->
                    event.consumePositionChange()
                    initialDelta = overSlop
                }
                val result = if (isVertical()) {
                    awaitVerticalTouchSlopOrCancellation(down.id, onSlopPassed)
                } else {
                    awaitHorizontalTouchSlopOrCancellation(down.id, onSlopPassed)
                }
                (if (enabled.value) result else null) to initialDelta
            }
        }
    }

    suspend fun PointerInputScope.mainDragCycle(
        drag: PointerInputChange,
        initialDelta: Float,
        velocityTracker: VelocityTracker,
    ): Boolean {
        var result = false

        fun ScrollScope.touchDragTick(event: PointerInputChange) {
            velocityTracker.addPosition(event.uptimeMillis, event.position)
            val delta = event.positionChange().axisValue()
            if (enabled.value) {
                with(scrollLogic.value) {
                    dispatchScroll(delta, NestedScrollSource.Drag)
                }
            }
            event.consumePositionChange()
        }

        try {
            scrollableState.value.scroll(MutatePriority.UserInput) {
                awaitPointerEventScope {
                    if (enabled.value) {
                        with(scrollLogic.value) {
                            dispatchScroll(initialDelta, NestedScrollSource.Drag)
                        }
                    }
                    velocityTracker.addPosition(drag.uptimeMillis, drag.position)
                    val dragTick = { event: PointerInputChange ->
                        if (event.type != PointerType.Mouse) {
                            touchDragTick(event)
                        }
                    }
                    result = if (isVertical()) {
                        verticalDrag(drag.id, dragTick)
                    } else {
                        horizontalDrag(drag.id, dragTick)
                    }
                }
            }
        } catch (c: CancellationException) {
            result = false
        }
        return result
    }

    suspend fun fling(velocity: Velocity) {
        val preConsumedByParent = nestedScrollDispatcher.value.dispatchPreFling(velocity)
        val available = velocity - preConsumedByParent
        val velocityLeft = scrollLogic.value.doFlingAnimation(available)
        nestedScrollDispatcher.value.dispatchPostFling(available - velocityLeft, velocityLeft)
    }

    val scrollLambda: suspend PointerInputScope.() -> Unit = remember {
        {
            forEachGesture {
                val (startEvent, initialDelta) = initialDown()
                if (startEvent != null) {
                    val velocityTracker = VelocityTracker()
                    // remember enabled state when we add interaction to remove later if needed
                    val enabledWhenInteractionAdded = enabled.value
                    if (enabledWhenInteractionAdded) {
                        coroutineScope {
                            launch {
                                dragStartInteraction.value?.let { oldInteraction ->
                                    interactionSource.value?.emit(
                                        DragInteraction.Cancel(oldInteraction)
                                    )
                                }
                                val interaction = DragInteraction.Start()
                                interactionSource.value?.emit(interaction)
                                dragStartInteraction.value = interaction
                            }
                        }
                    }
                    val isDragSuccessful = mainDragCycle(startEvent, initialDelta, velocityTracker)
                    if (enabledWhenInteractionAdded) {
                        coroutineScope {
                            launch {
                                dragStartInteraction.value?.let { interaction ->
                                    interactionSource.value?.emit(
                                        DragInteraction.Stop(interaction)
                                    )
                                    dragStartInteraction.value = null
                                }
                            }
                        }
                    }
                    if (isDragSuccessful) {
                        nestedScrollDispatcher.value.coroutineScope.launch {
                            fling(velocityTracker.calculateVelocity())
                        }
                    }
                }
            }
        }
    }
    return pointerInput(scrollLambda, scrollLambda)
}

private class ScrollingLogic(
    val orientation: Orientation,
    val reverseDirection: Boolean,
    val nestedScrollDispatcher: State<NestedScrollDispatcher>,
    val scrollableState: ScrollableState,
    val flingBehavior: FlingBehavior
) {
    fun Float.toOffset(): Offset =
        if (orientation == Horizontal) Offset(this, 0f) else Offset(0f, this)

    fun Float.toVelocity(): Velocity =
        if (orientation == Horizontal) Velocity(this, 0f) else Velocity(0f, this)

    fun Offset.toFloat(): Float =
        if (orientation == Horizontal) this.x else this.y

    fun Velocity.toFloat(): Float =
        if (orientation == Horizontal) this.x else this.y

    fun Float.reverseIfNeeded(): Float = if (reverseDirection) this * -1 else this

    fun ScrollScope.dispatchScroll(scrollDelta: Float, source: NestedScrollSource): Float {
        val scrollOffset = scrollDelta.toOffset()
        val preConsumedByParent = nestedScrollDispatcher.value
            .dispatchPreScroll(scrollOffset, source)

        val scrollAvailable = scrollOffset - preConsumedByParent
        val consumed = scrollBy(scrollAvailable.toFloat().reverseIfNeeded())
            .reverseIfNeeded().toOffset()
        val leftForParent = scrollAvailable - consumed
        nestedScrollDispatcher.value.dispatchPostScroll(consumed, leftForParent, source)
        return leftForParent.toFloat()
    }

    fun performRawScroll(scroll: Offset): Offset {
        return if (scrollableState.isScrollInProgress) {
            Offset.Zero
        } else {
            scrollableState.dispatchRawDelta(scroll.toFloat().reverseIfNeeded())
                .reverseIfNeeded().toOffset()
        }
    }

    suspend fun doFlingAnimation(available: Velocity): Velocity {
        var result: Velocity = available
        // come up with the better threshold, but we need it since spline curve gives us NaNs
        if (abs(available.toFloat()) > 1f) scrollableState.scroll {
            val outerScopeScroll: (Float) -> Float =
                { delta -> this.dispatchScroll(delta, NestedScrollSource.Fling) }
            val scope = object : ScrollScope {
                override fun scrollBy(pixels: Float): Float {
                    return outerScopeScroll.invoke(pixels)
                }
            }
            with(scope) {
                with(flingBehavior) {
                    result = performFling(available.toFloat()).toVelocity()
                }
            }
        }
        return result
    }
}

private fun scrollableNestedScrollConnection(
    scrollLogic: State<ScrollingLogic>
): NestedScrollConnection = object : NestedScrollConnection {
    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset = scrollLogic.value.performRawScroll(available)

    override suspend fun onPostFling(
        consumed: Velocity,
        available: Velocity
    ): Velocity {
        val velocityLeft = scrollLogic.value.doFlingAnimation(available)
        return available - velocityLeft
    }
}

private class DefaultFlingBehavior(
    private val flingDecay: DecayAnimationSpec<Float>
) : FlingBehavior {
    override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
        var velocityLeft = initialVelocity
        var lastValue = 0f
        AnimationState(
            initialValue = 0f,
            initialVelocity = initialVelocity,
        ).animateDecay(flingDecay) {
            val delta = value - lastValue
            val left = scrollBy(delta)
            lastValue = value
            velocityLeft = this.velocity
            // avoid rounding errors and stop if anything is unconsumed
            if (abs(left) > 0.5f) this.cancelAnimation()
        }
        return velocityLeft
    }
}