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.foundation.gestures.rememberScrollableController
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.savedinstancestate.Saver
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.node.Ref
import androidx.compose.ui.platform.AmbientLayoutDirection
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
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

internal fun Modifier.textFieldScroll(
    orientation: Orientation,
    scrollerPosition: TextFieldScrollerPosition,
    textFieldValue: TextFieldValue,
    visualTransformation: VisualTransformation,
    textLayoutResult: Ref<TextLayoutResult?>
) = composed(
    factory = {
        // do not reverse direction only in case of RTL in horizontal orientation
        val rtl = AmbientLayoutDirection.current == LayoutDirection.Rtl
        val reverseDirection = orientation == Orientation.Vertical || !rtl
        val scroll = Modifier.scrollable(
            orientation = orientation,
            canScroll = { scrollerPosition.maximum != 0f },
            reverseDirection = reverseDirection,
            controller = rememberScrollableController { 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 transformedText = visualTransformation.filter(
            AnnotatedString(textFieldValue.text)
        )
        val layout = when (orientation) {
            Orientation.Vertical ->
                VerticalScrollLayoutModifier(
                    scrollerPosition,
                    textFieldValue,
                    transformedText,
                    textLayoutResult
                )
            Orientation.Horizontal ->
                HorizontalScrollLayoutModifier(
                    scrollerPosition,
                    textFieldValue,
                    transformedText,
                    textLayoutResult
                )
        }
        this.then(scroll).clipToBounds().then(layout)
    },
    inspectorInfo = debugInspectorInfo {
        name = "textFieldScroll"
        properties["orientation"] = orientation
        properties["scrollerPosition"] = scrollerPosition
        properties["textFieldValue"] = textFieldValue
        properties["visualTransformation"] = visualTransformation
        properties["textLayoutResult"] = textLayoutResult
    }
)

private data class VerticalScrollLayoutModifier(
    val scrollerPosition: TextFieldScrollerPosition,
    val textFieldValue: TextFieldValue,
    val transformedText: TransformedText,
    val textLayoutResult: Ref<TextLayoutResult?>
) : 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(
                textFieldValue = textFieldValue,
                transformedText = transformedText,
                textLayoutResult = textLayoutResult,
                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 textFieldValue: TextFieldValue,
    val transformedText: TransformedText,
    val textLayoutResult: Ref<TextLayoutResult?>
) : 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(
                textFieldValue = textFieldValue,
                transformedText = transformedText,
                textLayoutResult = textLayoutResult,
                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(
    textFieldValue: TextFieldValue,
    transformedText: TransformedText,
    textLayoutResult: Ref<TextLayoutResult?>,
    rtl: Boolean,
    textFieldWidth: Int
): Rect {
    val cursorRect = textLayoutResult.value?.getCursorRect(
        transformedText.offsetMap.originalToTransformed(textFieldValue.selection.min)
    ) ?: 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(initial: Float = 0f) {
    /**
     * 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

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

    companion object {
        val Saver = Saver<TextFieldScrollerPosition, Float>(
            save = { it.offset },
            restore = { restored ->
                TextFieldScrollerPosition(
                    initial = restored
                )
            }
        )
    }
}