blob: 29df3cd619bd40e49721f1c45b309edaee117881 [file] [log] [blame]
/*
* 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.text2.input.internal
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.neverEqualPolicy
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
/**
* Manages text layout for TextField including layout coordinates of decoration box and inner text
* field.
*/
@OptIn(ExperimentalFoundationApi::class)
internal class TextLayoutState {
private var layoutCache = TextFieldLayoutStateCache()
var onTextLayout: (Density.(() -> TextLayoutResult?) -> Unit)? = null
val layoutResult: TextLayoutResult? by layoutCache
/**
* Measured layout coordinates of the decoration box, core text field, and text layout node.
*
* DecoratorNode
* -------------------
* | CoreNode |--> Outer Decoration Box with padding
* | ------------- |
* | | | |
* | | |--|--> Visible inner text field
* | ------------- | (Below the dashed line is not visible)
* | | | |
* | | | |
* -------------------
* | |
* | |---> Scrollable part (TextLayoutNode)
* -------------
*
* These coordinates are used to calculate the relative positioning between multiple layers
* of a BasicTextField. For example, touches are processed by the decoration box but these
* should be converted to text layout positions to find out which character is pressed.
*
* [LayoutCoordinates] object returned from onGloballyPositioned callback is usually the same
* instance unless a node is detached and re-attached to the tree. To react to layout and
* positional changes even though the object never changes, we employ a neverEqualPolicy.
*/
var textLayoutNodeCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy())
var coreNodeCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy())
var decoratorNodeCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy())
/**
* Set to a non-zero value for single line TextFields in order to prevent text cuts.
*/
var minHeightForSingleLineField by mutableStateOf(0.dp)
/**
* Updates the [TextFieldLayoutStateCache] with inputs that don't come from the measure phase.
* This method will initialize the cache the first time it's called.
* If the new inputs require re-calculating text layout, any readers of [layoutResult] called
* from a snapshot observer will be invalidated.
*
* @see layoutWithNewMeasureInputs
*/
fun updateNonMeasureInputs(
textFieldState: TransformedTextFieldState,
textStyle: TextStyle,
singleLine: Boolean,
softWrap: Boolean,
) {
layoutCache.updateNonMeasureInputs(
textFieldState = textFieldState,
textStyle = textStyle,
singleLine = singleLine,
softWrap = softWrap,
)
}
/**
* Updates the [TextFieldLayoutStateCache] with inputs that come from the measure phase and returns the
* latest [TextLayoutResult]. If the measure inputs haven't changed significantly since the
* last call, this will be the cached result. If the new inputs require re-calculating text
* layout, any readers of [layoutResult] called from a snapshot observer will be invalidated.
*
* [updateNonMeasureInputs] must be called before this method to initialize the cache.
*/
fun layoutWithNewMeasureInputs(
density: Density,
layoutDirection: LayoutDirection,
fontFamilyResolver: FontFamily.Resolver,
constraints: Constraints,
): TextLayoutResult {
val layoutResult = layoutCache.layoutWithNewMeasureInputs(
density = density,
layoutDirection = layoutDirection,
fontFamilyResolver = fontFamilyResolver,
constraints = constraints,
)
onTextLayout?.let { onTextLayout ->
val textLayoutProvider = { layoutCache.value }
onTextLayout(density, textLayoutProvider)
}
return layoutResult
}
/**
* Translates the position of the touch on the screen to the position in text. Because touch
* is relative to the decoration box, we need to translate it to the inner text field's
* coordinates first before calculating position of the symbol in text.
*
* @param position original position of the gesture relative to the decoration box
* @param coerceInVisibleBounds if true and original [position] is outside visible bounds
* of the inner text field, the [position] will be shifted to the closest edge of the inner
* text field's visible bounds. This is useful when you have a decoration box
* bigger than the inner text field, so when user touches to the decoration box area, the cursor
* goes to the beginning or the end of the visible inner text field; otherwise if we put the
* cursor under the touch in the invisible part of the inner text field, it would scroll to
* make the cursor visible. This behavior is not needed, and therefore
* [coerceInVisibleBounds] should be set to false, when the user drags outside visible bounds
* to make a selection.
* @return The offset that corresponds to the [position]. Returns -1 if text layout has not
* been measured yet.
*/
fun getOffsetForPosition(position: Offset, coerceInVisibleBounds: Boolean = true): Int {
val layoutResult = layoutResult ?: return -1
val coercedPosition = if (coerceInVisibleBounds) {
position.coercedInVisibleBoundsOfInputText()
} else {
position
}
val relativePosition = fromDecorationToTextLayout(coercedPosition)
return layoutResult.getOffsetForPosition(relativePosition)
}
/**
* Returns true if the screen coordinates position (x,y) corresponds to a character displayed
* in the view. Returns false when the position is in the empty space of left/right of text.
* This function may return true even when [offset] is below or above the text layout.
*/
fun isPositionOnText(offset: Offset): Boolean {
val layoutResult = layoutResult ?: return false
val relativeOffset = fromDecorationToTextLayout(offset.coercedInVisibleBoundsOfInputText())
val line = layoutResult.getLineForVerticalPosition(relativeOffset.y)
return relativeOffset.x >= layoutResult.getLineLeft(line) &&
relativeOffset.x <= layoutResult.getLineRight(line)
}
/**
* If click on the decoration box happens outside visible inner text field, coerce the click
* position to the visible edges of the inner text field.
*/
private fun Offset.coercedInVisibleBoundsOfInputText(): Offset {
// If offset is outside visible bounds of the inner text field, use visible bounds edges
val visibleTextLayoutNodeRect =
textLayoutNodeCoordinates?.let { textLayoutNodeCoordinates ->
if (textLayoutNodeCoordinates.isAttached) {
decoratorNodeCoordinates?.localBoundingBoxOf(textLayoutNodeCoordinates)
} else {
Rect.Zero
}
} ?: Rect.Zero
return this.coerceIn(visibleTextLayoutNodeRect)
}
}
internal fun Offset.coerceIn(rect: Rect): Offset {
val xOffset = when {
x < rect.left -> rect.left
x > rect.right -> rect.right
else -> x
}
val yOffset = when {
y < rect.top -> rect.top
y > rect.bottom -> rect.bottom
else -> y
}
return Offset(xOffset, yOffset)
}
/**
* Translates a position from text layout node coordinates to core node coordinates.
*/
internal fun TextLayoutState.fromTextLayoutToCore(offset: Offset): Offset {
return textLayoutNodeCoordinates?.takeIf { it.isAttached }?.let { textLayoutNodeCoordinates ->
coreNodeCoordinates?.takeIf { it.isAttached }?.let { coreNodeCoordinates ->
coreNodeCoordinates.localPositionOf(textLayoutNodeCoordinates, offset)
}
} ?: offset
}
/**
* Translates the click happened on the decorator node to the position in the text layout node
* coordinates. This relative position is then used to determine symbol position in text using
* TextLayoutResult object.
*/
internal fun TextLayoutState.fromDecorationToTextLayout(offset: Offset): Offset {
return textLayoutNodeCoordinates?.let { textLayoutNodeCoordinates ->
decoratorNodeCoordinates?.let { decoratorNodeCoordinates ->
if (textLayoutNodeCoordinates.isAttached && decoratorNodeCoordinates.isAttached) {
textLayoutNodeCoordinates.localPositionOf(decoratorNodeCoordinates, offset)
} else {
offset
}
}
} ?: offset
}