commonMain.androidx.compose.foundation.text.TextFieldScroll.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of foundation-desktop Show documentation
Show all versions of foundation-desktop Show documentation
Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers
/*
* Copyright 2023 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.ExperimentalFoundationApi
import androidx.compose.foundation.OverscrollEffect
import androidx.compose.foundation.clipScrollableContainer
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.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.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.LocalLayoutDirection
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
@ExperimentalFoundationApi
@Composable
internal expect fun rememberTextFieldOverscrollEffect(): OverscrollEffect?
// Scrollable
internal fun Modifier.textFieldScrollable(
scrollerPosition: TextFieldScrollerPosition,
interactionSource: MutableInteractionSource? = null,
enabled: Boolean = true,
overscrollEffect: OverscrollEffect? = null
) = composed(
inspectorInfo = debugInspectorInfo {
name = "textFieldScrollable"
properties["scrollerPosition"] = scrollerPosition
properties["interactionSource"] = interactionSource
properties["enabled"] = enabled
}
) {
// do not reverse direction only in case of RTL in horizontal orientation
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val reverseDirection = scrollerPosition.orientation == Orientation.Vertical || !rtl
val scrollableState = rememberScrollableState { 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
}
// TODO: b/255557085 remove when / if rememberScrollableState exposes lambda parameters for
// setting these
val wrappedScrollableState = remember(scrollableState, scrollerPosition) {
createScrollableState(scrollableState, scrollerPosition)
}
val scroll = Modifier.scrollable(
orientation = scrollerPosition.orientation,
reverseDirection = reverseDirection,
overscrollEffect = overscrollEffect,
state = wrappedScrollableState,
interactionSource = interactionSource,
enabled = enabled && scrollerPosition.maximum != 0f
)
scroll
}
// Workaround for K/JS
private inline fun createScrollableState(
scrollableState: ScrollableState,
scrollerPosition: TextFieldScrollerPosition
): ScrollableState {
return object : ScrollableState by scrollableState {
override val canScrollForward by derivedStateOf {
scrollerPosition.offset < scrollerPosition.maximum
}
override val canScrollBackward by derivedStateOf { scrollerPosition.offset > 0f }
}
}
// Layout
internal expect fun Modifier.textFieldScroll(
scrollerPosition: TextFieldScrollerPosition,
textFieldValue: TextFieldValue,
visualTransformation: VisualTransformation,
textLayoutResultProvider: () -> TextLayoutResultProxy?
): Modifier
internal fun Modifier.defaultTextFieldScroll(
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.filterWithValidation(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)
}
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 {
// If the maxIntrinsicWidth of the children is already smaller than the constraint, pass
// the original constraints so that the children has more information to determine its
// size.
val maxIntrinsicWidth = measurable.maxIntrinsicWidth(constraints.maxHeight)
val childConstraints = if (maxIntrinsicWidth < constraints.maxWidth) {
constraints
} else {
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.roundToPx()
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 mutableFloatStateOf(initial)
/**
* 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 mutableFloatStateOf(0f)
private set
/**
* Size of the visible part, on the scrollable axis, in pixels.
*/
var viewportSize by mutableStateOf(0)
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)
viewportSize = containerSize
}
/*@VisibleForTesting*/
internal fun coerceOffset(cursorStart: Float, cursorEnd: Float, containerSize: Int) {
val startVisibleBound = offset
val endVisibleBound = startVisibleBound + containerSize
val offsetDifference = when {
// make bottom/end of the cursor visible
//
// text box
// +----------------------+
// | |
// | |
// | cursor |
// | | |
// +-------------|--------+
// |
//
cursorEnd > endVisibleBound -> cursorEnd - endVisibleBound
// in rare cases when there's not enough space to fit the whole cursor, prioritise
// the bottom/end of the cursor
//
// cursor
// text box |
// +-------------|--------+
// | | |
// +-------------|--------+
// |
//
cursorStart < startVisibleBound && cursorEnd - cursorStart > containerSize ->
cursorEnd - endVisibleBound
// make top/start of the cursor visible if there's enough space to fit the whole cursor
//
// cursor
// text box |
// +--------------|-------+
// | | |
// | |
// | |
// | |
// +----------------------+
//
cursorStart < startVisibleBound && cursorEnd - cursorStart <= containerSize ->
cursorStart - startVisibleBound
// otherwise keep current offset
else -> 0f
}
offset += offsetDifference
}
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(
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
)
}
)
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy