blob: 54c30a78bba2605e6f61288a33e8ffbdfe743e58 [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.selection
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.text.DefaultCursorThickness
import androidx.compose.foundation.text.Handle
import androidx.compose.foundation.text.selection.SelectionAdjustment
import androidx.compose.foundation.text.selection.SelectionLayout
import androidx.compose.foundation.text.selection.containsInclusive
import androidx.compose.foundation.text.selection.getAdjustedCoordinates
import androidx.compose.foundation.text.selection.getSelectionHandleCoordinates
import androidx.compose.foundation.text.selection.getTextFieldSelectionLayout
import androidx.compose.foundation.text.selection.isPrecisePointer
import androidx.compose.foundation.text.selection.visibleBounds
import androidx.compose.foundation.text2.input.TextFieldCharSequence
import androidx.compose.foundation.text2.input.getSelectedText
import androidx.compose.foundation.text2.input.internal.TextLayoutState
import androidx.compose.foundation.text2.input.internal.TransformedTextFieldState
import androidx.compose.foundation.text2.input.internal.coerceIn
import androidx.compose.foundation.text2.input.internal.fromDecorationToTextLayout
import androidx.compose.foundation.text2.input.internal.undo.TextFieldEditUndoBehavior
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.geometry.isUnspecified
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.max
import kotlin.math.min
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
internal class TextFieldSelectionState(
private val textFieldState: TransformedTextFieldState,
private val textLayoutState: TextLayoutState,
private var density: Density,
private var enabled: Boolean,
private var readOnly: Boolean,
var isFocused: Boolean, /* true iff component is focused and the window is focused */
) {
/**
* [HapticFeedback] handle to perform haptic feedback.
*/
private var hapticFeedBack: HapticFeedback? = null
/**
* [TextToolbar] to show floating toolbar(post-M) or primary toolbar(pre-M).
*/
private var textToolbar: TextToolbar? = null
/**
* [ClipboardManager] to perform clipboard features.
*/
private var clipboardManager: ClipboardManager? = null
/**
* Whether user is interacting with the UI in touch mode.
*/
var isInTouchMode: Boolean by mutableStateOf(true)
private set
/**
* The offset of visible bounds when dragging is started by a cursor or a selection handle.
* Total drag value needs to account for any auto scrolling that happens during dragging of a
* handle.
* This value is an anchor to calculate how much the visible bounds have shifted as the
* dragging continues. If a cursor or a selection handle is not dragging, this value needs to be
* [Offset.Unspecified]. This includes long press and drag gesture defined on TextField.
*/
private var startContentVisibleOffset by mutableStateOf(Offset.Unspecified)
/**
* Calculates the offset of currently visible bounds.
*/
private val currentContentVisibleOffset: Offset
get() = textLayoutCoordinates
?.visibleBounds()
?.topLeft ?: Offset.Unspecified
/**
* Current drag position of a handle for magnifier to read. Only one handle can be dragged
* at one time. This uses raw position as in only gesture start position and delta are used to
* calculate it. If auto-scroll happens due to selection changes while the gesture is active,
* it is not reflected on this value. See [handleDragPosition] for such a behavior.
*
* This value can reflect the drag position of either a real handle like cursor or selection or
* an acting handle when long press dragging happens directly on the text field. However, these
* two systems (real and acting handles) use different coordinate systems. When real handles
* set this value, they send inner text field coordinates. On the other hand, long press and
* drag gesture defined on text field would send coordinates in the decoration coordinates.
*/
private var rawHandleDragPosition by mutableStateOf(Offset.Unspecified)
/**
* Defines where the handle exactly is in text layout node coordinates. This is mainly used by
* Magnifier to anchor itself. Also, it provides an updated total drag value to cursor and
* selection handles to continue scrolling as they are dragged outside the visible bounds.
*
* This value is primarily used by Magnifier and any handle dragging gesture detector. Since
* these calculations use inner text field coordinates, [handleDragPosition] is also always
* represented in the same coordinate system.
*/
val handleDragPosition: Offset
get() = when {
// nothing is being dragged.
rawHandleDragPosition.isUnspecified -> {
Offset.Unspecified
}
// no real handle is being dragged, we need to offset the drag position by current
// inner-decorator relative positioning.
startContentVisibleOffset.isUnspecified -> {
textLayoutState.fromDecorationToTextLayout(rawHandleDragPosition)
}
// a cursor or a selection handle is being dragged, offset by comparing the current
// and starting visible offsets.
else -> {
rawHandleDragPosition + currentContentVisibleOffset - startContentVisibleOffset
}
}
/**
* Which selection handle is currently being dragged.
*/
var draggingHandle by mutableStateOf<Handle?>(null)
/**
* Whether to show the cursor handle below cursor indicator when the TextField is focused.
*/
private var showCursorHandle by mutableStateOf(false)
/**
* Whether to show the TextToolbar according to current selection state. This is not the final
* decider for showing the toolbar. Please refer to [observeTextToolbarVisibility] docs.
*/
private var textToolbarState by mutableStateOf(TextToolbarState.None)
/**
* Access helper for text layout node coordinates that checks attached state.
*/
private val textLayoutCoordinates: LayoutCoordinates?
get() = textLayoutState.textLayoutNodeCoordinates?.takeIf { it.isAttached }
/**
* Whether the contents of this TextField can be changed by the user.
*/
private val editable: Boolean
get() = enabled && !readOnly
/**
* The most recent [SelectionLayout] that passed the [SelectionLayout.shouldRecomputeSelection]
* check. Provides context to the next selection update such as if the selection is shrinking
* or not.
*/
private var previousSelectionLayout: SelectionLayout? = null
/**
* The previous offset of a drag, before selection adjustments.
* Only update when a selection layout change has occurred,
* or set to -1 if a new drag begins.
*/
private var previousRawDragOffset: Int = -1
/**
* State of the cursor handle that includes its visibility and position.
*/
val cursorHandle by derivedStateOf {
// For cursor handle to be visible, [showCursorHandle] must be true and the selection
// must be collapsed.
// Also, cursor handle should be in visible bounds of the TextField. However, if
// cursor is dragging and gets out of bounds, we cannot remove it from composition
// because that would stop the drag gesture defined on it. Instead, we allow the handle
// to be visible as long as it's being dragged.
// Visible bounds calculation lags one frame behind to let auto-scrolling settle.
val text = textFieldState.text
val visible = showCursorHandle &&
text.selectionInChars.collapsed &&
text.isNotEmpty() &&
(draggingHandle == Handle.Cursor || cursorHandleInBounds)
if (!visible) return@derivedStateOf TextFieldHandleState.Hidden
// text direction is useless for cursor handle, any value is fine.
TextFieldHandleState(
visible = true,
position = cursorRect.bottomCenter,
direction = ResolvedTextDirection.Ltr,
handlesCrossed = false
)
}
/**
* Whether currently cursor handle is in visible bounds. This derived state does not react to
* selection changes immediately because every selection change is processed in layout phase
* by auto-scroll behavior. Only after giving auto-scroll time to process the cursor movement,
* and possibly scroll the cursor back into view, we can say that whether cursor is in visible
* bounds or not. This is guaranteed to happen after scroll since new [textLayoutCoordinates]
* are reported after the layout phase end.
*/
private val cursorHandleInBounds by derivedStateOf(policy = structuralEqualityPolicy()) {
val position = Snapshot.withoutReadObservation { cursorRect.bottomCenter }
textLayoutCoordinates
?.visibleBounds()
?.containsInclusive(position)
?: false
}
/**
* Where the cursor should be at any given time in core node coordinates.
*
* Returns [Rect.Zero] if text layout has not been calculated yet or the selection is not
* collapsed (no cursor to locate).
*/
val cursorRect: Rect by derivedStateOf {
val layoutResult = textLayoutState.layoutResult ?: return@derivedStateOf Rect.Zero
val value = textFieldState.text
if (!value.selectionInChars.collapsed) return@derivedStateOf Rect.Zero
val cursorRect = layoutResult.getCursorRect(value.selectionInChars.start)
val cursorWidth = with(density) { DefaultCursorThickness.toPx() }
// left and right values in cursorRect should be the same but in any case use the
// logically correct anchor.
val cursorCenterX = if (layoutResult.layoutInput.layoutDirection == LayoutDirection.Ltr) {
(cursorRect.left + cursorWidth / 2)
} else {
(cursorRect.right - cursorWidth / 2)
}
// don't let cursor go beyond the bounds of text layout node or cursor will be clipped.
// but also make sure that empty Text Layout still draws a cursor.
val coercedCursorCenterX = cursorCenterX
// do not use coerceIn because it is not guaranteed that minimum value is smaller
// than the maximum value.
.coerceAtMost(layoutResult.size.width - cursorWidth / 2)
.coerceAtLeast(cursorWidth / 2)
Rect(
left = coercedCursorCenterX - cursorWidth / 2,
right = coercedCursorCenterX + cursorWidth / 2,
top = cursorRect.top,
bottom = cursorRect.bottom
)
}
val startSelectionHandle by derivedStateOf {
getSelectionHandleState(isStartHandle = true)
}
val endSelectionHandle by derivedStateOf {
getSelectionHandleState(isStartHandle = false)
}
fun update(
hapticFeedBack: HapticFeedback,
clipboardManager: ClipboardManager,
textToolbar: TextToolbar,
density: Density,
enabled: Boolean,
readOnly: Boolean,
) {
if (!enabled) {
hideTextToolbar()
}
this.hapticFeedBack = hapticFeedBack
this.clipboardManager = clipboardManager
this.textToolbar = textToolbar
this.density = density
this.enabled = enabled
this.readOnly = readOnly
}
/**
* Implements the complete set of gestures supported by the cursor handle.
*/
suspend fun PointerInputScope.cursorHandleGestures() {
coroutineScope {
launch(start = CoroutineStart.UNDISPATCHED) {
detectTouchMode()
}
launch(start = CoroutineStart.UNDISPATCHED) {
detectCursorHandleDragGestures()
}
launch(start = CoroutineStart.UNDISPATCHED) {
detectTapGestures(onTap = {
textToolbarState = if (textToolbarState == TextToolbarState.Cursor) {
TextToolbarState.None
} else {
TextToolbarState.Cursor
}
})
}
}
}
/**
* Implements the complete set of gestures supported by the TextField area.
*/
suspend fun PointerInputScope.textFieldGestures(
requestFocus: () -> Unit,
showKeyboard: () -> Unit
) {
coroutineScope {
launch(start = CoroutineStart.UNDISPATCHED) {
detectTouchMode()
}
launch(start = CoroutineStart.UNDISPATCHED) {
detectTextFieldTapGestures(requestFocus, showKeyboard)
}
launch(start = CoroutineStart.UNDISPATCHED) {
detectTextFieldLongPressAndAfterDrag(requestFocus)
}
}
}
/**
* Gesture detector for dragging the selection handles to change the selection in TextField.
*/
suspend fun PointerInputScope.selectionHandleGestures(isStartHandle: Boolean) {
coroutineScope {
launch(start = CoroutineStart.UNDISPATCHED) {
detectTouchMode()
}
launch(start = CoroutineStart.UNDISPATCHED) {
detectPressDownGesture(
onDown = {
markStartContentVisibleOffset()
updateHandleDragging(
handle = if (isStartHandle) {
Handle.SelectionStart
} else {
Handle.SelectionEnd
},
position = getAdjustedCoordinates(getHandlePosition(isStartHandle))
)
},
onUp = {
clearHandleDragging()
}
)
}
launch(start = CoroutineStart.UNDISPATCHED) {
detectSelectionHandleDragGestures(isStartHandle)
}
}
}
/**
* Starts observing changes in the current state for reactive rules. For example, the cursor
* handle or the selection handles should hide whenever the text content changes.
*/
suspend fun observeChanges() {
try {
coroutineScope {
launch { observeTextChanges() }
launch { observeTextToolbarVisibility() }
}
} finally {
showCursorHandle = false
if (textToolbarState != TextToolbarState.None) {
hideTextToolbar()
}
}
}
fun updateTextToolbarState(textToolbarState: TextToolbarState) {
this.textToolbarState = textToolbarState
}
fun dispose() {
hideTextToolbar()
textToolbar = null
clipboardManager = null
hapticFeedBack = null
}
/**
* Detects the current pointer type in this [PointerInputScope] to update the touch mode state.
* This helper gesture detector should be added to all TextField pointer input receivers such
* as TextFieldDecorator, cursor handle, and selection handles.
*/
private suspend fun PointerInputScope.detectTouchMode() {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
isInTouchMode = !event.isPrecisePointer
}
}
}
private suspend fun PointerInputScope.detectTextFieldTapGestures(
requestFocus: () -> Unit,
showKeyboard: () -> Unit
) {
detectTapAndDoubleTap(
onTap = { offset ->
logDebug { "onTapTextField" }
requestFocus()
if (editable && isFocused) {
showKeyboard()
if (textFieldState.text.isNotEmpty()) {
showCursorHandle = true
}
// do not show any TextToolbar.
updateTextToolbarState(TextToolbarState.None)
// find the cursor position
val cursorIndex = textLayoutState.getOffsetForPosition(offset)
// update the state
if (cursorIndex >= 0) {
textFieldState.placeCursorBeforeCharAt(cursorIndex)
}
}
},
onDoubleTap = { offset ->
logDebug { "onDoubleTapTextField" }
// onTap is already called at this point. Focus is requested.
showCursorHandle = false
// go into selection mode.
updateTextToolbarState(TextToolbarState.Selection)
val index = textLayoutState.getOffsetForPosition(offset)
val newSelection = updateSelection(
// reset selection, otherwise a previous selection may be used
// as context for creating the next selection
textFieldCharSequence = TextFieldCharSequence(
textFieldState.text,
TextRange.Zero
),
startOffset = index,
endOffset = index,
isStartHandle = false,
adjustment = SelectionAdjustment.Word,
)
textFieldState.selectCharsIn(newSelection)
}
)
}
private suspend fun PointerInputScope.detectCursorHandleDragGestures() {
var cursorDragStart = Offset.Unspecified
var cursorDragDelta = Offset.Unspecified
fun onDragStop() {
// Only execute clear-up if drag was actually ongoing.
if (cursorDragStart.isSpecified) {
cursorDragStart = Offset.Unspecified
cursorDragDelta = Offset.Unspecified
clearHandleDragging()
}
}
// b/288931376: detectDragGestures do not call onDragCancel when composable is disposed.
try {
detectDragGestures(
onDragStart = {
// mark start drag point
cursorDragStart = getAdjustedCoordinates(cursorRect.bottomCenter)
cursorDragDelta = Offset.Zero
isInTouchMode = true
markStartContentVisibleOffset()
updateHandleDragging(Handle.Cursor, cursorDragStart)
},
onDragEnd = { onDragStop() },
onDragCancel = { onDragStop() },
onDrag = onDrag@{ change, dragAmount ->
cursorDragDelta += dragAmount
updateHandleDragging(Handle.Cursor, cursorDragStart + cursorDragDelta)
val layoutResult = textLayoutState.layoutResult ?: return@onDrag
val offset = layoutResult.getOffsetForPosition(handleDragPosition)
val newSelection = TextRange(offset)
// Nothing changed, skip onValueChange hand hapticFeedback.
if (newSelection == textFieldState.text.selectionInChars) return@onDrag
change.consume()
// TODO: only perform haptic feedback if filter does not override the change
hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
textFieldState.selectCharsIn(newSelection)
}
)
} finally {
onDragStop()
}
}
private suspend fun PointerInputScope.detectTextFieldLongPressAndAfterDrag(
requestFocus: () -> Unit
) {
var dragBeginOffsetInText = -1
var dragBeginPosition: Offset = Offset.Unspecified
var dragTotalDistance: Offset = Offset.Zero
var actingHandle: Handle = Handle.SelectionEnd // start with a placeholder.
fun onDragStop() {
// Only execute clear-up if drag was actually ongoing.
if (dragBeginPosition.isSpecified) {
clearHandleDragging()
dragBeginOffsetInText = -1
dragBeginPosition = Offset.Unspecified
dragTotalDistance = Offset.Zero
previousRawDragOffset = -1
}
}
// offsets received by this gesture detector are in decoration box coordinates
detectDragGesturesAfterLongPress(
onDragStart = onDragStart@{ dragStartOffset ->
logDebug { "onDragStart after longPress $dragStartOffset" }
requestFocus()
// this gesture detector is applied on the decoration box. We do not need to
// convert the gesture offset, that's going to be calculated by [handleDragPosition]
updateHandleDragging(
handle = actingHandle,
position = dragStartOffset
)
dragBeginPosition = dragStartOffset
dragTotalDistance = Offset.Zero
previousRawDragOffset = -1
// Long Press at the blank area, the cursor should show up at the end of the line.
if (!textLayoutState.isPositionOnText(dragStartOffset)) {
val offset = textLayoutState.getOffsetForPosition(dragStartOffset)
hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
textFieldState.placeCursorBeforeCharAt(offset)
showCursorHandle = true
updateTextToolbarState(TextToolbarState.Cursor)
} else {
if (textFieldState.text.isEmpty()) return@onDragStart
val offset = textLayoutState.getOffsetForPosition(dragStartOffset)
val newSelection = updateSelection(
// reset selection, otherwise a previous selection may be used
// as context for creating the next selection
textFieldCharSequence = TextFieldCharSequence(
textFieldState.text,
TextRange.Zero
),
startOffset = offset,
endOffset = offset,
isStartHandle = false,
adjustment = SelectionAdjustment.CharacterWithWordAccelerate,
)
textFieldState.selectCharsIn(newSelection)
updateTextToolbarState(TextToolbarState.Selection)
// For touch, set the begin offset to the adjusted selection.
// When char based selection is used, we want to ensure we snap the
// beginning offset to the start word boundary of the first selected word.
dragBeginOffsetInText = newSelection.start
}
},
onDragEnd = { onDragStop() },
onDragCancel = { onDragStop() },
onDrag = onDrag@{ _, dragAmount ->
// selection never started, did not consume any drag
if (textFieldState.text.isEmpty()) return@onDrag
dragTotalDistance += dragAmount
// "start position + total delta" is not enough to understand the current
// pointer position relative to text layout. We need to also account for any
// changes to visible offset that's caused by auto-scrolling while dragging.
val currentDragPosition = dragBeginPosition + dragTotalDistance
logDebug { "onDrag after longPress $currentDragPosition" }
val startOffset: Int
val endOffset: Int
val adjustment: SelectionAdjustment
if (
dragBeginOffsetInText < 0 && // drag started in end padding
!textLayoutState.isPositionOnText(currentDragPosition) // still in end padding
) {
startOffset = textLayoutState.getOffsetForPosition(dragBeginPosition)
endOffset = textLayoutState.getOffsetForPosition(currentDragPosition)
adjustment = if (startOffset == endOffset) {
// start and end is in the same end padding, keep the collapsed selection
SelectionAdjustment.None
} else {
SelectionAdjustment.Word
}
} else {
startOffset = dragBeginOffsetInText.takeIf { it >= 0 }
?: textLayoutState.getOffsetForPosition(
position = dragBeginPosition,
coerceInVisibleBounds = false
)
endOffset = textLayoutState.getOffsetForPosition(
position = currentDragPosition,
coerceInVisibleBounds = false
)
if (dragBeginOffsetInText < 0 && startOffset == endOffset) {
// if we are selecting starting from end padding,
// don't start selection until we have and un-collapsed selection.
return@onDrag
}
adjustment = SelectionAdjustment.Word
}
val prevSelection = textFieldState.text.selectionInChars
var newSelection = updateSelection(
textFieldCharSequence = textFieldState.text,
startOffset = startOffset,
endOffset = endOffset,
isStartHandle = false,
adjustment = adjustment,
allowPreviousSelectionCollapsed = false,
)
// Although we support reversed selection, reversing the selection after it's
// initiated via long press has a visual glitch that's hard to get rid of. When
// handles (start/end) switch places after the selection reverts, draw happens a
// bit late, making it obvious that selection handles switched places. We simply do
// not allow reversed selection during long press drag.
if (newSelection.reversed) {
newSelection = newSelection.reverse()
}
// When drag starts from the end padding, we eventually need to update the start
// point once a selection is initiated. Otherwise, startOffset is always calculated
// from dragBeginPosition which can refer to different positions on text if
// TextField starts scrolling.
if (dragBeginOffsetInText == -1 && !newSelection.collapsed) {
dragBeginOffsetInText = newSelection.start
}
// if the new selection is not equal to previous selection, consider updating the
// acting handle. Otherwise, acting handle should remain the same.
if (newSelection != prevSelection) {
// Find the growing direction of selection
// - Since we do not allow reverse selection,
// - selection.start == selection.min
// - selection.end == selection.max
// - If only start or end changes ([A, B] => [A, C]; [C, E] => [D, E])
// - acting handle is the changing handle.
// - If both change, find the middle point and see how it moves.
// - If middle point moves right, acting handle is SelectionEnd
// - Otherwise, acting handle is SelectionStart
actingHandle = when {
newSelection.start != prevSelection.start &&
newSelection.end == prevSelection.end -> Handle.SelectionStart
newSelection.start == prevSelection.start &&
newSelection.end != prevSelection.end -> Handle.SelectionEnd
else -> {
val newMiddle = (newSelection.start + newSelection.end) / 2f
val prevMiddle = (prevSelection.start + prevSelection.end) / 2f
if (newMiddle > prevMiddle) {
Handle.SelectionEnd
} else {
Handle.SelectionStart
}
}
}
}
// Do not allow selection to collapse on itself while dragging. Selection can
// reverse but does not collapse.
if (prevSelection.collapsed || !newSelection.collapsed) {
textFieldState.selectCharsIn(newSelection)
}
updateHandleDragging(
handle = actingHandle,
position = currentDragPosition
)
}
)
}
private suspend fun PointerInputScope.detectSelectionHandleDragGestures(
isStartHandle: Boolean
) {
var dragBeginPosition: Offset = Offset.Unspecified
var dragTotalDistance: Offset = Offset.Zero
val handle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd
fun onDragStop() {
// Only execute clear-up if drag was actually ongoing.
if (dragBeginPosition.isSpecified) {
clearHandleDragging()
dragBeginPosition = Offset.Unspecified
dragTotalDistance = Offset.Zero
previousRawDragOffset = -1
}
}
// b/288931376: detectDragGestures do not call onDragCancel when composable is disposed.
try {
detectDragGestures(
onDragStart = {
// The position of the character where the drag gesture should begin. This is in
// the composable coordinates.
dragBeginPosition = getAdjustedCoordinates(getHandlePosition(isStartHandle))
// no need to call markStartContentVisibleOffset, since it was called by the
// initial down event.
updateHandleDragging(handle, dragBeginPosition)
// Zero out the total distance that being dragged.
dragTotalDistance = Offset.Zero
previousRawDragOffset = -1
},
onDragEnd = { onDragStop() },
onDragCancel = { onDragStop() },
onDrag = onDrag@{ _, delta ->
dragTotalDistance += delta
val layoutResult = textLayoutState.layoutResult ?: return@onDrag
updateHandleDragging(handle, dragBeginPosition + dragTotalDistance)
val startOffset = if (isStartHandle) {
layoutResult.getOffsetForPosition(handleDragPosition)
} else {
textFieldState.text.selectionInChars.start
}
val endOffset = if (isStartHandle) {
textFieldState.text.selectionInChars.end
} else {
layoutResult.getOffsetForPosition(handleDragPosition)
}
val prevSelection = textFieldState.text.selectionInChars
val newSelection = updateSelection(
textFieldCharSequence = textFieldState.text,
startOffset = startOffset,
endOffset = endOffset,
isStartHandle = isStartHandle,
adjustment = SelectionAdjustment.CharacterWithWordAccelerate,
)
// Do not allow selection to collapse on itself while dragging selection
// handles. Selection can reverse but does not collapse.
if (prevSelection.collapsed || !newSelection.collapsed) {
textFieldState.selectCharsIn(newSelection)
}
}
)
} finally {
logDebug {
"Selection Handle drag cancelled for " +
"draggingHandle: $draggingHandle definedOn: $handle"
}
if (draggingHandle == handle) {
onDragStop()
}
}
}
private suspend fun observeTextChanges() {
snapshotFlow { textFieldState.text }
.distinctUntilChanged(TextFieldCharSequence::contentEquals)
// first value needs to be dropped because it cannot be compared to a prior value
.drop(1)
.collect {
showCursorHandle = false
// hide the toolbar any time text content changes.
updateTextToolbarState(TextToolbarState.None)
}
}
/**
* Manages the visibility of text toolbar according to current state and received events from
* various sources.
*
* - Tapping the cursor handle toggles the visibility of the toolbar [TextToolbarState.Cursor].
* - Dragging the cursor handle or selection handles temporarily hides the toolbar
* [draggingHandle].
* - Tapping somewhere on the TextField, whether it causes a cursor position change or not,
* fully hides the toolbar [TextToolbarState.None].
* - When cursor or selection leaves the visible bounds, text toolbar is temporarily hidden.
* [getContentRect]
* - When selection is initiated via long press, double click, or semantics, text toolbar shows
* [TextToolbarState.Selection]
*/
private suspend fun observeTextToolbarVisibility() {
snapshotFlow {
val isCollapsed = textFieldState.text.selectionInChars.collapsed
val textToolbarStateVisible =
isCollapsed && textToolbarState == TextToolbarState.Cursor ||
!isCollapsed && textToolbarState == TextToolbarState.Selection
val textToolbarVisible =
// toolbar is requested specifically for the current selection state
textToolbarStateVisible &&
draggingHandle == null && // not dragging any selection handles
isInTouchMode // toolbar hidden when not in touch mode
// final visibility decision is made by contentRect visibility.
// if contentRect is not in visible bounds, just pass Rect.Zero to the observer so that
// it hides the toolbar. If Rect is successfully passed to the observer, toolbar will
// be displayed.
if (!textToolbarVisible) {
Rect.Zero
} else {
// contentRect is calculated in root coordinates. VisibleBounds are in parent
// coordinates. Convert visibleBounds to root before checking the overlap.
val visibleBounds = textLayoutCoordinates?.visibleBounds()
if (visibleBounds != null) {
val visibleBoundsTopLeftInRoot =
textLayoutCoordinates?.localToRoot(visibleBounds.topLeft)
val visibleBoundsInRoot =
Rect(visibleBoundsTopLeftInRoot!!, visibleBounds.size)
// contentRect can be very wide if a big part of text is selected. Our toolbar
// should be aligned only to visible region.
getContentRect()
.takeIf { visibleBoundsInRoot.overlaps(it) }
?.intersect(visibleBoundsInRoot)
?: Rect.Zero
} else {
Rect.Zero
}
}
}.collect { rect ->
if (rect == Rect.Zero) {
hideTextToolbar()
} else {
showTextToolbar(rect)
}
}
}
/**
* Calculate selected region as [Rect]. The top is the top of the first selected
* line, and the bottom is the bottom of the last selected line. The left is the leftmost
* handle's horizontal coordinates, and the right is the rightmost handle's coordinates.
*/
private fun getContentRect(): Rect {
val text = textFieldState.text
// accept cursor position as content rect when selection is collapsed
// contentRect is defined in text layout node coordinates, so it needs to be realigned to
// the root container.
if (text.selectionInChars.collapsed) {
val topLeft = textLayoutCoordinates?.localToRoot(cursorRect.topLeft) ?: Offset.Zero
return Rect(topLeft, cursorRect.size)
}
val startOffset =
textLayoutCoordinates?.localToRoot(getHandlePosition(true)) ?: Offset.Zero
val endOffset =
textLayoutCoordinates?.localToRoot(getHandlePosition(false)) ?: Offset.Zero
val startTop =
textLayoutCoordinates?.localToRoot(
Offset(
0f,
textLayoutState.layoutResult?.getCursorRect(
text.selectionInChars.start
)?.top ?: 0f
)
)?.y ?: 0f
val endTop =
textLayoutCoordinates?.localToRoot(
Offset(
0f,
textLayoutState.layoutResult?.getCursorRect(
text.selectionInChars.end
)?.top ?: 0f
)
)?.y ?: 0f
return Rect(
left = min(startOffset.x, endOffset.x),
right = max(startOffset.x, endOffset.x),
top = min(startTop, endTop),
bottom = max(startOffset.y, endOffset.y)
)
}
private fun getSelectionHandleState(isStartHandle: Boolean): TextFieldHandleState {
val handle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd
val layoutResult = textLayoutState.layoutResult ?: return TextFieldHandleState.Hidden
val selection = textFieldState.text.selectionInChars
if (selection.collapsed) return TextFieldHandleState.Hidden
val position = getHandlePosition(isStartHandle)
val visible = draggingHandle == handle ||
(textLayoutCoordinates
?.visibleBounds()
?.containsInclusive(position)
?: false)
if (!visible) return TextFieldHandleState.Hidden
val directionOffset = if (isStartHandle) selection.start else max(selection.end - 1, 0)
val direction = layoutResult.getBidiRunDirection(directionOffset)
val handlesCrossed = selection.reversed
// Handle normally is visible when it's out of bounds but when the handle is being dragged,
// we let it stay on the screen to maintain gesture continuation. However, we still want
// to coerce handle's position to visible bounds to not let it jitter while scrolling the
// TextField as the selection is expanding.
val coercedPosition = textLayoutCoordinates?.visibleBounds()?.let { position.coerceIn(it) }
?: position
return TextFieldHandleState(
visible = true,
position = coercedPosition,
direction = direction,
handlesCrossed = handlesCrossed
)
}
private fun getHandlePosition(isStartHandle: Boolean): Offset {
val layoutResult = textLayoutState.layoutResult ?: return Offset.Zero
val selection = textFieldState.text.selectionInChars
val offset = if (isStartHandle) {
selection.start
} else {
selection.end
}
return getSelectionHandleCoordinates(
textLayoutResult = layoutResult,
offset = offset,
isStart = isStartHandle,
areHandlesCrossed = selection.reversed
)
}
/**
* Sets currently dragging handle state to [handle] and positions it at [position]. This is
* mostly useful for updating the magnifier.
*
* @param handle A real or acting handle that specifies which one is being dragged.
* @param position Where the handle currently is
*/
private fun updateHandleDragging(
handle: Handle,
position: Offset
) {
draggingHandle = handle
rawHandleDragPosition = position
}
/**
* When a Selection or Cursor Handle is started to being dragged, this function should be called
* to mark the current visible offset, so that if content gets scrolled during the drag, we
* can correctly offset the actual position where drag corresponds to.
*/
private fun markStartContentVisibleOffset() {
startContentVisibleOffset = textLayoutCoordinates
?.visibleBounds()
?.topLeft ?: Offset.Unspecified
}
/**
* Call this function when a selection or cursor handle is stopped dragging.
*/
private fun clearHandleDragging() {
draggingHandle = null
rawHandleDragPosition = Offset.Unspecified
startContentVisibleOffset = Offset.Unspecified
}
/**
* The method for cutting text.
*
* If there is no selection, return.
* Put the selected text into the [ClipboardManager].
* The new text should be the text before the selection plus the text after the selection.
* And the new cursor offset should be between the text before the selection, and the text
* after the selection.
*/
fun cut() {
val text = textFieldState.text
if (text.selectionInChars.collapsed) return
clipboardManager?.setText(AnnotatedString(text.getSelectedText().toString()))
textFieldState.deleteSelectedText()
}
/**
* The method for copying text.
*
* If there is no selection, return.
* Put the selected text into the [ClipboardManager], and cancel the selection, if
* [cancelSelection] is true.
* The text in the text field should be unchanged.
* If [cancelSelection] is true, the new cursor offset should be at the end of the previous
* selected text.
*/
fun copy(cancelSelection: Boolean = true) {
val text = textFieldState.text
if (text.selectionInChars.collapsed) return
clipboardManager?.setText(AnnotatedString(text.getSelectedText().toString()))
if (!cancelSelection) return
textFieldState.collapseSelectionToMax()
}
/**
* The method for pasting text.
*
* Get the text from [ClipboardManager]. If it's null, return.
* The new content should be the text before the selected text, plus the text from the
* [ClipboardManager], and plus the text after the selected text.
* Then the selection should collapse, and the new cursor offset should be at the end of the
* newly added text.
*/
fun paste() {
val clipboardText = clipboardManager?.getText()?.text ?: return
textFieldState.replaceSelectedText(
clipboardText,
undoBehavior = TextFieldEditUndoBehavior.NeverMerge
)
}
/**
* This function get the selected region as a Rectangle region, and pass it to [TextToolbar]
* to make the FloatingToolbar show up in the proper place. In addition, this function passes
* the copy, paste and cut method as callbacks when "copy", "cut" or "paste" is clicked.
*
* @param contentRect Rectangle region where the toolbar will be anchored.
*/
private fun showTextToolbar(contentRect: Rect) {
val selection = textFieldState.text.selectionInChars
val paste: (() -> Unit)? = if (editable && clipboardManager?.hasText() == true) {
{
paste()
updateTextToolbarState(TextToolbarState.None)
}
} else null
val copy: (() -> Unit)? = if (!selection.collapsed) {
{
copy()
updateTextToolbarState(TextToolbarState.None)
}
} else null
val cut: (() -> Unit)? = if (!selection.collapsed && editable) {
{
cut()
updateTextToolbarState(TextToolbarState.None)
}
} else null
val selectAll: (() -> Unit)? = if (selection.length != textFieldState.text.length) {
{
textFieldState.selectAll()
updateTextToolbarState(TextToolbarState.Selection)
}
} else null
textToolbar?.showMenu(
rect = contentRect,
onCopyRequested = copy,
onPasteRequested = paste,
onCutRequested = cut,
onSelectAllRequested = selectAll
)
}
fun deselect() {
if (!textFieldState.text.selectionInChars.collapsed) {
textFieldState.collapseSelectionToEnd()
}
showCursorHandle = false
updateTextToolbarState(TextToolbarState.None)
}
private fun hideTextToolbar() {
if (textToolbar?.status == TextToolbarStatus.Shown) {
textToolbar?.hide()
}
}
/**
* Update the text field's selection based on new offsets.
*
* @param textFieldCharSequence the current text editing state
* @param startOffset the start offset to use
* @param endOffset the end offset to use
* @param isStartHandle whether the start or end handle is being updated
* @param adjustment The selection adjustment to use
* @param allowPreviousSelectionCollapsed Allow a collapsed selection to be passed to selection
* adjustment. In most cases, a collapsed selection should be considered "no previous
* selection" for selection adjustment. However, in some cases - like starting a selection in
* end padding - a collapsed selection may be necessary context to avoid selection flickering.
*/
private fun updateSelection(
textFieldCharSequence: TextFieldCharSequence,
startOffset: Int,
endOffset: Int,
isStartHandle: Boolean,
adjustment: SelectionAdjustment,
allowPreviousSelectionCollapsed: Boolean = false,
): TextRange {
val newSelection = getTextFieldSelection(
rawStartOffset = startOffset,
rawEndOffset = endOffset,
previousSelection = textFieldCharSequence.selectionInChars
.takeIf { allowPreviousSelectionCollapsed || !it.collapsed },
isStartHandle = isStartHandle,
adjustment = adjustment,
)
if (newSelection == textFieldCharSequence.selectionInChars) return newSelection
val onlyChangeIsReversed =
newSelection.reversed != textFieldCharSequence.selectionInChars.reversed &&
newSelection.run { TextRange(end, start) } == textFieldCharSequence.selectionInChars
// don't haptic if we are using a mouse or if we aren't moving the selection bounds
if (isInTouchMode && !onlyChangeIsReversed) {
hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
return newSelection
}
private fun getTextFieldSelection(
rawStartOffset: Int,
rawEndOffset: Int,
previousSelection: TextRange?,
isStartHandle: Boolean,
adjustment: SelectionAdjustment
): TextRange {
val layoutResult = textLayoutState.layoutResult ?: return TextRange.Zero
// When the previous selection is null, it's allowed to have collapsed selection on
// TextField. So we can ignore the SelectionAdjustment.Character.
if (previousSelection == null && adjustment == SelectionAdjustment.Character) {
return TextRange(rawStartOffset, rawEndOffset)
}
val selectionLayout = getTextFieldSelectionLayout(
layoutResult = layoutResult,
rawStartHandleOffset = rawStartOffset,
rawEndHandleOffset = rawEndOffset,
rawPreviousHandleOffset = previousRawDragOffset,
previousSelectionRange = previousSelection ?: TextRange.Zero,
isStartOfSelection = previousSelection == null,
isStartHandle = isStartHandle,
)
if (previousSelection != null &&
!selectionLayout.shouldRecomputeSelection(previousSelectionLayout)
) {
return previousSelection
}
val result = adjustment.adjust(selectionLayout).toTextRange()
previousSelectionLayout = selectionLayout
previousRawDragOffset = if (isStartHandle) rawStartOffset else rawEndOffset
return result
}
}
private fun TextRange.reverse() = TextRange(end, start)
/**
* A state that indicates when to show TextToolbar.
*
* - [None] Do not show the TextToolbar at all.
* - [Cursor] if selection is collapsed and all the other criteria are met, show the TextToolbar.
* - [Selection] if selection is expanded and all the other criteria are met, show the TextToolbar.
*
* @see [TextFieldSelectionState.observeTextToolbarVisibility]
*/
internal enum class TextToolbarState {
None,
Cursor,
Selection,
}
private const val DEBUG = false
private const val DEBUG_TAG = "TextFieldSelectionState"
private fun logDebug(text: () -> String) {
if (DEBUG) {
println("$DEBUG_TAG: ${text()}")
}
}