Skip to content

Commit

Permalink
Double tap and triple tap gesture handling in textfields on iOS (#831)
Browse files Browse the repository at this point in the history
## Proposed Changes

Added UIKitTapGesturesDetector.kt - repeating tap gestures detector.
Moved and separated the logic for handling taps in the textfields.
Added handling of double and triple taps in the textfields on iOS.

This PR also includes review fixes from [my previous, closed
one](#798)

## Testing

Test: Open any screen with textfield with mutable text, then try to
double or triple tap on text (or if its empty, write something in it).
Try to test with focused textfield and unfocused.
Double tap selects the word, triple tap selects the paragraph.

## Issues Fixed

JetBrains/compose-multiplatform#2682

https://youtrack.jetbrains.com/issue/COMPOSE-333/iOS-TextField-Incorrect-behavior-of-text-selection-by-double-tap
(YT double version of the previous issue)

https://youtrack.jetbrains.com/issue/COMPOSE-409/iOS-TextField-support-triple-tap-handle


## Google CLA
You need to sign the Google Contributor’s License Agreement at
https://cla.developers.google.com/.
This is needed since we synchronise most of the code with Google’s AOSP
repository. Signing this agreement allows us to synchronise code from
your Pull Requests as well.

---------

Co-authored-by: dima.avdeev <dima.avdeev@jetbrains.com>
Co-authored-by: Igor Demin <igordmn@users.noreply.github.com>
  • Loading branch information
3 people committed Sep 26, 2023
1 parent da1be45 commit 3adcf23
Show file tree
Hide file tree
Showing 10 changed files with 601 additions and 54 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.foundation.text

import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.text.selection.TextFieldSelectionManager
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.input.OffsetMapping

@Composable
internal actual fun Modifier.textFieldPointer(
manager: TextFieldSelectionManager,
enabled: Boolean,
interactionSource: MutableInteractionSource?,
state: TextFieldState,
focusRequester: FocusRequester,
readOnly: Boolean,
offsetMapping: OffsetMapping
): Modifier = Modifier.defaultTextFieldPointer(
manager,
enabled,
interactionSource,
state,
focusRequester,
readOnly,
offsetMapping,
)
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.IntrinsicMeasurable
Expand Down Expand Up @@ -367,42 +366,15 @@ internal fun CoreTextField(
}
}

val pointerModifier = if (isInTouchMode) {
val selectionModifier =
Modifier.longPressDragGestureFilter(manager.touchSelectionObserver, enabled)
Modifier
.tapPressTextFieldModifier(interactionSource, enabled) { offset ->
tapToFocus(state, focusRequester, !readOnly)
if (state.hasFocus) {
if (state.handleState != HandleState.Selection) {
state.layoutResult?.let { layoutResult ->
TextFieldDelegate.setCursorOffset(
offset,
layoutResult,
state.processor,
offsetMapping,
state.onValueChange
)
// Won't enter cursor state when text is empty.
if (state.textDelegate.text.isNotEmpty()) {
state.handleState = HandleState.Cursor
}
}
} else {
manager.deselect(offset)
}
}
}
.then(selectionModifier)
.pointerHoverIcon(textPointerIcon)
} else {
Modifier
.mouseDragGestureDetector(
observer = manager.mouseSelectionObserver,
enabled = enabled
)
.pointerHoverIcon(textPointerIcon)
}
val pointerModifier = Modifier.textFieldPointer(
manager,
enabled,
interactionSource,
state,
focusRequester,
readOnly,
offsetMapping
)

val drawModifier = Modifier.drawBehind {
state.layoutResult?.let { layoutResult ->
Expand Down Expand Up @@ -550,7 +522,7 @@ internal fun CoreTextField(
onClick {
// according to the documentation, we still need to provide proper semantics actions
// even if the state is 'disabled'
tapToFocus(state, focusRequester, !readOnly)
tapTextFieldToFocus(state, focusRequester, !readOnly)
true
}
onLongClick {
Expand Down Expand Up @@ -967,7 +939,7 @@ internal class TextFieldState(
/**
* Request focus on tap. If already focused, makes sure the keyboard is requested.
*/
private fun tapToFocus(
internal fun tapTextFieldToFocus(
state: TextFieldState,
focusRequester: FocusRequester,
allowKeyboard: Boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.foundation.text

import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.text.selection.TextFieldSelectionManager
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.text.input.OffsetMapping

@Composable
internal expect fun Modifier.textFieldPointer(
manager: TextFieldSelectionManager,
enabled: Boolean,
interactionSource: MutableInteractionSource?,
state: TextFieldState,
focusRequester: FocusRequester,
readOnly: Boolean,
offsetMapping: OffsetMapping
): Modifier

@Composable
@OptIn(InternalFoundationTextApi::class)
internal fun Modifier.defaultTextFieldPointer(
manager: TextFieldSelectionManager,
enabled: Boolean,
interactionSource: MutableInteractionSource?,
state: TextFieldState,
focusRequester: FocusRequester,
readOnly: Boolean,
offsetMapping: OffsetMapping
): Modifier = if (isInTouchMode) {
val selectionModifier =
Modifier.longPressDragGestureFilter(manager.touchSelectionObserver, enabled)
this
.tapPressTextFieldModifier(interactionSource, enabled) { offset ->
tapTextFieldToFocus(state, focusRequester, !readOnly)
if (state.hasFocus) {
if (state.handleState != HandleState.Selection) {
state.layoutResult?.let { layoutResult ->
TextFieldDelegate.setCursorOffset(
offset,
layoutResult,
state.processor,
offsetMapping,
state.onValueChange
)
// Won't enter cursor state when text is empty.
if (state.textDelegate.text.isNotEmpty()) {
state.handleState = HandleState.Cursor
}
}
} else {
manager.deselect(offset)
}
}
}
.then(selectionModifier)
.pointerHoverIcon(textPointerIcon)
} else {
this
.mouseDragGestureDetector(
observer = manager.mouseSelectionObserver,
enabled = enabled
)
.pointerHoverIcon(textPointerIcon)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import androidx.compose.foundation.gestures.drag
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAll

// * Without shift it starts the new selection from the scratch.
Expand All @@ -40,26 +41,24 @@ internal interface MouseSelectionObserver {
fun onDrag(dragPosition: Offset, adjustment: SelectionAdjustment): Boolean
}

// Distance in pixels between consecutive click positions to be considered them as clicks sequence
internal const val ClicksSlop = 100.0

private class ClicksCounter(
private val viewConfiguration: ViewConfiguration
internal class ClicksCounter(
private val viewConfiguration: ViewConfiguration,
private val clicksSlop: Float // Distance in pixels between consecutive click positions to be considered them as clicks sequence
) {
var clicks = 0
var prevClick: PointerInputChange? = null
fun update(event: PointerEvent) {
val currentPrevClick = prevClick
val newClick = event.changes[0]
if (currentPrevClick != null &&
timeIsTolerable(currentPrevClick, newClick) &&
positionIsTolerable(currentPrevClick, newClick)
fun update(event: PointerInputChange) {
val currentPrevEvent = prevClick
// Here and further event means upcoming event (new)
if (currentPrevEvent != null &&
timeIsTolerable(currentPrevEvent, event) &&
positionIsTolerable(currentPrevEvent, event)
) {
clicks += 1
} else {
clicks = 1
}
prevClick = newClick
prevClick = event
}

fun timeIsTolerable(prevClick: PointerInputChange, newClick: PointerInputChange): Boolean {
Expand All @@ -69,19 +68,19 @@ private class ClicksCounter(

fun positionIsTolerable(prevClick: PointerInputChange, newClick: PointerInputChange): Boolean {
val diff = newClick.position - prevClick.position
return diff.getDistance() < ClicksSlop
return diff.getDistance() < clicksSlop
}
}

internal suspend fun PointerInputScope.mouseSelectionDetector(
observer: MouseSelectionObserver
) {
awaitEachGesture {
val clicksCounter = ClicksCounter(viewConfiguration)
val clicksCounter = ClicksCounter(viewConfiguration, clicksSlop = 50.dp.toPx())
while (true) {
val down = awaitMouseEventDown()
clicksCounter.update(down)
val downChange = down.changes[0]
clicksCounter.update(downChange)
if (down.keyboardModifiers.isShiftPressed) {
val started = observer.onExtend(downChange.position)
if (started) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.foundation.text

import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.text.selection.TextFieldSelectionManager
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.input.OffsetMapping

@Composable
internal actual fun Modifier.textFieldPointer(
manager: TextFieldSelectionManager,
enabled: Boolean,
interactionSource: MutableInteractionSource?,
state: TextFieldState,
focusRequester: FocusRequester,
readOnly: Boolean,
offsetMapping: OffsetMapping
): Modifier = Modifier.defaultTextFieldPointer(
manager,
enabled,
interactionSource,
state,
focusRequester,
readOnly,
offsetMapping,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.foundation.text

import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.text.selection.TextFieldSelectionManager
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.input.OffsetMapping

@Composable
internal actual fun Modifier.textFieldPointer(
manager: TextFieldSelectionManager,
enabled: Boolean,
interactionSource: MutableInteractionSource?,
state: TextFieldState,
focusRequester: FocusRequester,
readOnly: Boolean,
offsetMapping: OffsetMapping
): Modifier = Modifier.defaultTextFieldPointer(
manager,
enabled,
interactionSource,
state,
focusRequester,
readOnly,
offsetMapping,
)

0 comments on commit 3adcf23

Please sign in to comment.