Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Caret behavior by single tap in Cupertino textfields #858

Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -97,29 +97,39 @@ private fun getTapHandlerModifier(
return Modifier.pointerInput(interactionSource) {
detectRepeatingTapGestures(
onTap = { touchPointOffset ->
tapTextFieldToFocus(
currentState,
currentFocusRequester,
!currentReadOnly
)
if (currentState.hasFocus) {
if (currentState.handleState != HandleState.Selection) {
currentState.layoutResult?.let { layoutResult ->
TextFieldDelegate.setCursorOffset(
touchPointOffset,
layoutResult,
currentState.processor,
currentOffsetMapping,
currentState.onValueChange
TextFieldDelegate.cupertinoSetCursorOffsetFocused(
position = touchPointOffset,
textLayoutResult = layoutResult,
editProcessor = currentState.processor,
offsetMapping = currentOffsetMapping,
showContextMenu = {},
onValueChange = currentState.onValueChange
)
// Won't enter cursor state when text is empty.
if (currentState.textDelegate.text.isNotEmpty()) {
currentState.handleState = HandleState.Cursor
}
}
} else {
currentManager.deselect(touchPointOffset)
}
} else {
tapTextFieldToFocus(
currentState,
currentFocusRequester,
!currentReadOnly
)
currentState.layoutResult?.let { layoutResult ->
TextFieldDelegate.setCursorOffset(
touchPointOffset,
layoutResult,
currentState.processor,
currentOffsetMapping,
currentState.onValueChange
)
}
}
if (currentState.textDelegate.text.isNotEmpty()) {
currentState.handleState = HandleState.Cursor
}
},
onDoubleTap = {
Expand Down Expand Up @@ -149,7 +159,10 @@ private fun getSelectionModifier(manager: TextFieldSelectionManager): Modifier {
}
}

private fun TextFieldSelectionManager.doRepeatingTapSelection(touchPointOffset: Offset, selectionAdjustment: SelectionAdjustment) {
private fun TextFieldSelectionManager.doRepeatingTapSelection(
touchPointOffset: Offset,
selectionAdjustment: SelectionAdjustment
) {
if (value.text.isEmpty()) return
enterSelectionMode()
state?.layoutResult?.let { layoutResult ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,157 @@ internal actual fun String.findFollowingBreak(index: Int): Int {
val it = BreakIterator.makeCharacterInstance()
it.setText(this)
return it.following(index)
}

// TODO Remove once it's available in common stdlib https://youtrack.jetbrains.com/issue/KT-23251
internal typealias CodePoint = Int

/**
* Converts a surrogate pair to a unicode code point.
MatkovIvan marked this conversation as resolved.
Show resolved Hide resolved
*/
private fun Char.Companion.toCodePoint(high: Char, low: Char): CodePoint =
(((high - MIN_HIGH_SURROGATE) shl 10) or (low - MIN_LOW_SURROGATE)) + 0x10000

/**
* The minimum value of a supplementary code point, `\u0x10000`.
*/
private const val MIN_SUPPLEMENTARY_CODE_POINT: Int = 0x10000

internal fun CodePoint.charCount(): Int = if (this >= MIN_SUPPLEMENTARY_CODE_POINT) 2 else 1

internal val String.codePoints
get() = sequence {
var index = 0
while (index < length) {
val codePoint = codePointAt(index)
yield(codePoint)
index += codePoint.charCount()
}
}

/**
* Returns the character (Unicode code point) at the specified index.
*/
internal fun String.codePointAt(index: Int): CodePoint {
val high = this[index]
if (high.isHighSurrogate() && index + 1 < this.length) {
val low = this[index + 1]
if (low.isLowSurrogate()) {
return Char.toCodePoint(high, low)
}
}
return high.code
}


/**
* Finds the offset of the next non-whitespace symbols subsequence (word) in the given text
* starting from the specified caret offset.
*
* @param offset The offset where to start looking for the next word.
* @param currentText The current text in which to search for the next word.
* @return The offset of the next non-whitespace symbols subsequence (word), or the end of the string
* if no such word is found.
*/
internal fun findNextNonWhitespaceSymbolsSubsequenceStartOffset(
offset: Int,
currentText: String
): Int {
/* Assume that next non whitespaces symbols subsequence (word) is when current char is whitespace and next character is not.
* Emoji (compound incl.) should be treated as a new word.
*/
val charIterator =
BreakIterator.makeCharacterInstance() // wordInstance doesn't consider symbols sequence as word
charIterator.setText(currentText)

var currentOffset: Int
var nextOffset = charIterator.next()
while (nextOffset < offset) {
nextOffset = charIterator.next()
}
currentOffset = nextOffset

while (nextOffset != BreakIterator.DONE) {
nextOffset = charIterator.next()
if (currentText.codePointAt(currentOffset).isWhitespace() && !currentText.codePointAt(
nextOffset
).isWhitespace()
) {
return currentOffset
} else {
currentOffset = nextOffset
}
}
return currentOffset
}

/**
* Determines whether the character at the specified offset in the string is a whitespace Unicode character.
*
* @param offset The index of the character to check.
* @return `true` if the character at the specified offset is a whitespace character, `false` otherwise.
*/
internal fun String.isWhitespace(offset: Int): Boolean {
return this.codePointAt(offset).isWhitespace()
}

/**
* Checks if the character at the specified offset in the string is a punctuation Unicode character.
*
* @param offset The offset of the character to check.
* @return true if the character at the specified offset is a punctuation character, false otherwise.
*/
internal fun String.isPunctuation(offset: Int): Boolean {
return this.codePointAt(offset).isPunctuation()
}

internal fun String.halfSymbolsOffset(): Int {
MatkovIvan marked this conversation as resolved.
Show resolved Hide resolved
val symbolsCount = this.codePoints.count()
val charIterator = BreakIterator.makeCharacterInstance()
charIterator.setText(this)
var currentOffset = 0
for (i in 0..symbolsCount / 2) {
currentOffset = charIterator.next()
}
return currentOffset
}

/**
* Checks if the given Unicode code point is a whitespace character.
*
* @return `true` if the code point is a whitespace character, `false` otherwise.
*/
private fun CodePoint.isWhitespace(): Boolean {
// TODO: Extend this behavior when (if) Unicode will have compound whitespace characters.
if (this.charCount() != 1) {
return false
}
return this.toChar().isWhitespace()
}

/**
* Checks if the given Unicode code point is a punctuation character.
*
* @return 'true' if the CodePoint is a punctuation character, 'false' otherwise.
*/
private fun CodePoint.isPunctuation(): Boolean {
// TODO: Extend this behavior when (if) Unicode will have compound punctuation characters.
if (this.charCount() != 1) {
return false
}
val punctuationSet = setOf(
CharCategory.DASH_PUNCTUATION,
CharCategory.START_PUNCTUATION,
CharCategory.END_PUNCTUATION,
CharCategory.CONNECTOR_PUNCTUATION,
CharCategory.OTHER_PUNCTUATION,
CharCategory.INITIAL_QUOTE_PUNCTUATION,
CharCategory.FINAL_QUOTE_PUNCTUATION
)
punctuationSet.forEach {
if (it.contains(this.toChar())) {
return true
}
}
return false
MatkovIvan marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* 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.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.EditProcessor
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TextFieldValue

/**
* Sets and adjusts the cursor offset when the TextField is focused in a Cupertino style.
* Cursor final offset depends on position of the cursor and content of the textfield.
*
* See **determineCursorDesiredPosition()** for more details.
* @param position The position of the cursor in the TextField.
* @param textLayoutResult The TextLayoutResultProxy object that contains the layout information of the TextField text.
* @param editProcessor The EditProcessor object that manages the editing operations of the TextField.
* @param offsetMapping The OffsetMapping object that maps the transformed offset to the original offset.
* @param showContextMenu The function that displays the context menu at the given rectangular area.
* @param onValueChange The function that is called when the TextField value changes.
*/
internal fun TextFieldDelegate.Companion.cupertinoSetCursorOffsetFocused(
position: Offset,
textLayoutResult: TextLayoutResultProxy,
editProcessor: EditProcessor,
offsetMapping: OffsetMapping,
showContextMenu: (Rect) -> Unit,
onValueChange: (TextFieldValue) -> Unit
) {
val offset =
offsetMapping.transformedToOriginal(textLayoutResult.getOffsetForPosition(position))
val currentValue = editProcessor.toTextFieldValue()
val currentText = currentValue.text

val cursorDesiredOffset = determineCursorDesiredOffset(
offset,
currentValue,
textLayoutResult,
currentText
)

if (cursorDesiredOffset == offset) {
showContextMenu(textLayoutResult.value.getCursorRect(offset))
}
onValueChange(
editProcessor.toTextFieldValue().copy(selection = TextRange(cursorDesiredOffset))
)
}

/**
* Determines the desired cursor position based on the given parameters.
*
* The rules for determining position of the caret are as follows:
* - When you make a single tap on a word, the caret moves to the end of this word.
* - If there’s a punctuation mark after the word, the caret is between the word and the punctuation mark.
* - If you tap on a whitespace, the caret is placed before the word. Same for many whitespaces in a row. (and punctuation marks)
* - If there’s a punctuation mark before the word, the caret is between the punctuation mark and the word.
* - When you make a single tap on the first half of the word, the caret is placed before this word.
* - If you tap on the left edge of the TextField, the caret is placed before the first word on this line. The same is for the right edge.
* - If you tap at the caret, that is placed in the middle of the word, it will jump to the end of the word.
* @param offset The current offset position.
* @param currentValue The current TextFieldValue.
* @param textLayoutResult The TextLayoutResultProxy representing the layout of the text.
* @param currentText The current text in the TextField.
* @return The desired cursor position after evaluating the given parameters.
*/
internal fun determineCursorDesiredOffset(
offset: Int,
currentValue: TextFieldValue,
textLayoutResult: TextLayoutResultProxy,
currentText: String
): Int {
val caretOffsetPosition: Int
val previousCaretPosition = currentValue.selection.start
if (offset == previousCaretPosition) {
MatkovIvan marked this conversation as resolved.
Show resolved Hide resolved
caretOffsetPosition = offset
} else if (textLayoutResult.isLeftEdgeTapped(offset)) {
val lineNumber = textLayoutResult.value.getLineForOffset(offset)
caretOffsetPosition = textLayoutResult.value.getLineStart(lineNumber)
} else if (textLayoutResult.isRightEdgeTapped(offset)) {
val lineNumber = textLayoutResult.value.getLineForOffset(offset)
caretOffsetPosition = textLayoutResult.value.getLineEnd(lineNumber)
} else if (isPunctuationOrSpaceTapped(offset, currentText)) {
val nextWordStartOffset =
findNextNonWhitespaceSymbolsSubsequenceStartOffset(offset, currentText)
caretOffsetPosition = nextWordStartOffset
} else if (textLayoutResult.isFirstHalfOfWordTapped(offset, currentText)) {
val wordBoundary = textLayoutResult.value.getWordBoundary(offset)
caretOffsetPosition = wordBoundary.start
} else {
val wordBoundary = textLayoutResult.value.getWordBoundary(offset)
caretOffsetPosition = wordBoundary.end
}
return caretOffsetPosition
}

private fun isPunctuationOrSpaceTapped(
caretOffset: Int,
currentText: String
): Boolean {
/* From TextLayoutResultProxy.value.getWordBoundary(caretOffset) it is clear
* that for whitespace or punctuation mark method will return empty range.
* caretOffset can be between 0...currentText.length, so it should be somewhere between words.
* */
return currentText.isPunctuation(caretOffset) || currentText.isWhitespace(caretOffset)
MatkovIvan marked this conversation as resolved.
Show resolved Hide resolved
}

private fun TextLayoutResultProxy.isFirstHalfOfWordTapped(
caretOffset: Int,
currentText: String
): Boolean {
val wordBoundary = value.getWordBoundary(caretOffset)
val word = currentText.substring(wordBoundary.start, wordBoundary.end)
val middleIndex = wordBoundary.start + word.halfSymbolsOffset()
return caretOffset < middleIndex
}

private fun TextLayoutResultProxy.isLeftEdgeTapped(caretOffset: Int): Boolean {
val lineNumber = value.getLineForOffset(caretOffset)
val lineStartOffset = value.getLineStart(lineNumber)
return lineStartOffset == caretOffset
}

private fun TextLayoutResultProxy.isRightEdgeTapped(caretOffset: Int): Boolean {
val lineNumber = value.getLineForOffset(caretOffset)
val lineEndOffset = value.getLineEnd(lineNumber)
return lineEndOffset == caretOffset
}
Loading