[go: nahoru, domu]

blob: 465fc4640f00138f646a4057c61214a939ee4602 [file] [log] [blame]
/*
* Copyright 2021 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.selection
import androidx.annotation.VisibleForTesting
import androidx.collection.LongObjectMap
import androidx.collection.emptyLongObjectMap
import androidx.collection.mutableLongIntMapOf
import androidx.collection.mutableLongObjectMapOf
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.text.Handle
import androidx.compose.foundation.text.TextDragObserver
import androidx.compose.foundation.text.input.internal.coerceIn
import androidx.compose.foundation.text.selection.Selection.AnchorInfo
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
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.key.KeyEvent
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.layout.positionInWindow
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.buildAnnotatedString
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastFold
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
import androidx.compose.ui.util.fastMapNotNull
import kotlin.math.absoluteValue
/**
* A bridge class between user interaction to the text composables for text selection.
*/
internal class SelectionManager(private val selectionRegistrar: SelectionRegistrarImpl) {
private val _selection: MutableState<Selection?> = mutableStateOf(null)
/**
* The current selection.
*/
var selection: Selection?
get() = _selection.value
set(value) {
_selection.value = value
if (value != null) {
updateHandleOffsets()
}
}
/**
* Is touch mode active
*/
private val _isInTouchMode = mutableStateOf(true)
var isInTouchMode: Boolean
get() = _isInTouchMode.value
set(value) {
if (_isInTouchMode.value != value) {
_isInTouchMode.value = value
updateSelectionToolbar()
}
}
/**
* The manager will invoke this every time it comes to the conclusion that the selection should
* change. The expectation is that this callback will end up causing `setSelection` to get
* called. This is what makes this a "controlled component".
*/
var onSelectionChange: (Selection?) -> Unit = { selection = it }
set(newOnSelectionChange) {
// Wrap the given lambda with one that sets the selection immediately.
// The onSelectionChange loop requires a composition to happen for the selection
// to be updated, so we want to shorten that loop for gesture use cases where
// multiple selection changing events can be acted on within a single composition
// loop. Previous selection is used as part of that loop so keeping it up to date
// is important.
field = { newSelection ->
selection = newSelection
newOnSelectionChange(newSelection)
}
}
/**
* [HapticFeedback] handle to perform haptic feedback.
*/
var hapticFeedBack: HapticFeedback? = null
/**
* [ClipboardManager] to perform clipboard features.
*/
var clipboardManager: ClipboardManager? = null
/**
* [TextToolbar] to show floating toolbar(post-M) or primary toolbar(pre-M).
*/
var textToolbar: TextToolbar? = null
/**
* Focus requester used to request focus when selection becomes active.
*/
var focusRequester: FocusRequester = FocusRequester()
/**
* Return true if the corresponding SelectionContainer is focused.
*/
var hasFocus: Boolean by mutableStateOf(false)
/**
* Modifier for selection container.
*/
val modifier
get() = Modifier
.onClearSelectionRequested { onRelease() }
.onGloballyPositioned { containerLayoutCoordinates = it }
.focusRequester(focusRequester)
.onFocusChanged { focusState ->
if (!focusState.isFocused && hasFocus) {
onRelease()
}
hasFocus = focusState.isFocused
}
.focusable()
.updateSelectionTouchMode { isInTouchMode = it }
.onKeyEvent {
if (isCopyKeyEvent(it)) {
copy()
true
} else {
false
}
}
.then(if (shouldShowMagnifier) Modifier.selectionMagnifier(this) else Modifier)
private var previousPosition: Offset? = null
/**
* Layout Coordinates of the selection container.
*/
var containerLayoutCoordinates: LayoutCoordinates? = null
set(value) {
field = value
if (hasFocus && selection != null) {
val positionInWindow = value?.positionInWindow()
if (previousPosition != positionInWindow) {
previousPosition = positionInWindow
updateHandleOffsets()
updateSelectionToolbar()
}
}
}
/**
* The beginning position of the drag gesture. Every time a new drag gesture starts, it wil be
* recalculated.
*/
internal var dragBeginPosition by mutableStateOf(Offset.Zero)
private set
/**
* The total distance being dragged of the drag gesture. Every time a new drag gesture starts,
* it will be zeroed out.
*/
internal var dragTotalDistance by mutableStateOf(Offset.Zero)
private set
/**
* The calculated position of the start handle in the [SelectionContainer] coordinates. It
* is null when handle shouldn't be displayed.
* It is a [State] so reading it during the composition will cause recomposition every time
* the position has been changed.
*/
var startHandlePosition: Offset? by mutableStateOf(null)
private set
/**
* The calculated position of the end handle in the [SelectionContainer] coordinates. It
* is null when handle shouldn't be displayed.
* It is a [State] so reading it during the composition will cause recomposition every time
* the position has been changed.
*/
var endHandlePosition: Offset? by mutableStateOf(null)
private set
/**
* The handle that is currently being dragged, or null when no handle is being dragged. To get
* the position of the last drag event, use [currentDragPosition].
*/
var draggingHandle: Handle? by mutableStateOf(null)
private set
/**
* When a handle is being dragged (i.e. [draggingHandle] is non-null), this is the last position
* of the actual drag event. It is not clamped to handle positions. Null when not being dragged.
*/
var currentDragPosition: Offset? by mutableStateOf(null)
private set
private val shouldShowMagnifier
get() = draggingHandle != null && isInTouchMode && !isTriviallyCollapsedSelection()
@VisibleForTesting
internal var previousSelectionLayout: SelectionLayout? = null
init {
selectionRegistrar.onPositionChangeCallback = { selectableId ->
if (selectableId in selectionRegistrar.subselections) {
updateHandleOffsets()
updateSelectionToolbar()
}
}
selectionRegistrar.onSelectionUpdateStartCallback =
{ isInTouchMode, layoutCoordinates, rawPosition, selectionMode ->
val textRect = with(layoutCoordinates.size) {
Rect(0f, 0f, width.toFloat(), height.toFloat())
}
val position = if (textRect.containsInclusive(rawPosition)) {
rawPosition
} else {
rawPosition.coerceIn(textRect)
}
val positionInContainer = convertToContainerCoordinates(
layoutCoordinates,
position
)
if (positionInContainer.isSpecified) {
this.isInTouchMode = isInTouchMode
startSelection(
position = positionInContainer,
isStartHandle = false,
adjustment = selectionMode
)
focusRequester.requestFocus()
showToolbar = false
}
}
selectionRegistrar.onSelectionUpdateSelectAll =
{ isInTouchMode, selectableId ->
val (newSelection, newSubselection) = selectAllInSelectable(
selectableId = selectableId,
previousSelection = selection,
)
if (newSelection != selection) {
selectionRegistrar.subselections = newSubselection
onSelectionChange(newSelection)
}
this.isInTouchMode = isInTouchMode
focusRequester.requestFocus()
showToolbar = false
}
selectionRegistrar.onSelectionUpdateCallback =
{ isInTouchMode,
layoutCoordinates,
newPosition,
previousPosition,
isStartHandle,
selectionMode ->
val newPositionInContainer =
convertToContainerCoordinates(layoutCoordinates, newPosition)
val previousPositionInContainer =
convertToContainerCoordinates(layoutCoordinates, previousPosition)
this.isInTouchMode = isInTouchMode
updateSelection(
newPosition = newPositionInContainer,
previousPosition = previousPositionInContainer,
isStartHandle = isStartHandle,
adjustment = selectionMode
)
}
selectionRegistrar.onSelectionUpdateEndCallback = {
showToolbar = true
// This property is set by updateSelection while dragging, so we need to clear it after
// the original selection drag.
draggingHandle = null
currentDragPosition = null
}
// This function is meant to handle changes in the selectable content,
// such as the text changing.
selectionRegistrar.onSelectableChangeCallback = { selectableKey ->
if (selectableKey in selectionRegistrar.subselections) {
// Clear the selection range of each Selectable.
onRelease()
selection = null
}
}
selectionRegistrar.afterSelectableUnsubscribe = { selectableId ->
if (selectableId == selection?.start?.selectableId) {
// The selectable that contains a selection handle just unsubscribed.
// Hide the associated selection handle
startHandlePosition = null
}
if (selectableId == selection?.end?.selectableId) {
endHandlePosition = null
}
if (selectableId in selectionRegistrar.subselections) {
// Unsubscribing the selectable may make the selection empty, which would hide it.
updateSelectionToolbar()
}
}
}
/**
* Returns the [Selectable] responsible for managing the given [AnchorInfo], or null if the
* anchor is not from a currently-registered [Selectable].
*/
internal fun getAnchorSelectable(anchor: AnchorInfo): Selectable? {
return selectionRegistrar.selectableMap[anchor.selectableId]
}
private fun updateHandleOffsets() {
val selection = selection
val containerCoordinates = containerLayoutCoordinates
val startSelectable = selection?.start?.let(::getAnchorSelectable)
val endSelectable = selection?.end?.let(::getAnchorSelectable)
val startLayoutCoordinates = startSelectable?.getLayoutCoordinates()
val endLayoutCoordinates = endSelectable?.getLayoutCoordinates()
if (
selection == null ||
containerCoordinates == null ||
!containerCoordinates.isAttached ||
(startLayoutCoordinates == null && endLayoutCoordinates == null)
) {
this.startHandlePosition = null
this.endHandlePosition = null
return
}
val visibleBounds = containerCoordinates.visibleBounds()
this.startHandlePosition = startLayoutCoordinates?.let { handleCoordinates ->
// Set the new handle position only if the handle is in visible bounds or
// the handle is still dragging. If handle goes out of visible bounds during drag,
// handle popup is also removed from composition, halting the drag gesture. This
// affects multiple text selection when selected text is configured with maxLines=1
// and overflow=clip.
val handlePosition = startSelectable.getHandlePosition(selection, isStartHandle = true)
if (handlePosition.isUnspecified) return@let null
val position = containerCoordinates.localPositionOf(handleCoordinates, handlePosition)
position.takeIf {
draggingHandle == Handle.SelectionStart || visibleBounds.containsInclusive(it)
}
}
this.endHandlePosition = endLayoutCoordinates?.let { handleCoordinates ->
val handlePosition = endSelectable.getHandlePosition(selection, isStartHandle = false)
if (handlePosition.isUnspecified) return@let null
val position = containerCoordinates.localPositionOf(handleCoordinates, handlePosition)
position.takeIf {
draggingHandle == Handle.SelectionEnd || visibleBounds.containsInclusive(it)
}
}
}
/**
* Returns non-nullable [containerLayoutCoordinates].
*/
internal fun requireContainerCoordinates(): LayoutCoordinates {
val coordinates = containerLayoutCoordinates
requireNotNull(coordinates) { "null coordinates" }
require(coordinates.isAttached) { "unattached coordinates" }
return coordinates
}
internal fun selectAllInSelectable(
selectableId: Long,
previousSelection: Selection?
): Pair<Selection?, LongObjectMap<Selection>> {
val subselections = mutableLongObjectMapOf<Selection>()
val newSelection = selectionRegistrar.sort(requireContainerCoordinates())
.fastFold(null) { mergedSelection: Selection?, selectable: Selectable ->
val selection = if (selectable.selectableId == selectableId)
selectable.getSelectAllSelection() else null
selection?.let { subselections[selectable.selectableId] = it }
merge(mergedSelection, selection)
}
if (isInTouchMode && newSelection != previousSelection) {
hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
return Pair(newSelection, subselections)
}
/**
* Returns whether the selection encompasses the entire container.
*/
internal fun isEntireContainerSelected(): Boolean {
val selectables = selectionRegistrar.sort(requireContainerCoordinates())
// If there are no selectables, then an empty selection spans the entire container.
if (selectables.isEmpty()) return true
// Since some text exists, we must make sure that every selectable is fully selected.
return selectables.fastAll {
val text = it.getText()
if (text.isEmpty()) return@fastAll true // empty text is inherently fully selected
// If a non-empty selectable isn't included in the sub-selections,
// then some text in the container is not selected.
val subSelection = selectionRegistrar.subselections[it.selectableId]
?: return@fastAll false
val selectionStart = subSelection.start.offset
val selectionEnd = subSelection.end.offset
// The selection could be reversed,
// so just verify that the difference between the two offsets matches the text length
(selectionStart - selectionEnd).absoluteValue == text.length
}
}
/**
* Creates and sets a selection spanning the entire container.
*/
internal fun selectAll() {
val selectables = selectionRegistrar.sort(requireContainerCoordinates())
if (selectables.isEmpty()) return
var firstSubSelection: Selection? = null
var lastSubSelection: Selection? = null
val newSubSelections = mutableLongObjectMapOf<Selection>().apply {
selectables.fastForEach { selectable ->
val subSelection = selectable.getSelectAllSelection() ?: return@fastForEach
if (firstSubSelection == null) firstSubSelection = subSelection
lastSubSelection = subSelection
put(selectable.selectableId, subSelection)
}
}
if (newSubSelections.isEmpty()) return
// first/last sub selections are implied to be non-null from here on out
val newSelection = if (firstSubSelection === lastSubSelection) {
firstSubSelection
} else {
Selection(
start = firstSubSelection!!.start,
end = lastSubSelection!!.end,
handlesCrossed = false,
)
}
selectionRegistrar.subselections = newSubSelections
onSelectionChange(newSelection)
previousSelectionLayout = null
}
/**
* Returns whether the start and end anchors are equal.
*
* It is possible that this returns true, but the selection is still empty because it has
* multiple collapsed selections across multiple selectables. To test for that case, use
* [isNonEmptySelection].
*/
internal fun isTriviallyCollapsedSelection(): Boolean {
val selection = selection ?: return true
return selection.start == selection.end
}
/**
* Returns whether the selection selects zero characters.
*
* It is possible that the selection anchors are different but still result in a zero-width
* selection. In this case, you may want to still show the selection anchors, but not allow for
* a user to try and copy zero characters. To test for whether the anchors are equal, use
* [isTriviallyCollapsedSelection].
*/
internal fun isNonEmptySelection(): Boolean {
val selection = selection ?: return false
if (selection.start == selection.end) {
return false
}
if (selection.start.selectableId == selection.end.selectableId) {
// Selection is in the same selectable, but not the same anchors,
// so there must be some selected text.
return true
}
// All subselections associated with a selectable must be an empty selection.
return selectionRegistrar.sort(requireContainerCoordinates()).fastAny { selectable ->
selectionRegistrar.subselections[selectable.selectableId]
?.run { start.offset != end.offset }
?: false
}
}
internal fun getSelectedText(): AnnotatedString? {
if (selection == null || selectionRegistrar.subselections.isEmpty()) {
return null
}
return buildAnnotatedString {
selectionRegistrar.sort(requireContainerCoordinates()).fastForEach { selectable ->
selectionRegistrar.subselections[selectable.selectableId]?.let { subSelection ->
val currentText = selectable.getText()
val currentSelectedText = if (subSelection.handlesCrossed) {
currentText.subSequence(
subSelection.end.offset,
subSelection.start.offset
)
} else {
currentText.subSequence(
subSelection.start.offset,
subSelection.end.offset
)
}
append(currentSelectedText)
}
}
}
}
internal fun copy() {
getSelectedText()?.takeIf { it.isNotEmpty() }?.let { clipboardManager?.setText(it) }
}
/**
* Whether toolbar should be shown right now.
* Examples: Show toolbar after user finishes selection.
* Hide it during selection.
* Hide it when no selection exists.
*/
internal var showToolbar = false
internal set(value) {
field = value
updateSelectionToolbar()
}
private fun toolbarCopy() {
copy()
onRelease()
}
private fun updateSelectionToolbar() {
if (!hasFocus) {
return
}
val textToolbar = textToolbar ?: return
if (showToolbar && isInTouchMode) {
val rect = getContentRect() ?: return
textToolbar.showMenu(
rect = rect,
onCopyRequested = if (isNonEmptySelection()) ::toolbarCopy else null,
onSelectAllRequested = if (isEntireContainerSelected()) null else ::selectAll,
)
} else if (textToolbar.status == TextToolbarStatus.Shown) {
textToolbar.hide()
}
}
/**
* Calculate selected region as [Rect].
* The result is the smallest [Rect] that encapsulates the entire selection,
* coerced into visible bounds.
*/
private fun getContentRect(): Rect? {
selection ?: return null
val containerCoordinates = containerLayoutCoordinates ?: return null
if (!containerCoordinates.isAttached) return null
val selectableSubSelections = selectionRegistrar.sort(requireContainerCoordinates())
.fastMapNotNull { selectable ->
selectionRegistrar.subselections[selectable.selectableId]
?.let { selectable to it }
}
.firstAndLast()
if (selectableSubSelections.isEmpty()) return null
val selectedRegionRect =
getSelectedRegionRect(selectableSubSelections, containerCoordinates)
if (selectedRegionRect == invertedInfiniteRect) return null
val visibleRect = containerCoordinates.visibleBounds().intersect(selectedRegionRect)
// if the rectangles do not at least touch at the edges, we shouldn't show the toolbar
if (visibleRect.width < 0 || visibleRect.height < 0) return null
val rootRect = visibleRect.translate(containerCoordinates.positionInRoot())
return rootRect.copy(bottom = rootRect.bottom + HandleHeight.value * 4)
}
// This is for PressGestureDetector to cancel the selection.
fun onRelease() {
selectionRegistrar.subselections = emptyLongObjectMap()
showToolbar = false
if (selection != null) {
onSelectionChange(null)
if (isInTouchMode) {
hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
}
fun handleDragObserver(isStartHandle: Boolean): TextDragObserver = object : TextDragObserver {
override fun onDown(point: Offset) {
// if the handle position is null, then it is invisible, so ignore the gesture
(if (isStartHandle) startHandlePosition else endHandlePosition) ?: return
val selection = selection ?: return
val anchor = if (isStartHandle) selection.start else selection.end
val selectable = getAnchorSelectable(anchor) ?: return
// The LayoutCoordinates of the composable where the drag gesture should begin. This
// is used to convert the position of the beginning of the drag gesture from the
// composable coordinates to selection container coordinates.
val beginLayoutCoordinates = selectable.getLayoutCoordinates() ?: return
// The position of the character where the drag gesture should begin. This is in
// the composable coordinates.
val handlePosition = selectable.getHandlePosition(selection, isStartHandle)
if (handlePosition.isUnspecified) return
val beginCoordinates = getAdjustedCoordinates(handlePosition)
// Convert the position where drag gesture begins from composable coordinates to
// selection container coordinates.
currentDragPosition = requireContainerCoordinates().localPositionOf(
beginLayoutCoordinates,
beginCoordinates
)
draggingHandle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd
showToolbar = false
}
override fun onStart(startPoint: Offset) {
draggingHandle ?: return
val selection = selection!!
val anchor = if (isStartHandle) selection.start else selection.end
val selectable = checkNotNull(selectionRegistrar.selectableMap[anchor.selectableId]) {
"SelectionRegistrar should contain the current selection's selectableIds"
}
// The LayoutCoordinates of the composable where the drag gesture should begin. This
// is used to convert the position of the beginning of the drag gesture from the
// composable coordinates to selection container coordinates.
val beginLayoutCoordinates = checkNotNull(selectable.getLayoutCoordinates()) {
"Current selectable should have layout coordinates."
}
// The position of the character where the drag gesture should begin. This is in
// the composable coordinates.
val handlePosition = selectable.getHandlePosition(selection, isStartHandle)
if (handlePosition.isUnspecified) return
val beginCoordinates = getAdjustedCoordinates(handlePosition)
// Convert the position where drag gesture begins from composable coordinates to
// selection container coordinates.
dragBeginPosition = requireContainerCoordinates().localPositionOf(
beginLayoutCoordinates,
beginCoordinates
)
// Zero out the total distance that being dragged.
dragTotalDistance = Offset.Zero
}
override fun onDrag(delta: Offset) {
draggingHandle ?: return
dragTotalDistance += delta
val endPosition = dragBeginPosition + dragTotalDistance
val consumed = updateSelection(
newPosition = endPosition,
previousPosition = dragBeginPosition,
isStartHandle = isStartHandle,
adjustment = SelectionAdjustment.CharacterWithWordAccelerate
)
if (consumed) {
dragBeginPosition = endPosition
dragTotalDistance = Offset.Zero
}
}
private fun done() {
showToolbar = true
draggingHandle = null
currentDragPosition = null
}
override fun onUp() = done()
override fun onStop() = done()
override fun onCancel() = done()
}
/**
* Detect tap without consuming the up event.
*/
private suspend fun PointerInputScope.detectNonConsumingTap(onTap: (Offset) -> Unit) {
awaitEachGesture {
waitForUpOrCancellation()?.let {
onTap(it.position)
}
}
}
private fun Modifier.onClearSelectionRequested(block: () -> Unit): Modifier {
return if (hasFocus) pointerInput(Unit) { detectNonConsumingTap { block() } } else this
}
private fun convertToContainerCoordinates(
layoutCoordinates: LayoutCoordinates,
offset: Offset
): Offset {
val coordinates = containerLayoutCoordinates
if (coordinates == null || !coordinates.isAttached) return Offset.Unspecified
return requireContainerCoordinates().localPositionOf(layoutCoordinates, offset)
}
/**
* Cancel the previous selection and start a new selection at the given [position].
* It's used for long-press, double-click, triple-click and so on to start selection.
*
* @param position initial position of the selection. Both start and end handle is considered
* at this position.
* @param isStartHandle whether it's considered as the start handle moving. This parameter
* will influence the [SelectionAdjustment]'s behavior. For example,
* [SelectionAdjustment.Character] only adjust the moving handle.
* @param adjustment the selection adjustment.
*/
private fun startSelection(
position: Offset,
isStartHandle: Boolean,
adjustment: SelectionAdjustment
) {
previousSelectionLayout = null
updateSelection(
position = position,
previousHandlePosition = Offset.Unspecified,
isStartHandle = isStartHandle,
adjustment = adjustment
)
}
/**
* Updates the selection after one of the selection handle moved.
*
* @param newPosition the new position of the moving selection handle.
* @param previousPosition the previous position of the moving selection handle.
* @param isStartHandle whether the moving selection handle is the start handle.
* @param adjustment the [SelectionAdjustment] used to adjust the raw selection range and
* produce the final selection range.
*
* @return a boolean representing whether the movement is consumed.
*
* @see SelectionAdjustment
*/
internal fun updateSelection(
newPosition: Offset?,
previousPosition: Offset,
isStartHandle: Boolean,
adjustment: SelectionAdjustment,
): Boolean {
if (newPosition == null) return false
return updateSelection(
position = newPosition,
previousHandlePosition = previousPosition,
isStartHandle = isStartHandle,
adjustment = adjustment
)
}
/**
* Updates the selection after one of the selection handle moved.
*
* To make sure that [SelectionAdjustment] works correctly, it's expected that only one
* selection handle is updated each time. The only exception is that when a new selection is
* started. In this case, [previousHandlePosition] is always null.
*
* @param position the position of the current gesture.
* @param previousHandlePosition the position of the moving handle before the update.
* @param isStartHandle whether the moving selection handle is the start handle.
* @param adjustment the [SelectionAdjustment] used to adjust the raw selection range and
* produce the final selection range.
*
* @return a boolean representing whether the movement is consumed. It's useful for the case
* where a selection handle is updating consecutively. When the return value is true, it's
* expected that the caller will update the [startHandlePosition] to be the given
* [endHandlePosition] in following calls.
*
* @see SelectionAdjustment
*/
internal fun updateSelection(
position: Offset,
previousHandlePosition: Offset,
isStartHandle: Boolean,
adjustment: SelectionAdjustment,
): Boolean {
draggingHandle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd
currentDragPosition = position
val selectionLayout = getSelectionLayout(position, previousHandlePosition, isStartHandle)
if (!selectionLayout.shouldRecomputeSelection(previousSelectionLayout)) {
return false
}
val newSelection = adjustment.adjust(selectionLayout)
if (newSelection != selection) {
selectionChanged(selectionLayout, newSelection)
}
previousSelectionLayout = selectionLayout
return true
}
private fun getSelectionLayout(
position: Offset,
previousHandlePosition: Offset,
isStartHandle: Boolean,
): SelectionLayout {
val containerCoordinates = requireContainerCoordinates()
val sortedSelectables = selectionRegistrar.sort(containerCoordinates)
val idToIndexMap = mutableLongIntMapOf()
sortedSelectables.fastForEachIndexed { index, selectable ->
idToIndexMap[selectable.selectableId] = index
}
val selectableIdOrderingComparator = compareBy<Long> { idToIndexMap[it] }
// if previous handle is null, then treat this as a new selection.
val previousSelection = if (previousHandlePosition.isUnspecified) null else selection
val builder = SelectionLayoutBuilder(
currentPosition = position,
previousHandlePosition = previousHandlePosition,
containerCoordinates = containerCoordinates,
isStartHandle = isStartHandle,
previousSelection = previousSelection,
selectableIdOrderingComparator = selectableIdOrderingComparator,
)
sortedSelectables.fastForEach {
it.appendSelectableInfoToBuilder(builder)
}
return builder.build()
}
private fun selectionChanged(selectionLayout: SelectionLayout, newSelection: Selection) {
if (shouldPerformHaptics()) {
hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
selectionRegistrar.subselections = selectionLayout.createSubSelections(newSelection)
onSelectionChange(newSelection)
}
@VisibleForTesting
internal fun shouldPerformHaptics(): Boolean =
isInTouchMode && selectionRegistrar.selectables.fastAny { it.getText().isNotEmpty() }
fun contextMenuOpenAdjustment(position: Offset) {
val isEmptySelection = selection?.toTextRange()?.collapsed ?: true
// TODO(b/209483184) the logic should be more complex here, it should check that current
// selection doesn't include click position
if (isEmptySelection) {
startSelection(
position = position,
isStartHandle = true,
adjustment = SelectionAdjustment.Word
)
}
}
}
internal fun merge(lhs: Selection?, rhs: Selection?): Selection? {
return lhs?.merge(rhs) ?: rhs
}
internal expect fun isCopyKeyEvent(keyEvent: KeyEvent): Boolean
internal expect fun Modifier.selectionMagnifier(manager: SelectionManager): Modifier
private val invertedInfiniteRect = Rect(
left = Float.POSITIVE_INFINITY,
top = Float.POSITIVE_INFINITY,
right = Float.NEGATIVE_INFINITY,
bottom = Float.NEGATIVE_INFINITY
)
private fun <T> List<T>.firstAndLast(): List<T> = when (size) {
0, 1 -> this
else -> listOf(first(), last())
}
/**
* Get the selected region rect in the given [containerCoordinates].
* This will compute the smallest rect that contains every first/last
* character bounding box of each selectable. If for any reason there are no
* bounding boxes, then the [invertedInfiniteRect] is returned.
*/
@VisibleForTesting
internal fun getSelectedRegionRect(
selectableSubSelectionPairs: List<Pair<Selectable, Selection>>,
containerCoordinates: LayoutCoordinates,
): Rect {
if (selectableSubSelectionPairs.isEmpty()) return invertedInfiniteRect
var (containerLeft, containerTop, containerRight, containerBottom) = invertedInfiniteRect
selectableSubSelectionPairs.fastForEach { (selectable, subSelection) ->
val startOffset = subSelection.start.offset
val endOffset = subSelection.end.offset
if (startOffset == endOffset) return@fastForEach
val localCoordinates = selectable.getLayoutCoordinates() ?: return@fastForEach
val minOffset = minOf(startOffset, endOffset)
val maxOffset = maxOf(startOffset, endOffset)
val offsets = if (minOffset == maxOffset - 1) {
intArrayOf(minOffset)
} else {
intArrayOf(minOffset, maxOffset - 1)
}
var (left, top, right, bottom) = invertedInfiniteRect
for (i in offsets) {
val rect = selectable.getBoundingBox(i)
left = minOf(left, rect.left)
top = minOf(top, rect.top)
right = maxOf(right, rect.right)
bottom = maxOf(bottom, rect.bottom)
}
val localTopLeft = Offset(left, top)
val localBottomRight = Offset(right, bottom)
val containerTopLeft =
containerCoordinates.localPositionOf(localCoordinates, localTopLeft)
val containerBottomRight =
containerCoordinates.localPositionOf(localCoordinates, localBottomRight)
containerLeft = minOf(containerLeft, containerTopLeft.x)
containerTop = minOf(containerTop, containerTopLeft.y)
containerRight = maxOf(containerRight, containerBottomRight.x)
containerBottom = maxOf(containerBottom, containerBottomRight.y)
}
return Rect(containerLeft, containerTop, containerRight, containerBottom)
}
internal fun calculateSelectionMagnifierCenterAndroid(
manager: SelectionManager,
magnifierSize: IntSize
): Offset {
val selection = manager.selection ?: return Offset.Unspecified
return when (manager.draggingHandle) {
null -> return Offset.Unspecified
Handle.SelectionStart -> getMagnifierCenter(manager, magnifierSize, selection.start)
Handle.SelectionEnd -> getMagnifierCenter(manager, magnifierSize, selection.end)
Handle.Cursor -> error("SelectionContainer does not support cursor")
}
}
private fun getMagnifierCenter(
manager: SelectionManager,
magnifierSize: IntSize,
anchor: AnchorInfo
): Offset {
val selectable = manager.getAnchorSelectable(anchor) ?: return Offset.Unspecified
val containerCoordinates = manager.containerLayoutCoordinates ?: return Offset.Unspecified
val selectableCoordinates = selectable.getLayoutCoordinates() ?: return Offset.Unspecified
val offset = anchor.offset
if (offset > selectable.getLastVisibleOffset()) return Offset.Unspecified
// The horizontal position doesn't snap to cursor positions but should directly track the
// actual drag.
val localDragPosition = selectableCoordinates.localPositionOf(
containerCoordinates,
manager.currentDragPosition!!
)
val dragX = localDragPosition.x
// But it is constrained by the horizontal bounds of the current line.
val lineRange = selectable.getRangeOfLineContaining(offset)
val textConstrainedX = if (lineRange.collapsed) {
// A collapsed range implies the text is empty.
// line left and right are equal for this offset, so use either
selectable.getLineLeft(offset)
} else {
val lineStartX = selectable.getLineLeft(lineRange.start)
val lineEndX = selectable.getLineRight(lineRange.end - 1)
// in RTL/BiDi, lineStartX may be larger than lineEndX
val minX = minOf(lineStartX, lineEndX)
val maxX = maxOf(lineStartX, lineEndX)
dragX.coerceIn(minX, maxX)
}
// selectable couldn't determine horizontals
if (textConstrainedX == -1f) return Offset.Unspecified
// Hide the magnifier when dragged too far (outside the horizontal bounds of how big the
// magnifier actually is). See
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/Editor.java;l=5228-5231;drc=2fdb6bd709be078b72f011334362456bb758922c
// Also check whether magnifierSize is calculated. A platform magnifier instance is not
// created until it's requested for the first time. So the size will only be calculated after we
// return a specified offset from this function.
// It is very unlikely that this behavior would cause a flicker since magnifier immediately
// shows up where the pointer is being dragged. The pointer needs to drag further than the half
// of magnifier's width to hide by the following logic.
if (magnifierSize != IntSize.Zero &&
(dragX - textConstrainedX).absoluteValue > magnifierSize.width / 2) {
return Offset.Unspecified
}
val lineCenterY = selectable.getCenterYForOffset(offset)
// selectable couldn't determine the line center
if (lineCenterY == -1f) return Offset.Unspecified
return containerCoordinates.localPositionOf(
sourceCoordinates = selectableCoordinates,
relativeToSource = Offset(textConstrainedX, lineCenterY)
)
}
/** Returns the boundary of the visible area in this [LayoutCoordinates]. */
internal fun LayoutCoordinates.visibleBounds(): Rect {
// globalBounds is the global boundaries of this LayoutCoordinates after it's clipped by
// parents. We can think it as the global visible bounds of this Layout. Here globalBounds
// is convert to local, which is the boundary of the visible area within the LayoutCoordinates.
val boundsInWindow = boundsInWindow()
return Rect(
windowToLocal(boundsInWindow.topLeft),
windowToLocal(boundsInWindow.bottomRight)
)
}
internal fun Rect.containsInclusive(offset: Offset): Boolean =
offset.x in left..right && offset.y in top..bottom