TextFieldScroll.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.text

import androidx.compose.animation.asDisposableClock
import androidx.compose.foundation.InteractionState
import androidx.compose.foundation.animation.defaultFlingConfig
import androidx.compose.foundation.gestures.ScrollableController
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.savedinstancestate.listSaver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.gesture.scrollorientationlocking.Orientation
import androidx.compose.ui.layout.LayoutModifier
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.platform.AmbientAnimationClock
import androidx.compose.ui.platform.AmbientLayoutDirection
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.min
import kotlin.math.roundToInt

// Scrollable
internal fun Modifier.textFieldScrollable(
    scrollerPosition: TextFieldScrollerPosition,
    interactionState: InteractionState? = null,
    enabled: Boolean = true
) = composed(
    inspectorInfo = debugInspectorInfo {
        name = "textFieldScrollable"
        properties["scrollerPosition"] = scrollerPosition
        properties["interactionState"] = interactionState
        properties["enabled"] = enabled
    }
) {
    // do not reverse direction only in case of RTL in horizontal orientation
    val rtl = AmbientLayoutDirection.current == LayoutDirection.Rtl
    val reverseDirection = scrollerPosition.orientation == Orientation.Vertical || !rtl
    val controller = rememberScrollableController(
        scrollerPosition,
        interactionState = interactionState
    ) { delta ->
        val newOffset = scrollerPosition.offset + delta
        val consumedDelta = when {
            newOffset > scrollerPosition.maximum ->
                scrollerPosition.maximum - scrollerPosition.offset
            newOffset < 0f -> -scrollerPosition.offset
            else -> delta
        }
        scrollerPosition.offset += consumedDelta
        consumedDelta
    }
    val scroll = Modifier.scrollable(
        orientation = scrollerPosition.orientation,
        canScroll = { scrollerPosition.maximum != 0f },
        reverseDirection = reverseDirection,
        controller = controller,
        enabled = enabled
    )
    scroll
}

// Layout
internal fun Modifier.textFieldScroll(
    scrollerPosition: TextFieldScrollerPosition,
    textFieldValue: TextFieldValue,
    visualTransformation: VisualTransformation,
    textLayoutResultProvider: () -> TextLayoutResultProxy?
): Modifier {
    val orientation = scrollerPosition.orientation
    val cursorOffset = scrollerPosition.getOffsetToFollow(textFieldValue.selection)
    scrollerPosition.previousSelection = textFieldValue.selection

    val transformedText = visualTransformation.filter(textFieldValue.annotatedString)

    val layout = when (orientation) {
        Orientation.Vertical ->
            VerticalScrollLayoutModifier(
                scrollerPosition,
                cursorOffset,
                transformedText,
                textLayoutResultProvider
            )
        Orientation.Horizontal ->
            HorizontalScrollLayoutModifier(
                scrollerPosition,
                cursorOffset,
                transformedText,
                textLayoutResultProvider
            )
    }
    return this.clipToBounds().then(layout)
}

@Composable
private fun rememberScrollableController(
    vararg inputs: Any?,
    interactionState: InteractionState? = null,
    consumeScrollDelta: (Float) -> Float
): ScrollableController {
    val clocks = AmbientAnimationClock.current.asDisposableClock()
    val flingConfig = defaultFlingConfig()
    return remember(inputs, clocks, flingConfig, interactionState) {
        ScrollableController(consumeScrollDelta, flingConfig, clocks, interactionState)
    }
}

private data class VerticalScrollLayoutModifier(
    val scrollerPosition: TextFieldScrollerPosition,
    val cursorOffset: Int,
    val transformedText: TransformedText,
    val textLayoutResultProvider: () -> TextLayoutResultProxy?
) : LayoutModifier {
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val childConstraints = constraints.copy(maxHeight = Constraints.Infinity)
        val placeable = measurable.measure(childConstraints)
        val height = min(placeable.height, constraints.maxHeight)

        return layout(placeable.width, height) {
            val cursorRect = getCursorRectInScroller(
                cursorOffset = cursorOffset,
                transformedText = transformedText,
                textLayoutResult = textLayoutResultProvider()?.value,
                rtl = false,
                textFieldWidth = placeable.width
            )

            scrollerPosition.update(
                orientation = Orientation.Vertical,
                cursorRect = cursorRect,
                containerSize = height,
                textFieldSize = placeable.height
            )

            val offset = -scrollerPosition.offset
            placeable.placeRelative(0, offset.roundToInt())
        }
    }
}

private data class HorizontalScrollLayoutModifier(
    val scrollerPosition: TextFieldScrollerPosition,
    val cursorOffset: Int,
    val transformedText: TransformedText,
    val textLayoutResultProvider: () -> TextLayoutResultProxy?
) : LayoutModifier {
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val childConstraints = constraints.copy(maxWidth = Constraints.Infinity)
        val placeable = measurable.measure(childConstraints)
        val width = min(placeable.width, constraints.maxWidth)

        return layout(width, placeable.height) {
            val cursorRect = getCursorRectInScroller(
                cursorOffset = cursorOffset,
                transformedText = transformedText,
                textLayoutResult = textLayoutResultProvider()?.value,
                rtl = layoutDirection == LayoutDirection.Rtl,
                textFieldWidth = placeable.width
            )

            scrollerPosition.update(
                orientation = Orientation.Horizontal,
                cursorRect = cursorRect,
                containerSize = width,
                textFieldSize = placeable.width
            )

            val offset = -scrollerPosition.offset
            placeable.placeRelative(offset.roundToInt(), 0)
        }
    }
}

private fun Density.getCursorRectInScroller(
    cursorOffset: Int,
    transformedText: TransformedText,
    textLayoutResult: TextLayoutResult?,
    rtl: Boolean,
    textFieldWidth: Int
): Rect {
    val cursorRect = textLayoutResult?.getCursorRect(
        transformedText.offsetMapping.originalToTransformed(cursorOffset)
    ) ?: Rect.Zero
    val thickness = DefaultCursorThickness.toIntPx()

    val cursorLeft = if (rtl) {
        textFieldWidth - cursorRect.left - thickness
    } else {
        cursorRect.left
    }

    val cursorRight = if (rtl) {
        textFieldWidth - cursorRect.left
    } else {
        cursorRect.left + thickness
    }
    return cursorRect.copy(left = cursorLeft, right = cursorRight)
}

@Stable
internal class TextFieldScrollerPosition(
    initialOrientation: Orientation,
    initial: Float = 0f,
) {

    /*@VisibleForTesting*/
    constructor() : this(Orientation.Vertical)

    /**
     * Left or top offset. Takes values from 0 to [maximum].
     * Taken with the opposite sign defines the x or y position of the text field in the
     * horizontal or vertical scroller container correspondingly.
     */
    var offset by mutableStateOf(initial, structuralEqualityPolicy())

    /**
     * Maximum length by which the text field can be scrolled. Defined as a difference in
     * size between the scroller container and the text field.
     */
    var maximum by mutableStateOf(Float.POSITIVE_INFINITY, structuralEqualityPolicy())
        private set

    /**
     * Keeps the cursor position before a new symbol has been typed or the text field has been
     * dragged. We check it to understand if the [offset] needs to be updated.
     */
    private var previousCursorRect: Rect = Rect.Zero

    /**
     * Keeps the previous selection data in TextFieldValue in order to identify what has changed
     * in the new selection, and decide which selection offset (start, end) to follow.
     */
    var previousSelection: TextRange = TextRange.Zero

    var orientation by mutableStateOf(initialOrientation, structuralEqualityPolicy())

    fun update(
        orientation: Orientation,
        cursorRect: Rect,
        containerSize: Int,
        textFieldSize: Int
    ) {
        val difference = (textFieldSize - containerSize).toFloat()
        maximum = difference

        if (cursorRect.left != previousCursorRect.left ||
            cursorRect.top != previousCursorRect.top
        ) {
            val vertical = orientation == Orientation.Vertical
            val cursorStart = if (vertical) cursorRect.top else cursorRect.left
            val cursorEnd = if (vertical) cursorRect.bottom else cursorRect.right
            coerceOffset(cursorStart, cursorEnd, containerSize)
            previousCursorRect = cursorRect
        }
        offset = offset.coerceIn(0f, difference)
    }

    private fun coerceOffset(cursorStart: Float, cursorEnd: Float, containerSize: Int) {
        val startVisibleBound = offset
        val endVisibleBound = startVisibleBound + containerSize
        if (cursorStart < startVisibleBound) {
            offset -= startVisibleBound - cursorStart
        } else if (cursorEnd > endVisibleBound) {
            offset += cursorEnd - endVisibleBound
        }
    }

    fun getOffsetToFollow(selection: TextRange): Int {
        return when {
            selection.start != previousSelection.start -> selection.start
            selection.end != previousSelection.end -> selection.end
            else -> selection.min
        }
    }

    companion object {
        val Saver = listSaver<TextFieldScrollerPosition, Any>(
            save = {
                listOf(it.offset, it.orientation == Orientation.Vertical)
            },
            restore = { restored ->
                TextFieldScrollerPosition(
                    if (restored[1] as Boolean) Orientation.Vertical else Orientation.Horizontal,
                    restored[0] as Float
                )
            }
        )
    }
}