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

Fixed appearing of text editing menu in Selection Container in iOS #1269

Merged
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.TextField
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.mpp.demo.textfield.ClearFocusBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
Expand All @@ -40,46 +42,60 @@ import androidx.compose.ui.unit.dp
@Composable
fun SelectionExample() {
var count by remember { mutableStateOf(0) }
Column {
Button(onClick = { count++ }) {
Text("Outside Count: $count")
}
SelectionContainer(
Modifier.padding(24.dp).fillMaxWidth()
) {
Column {
Text(
"I'm a selection container. Double tap on word to select a word." +
" Triple tap on content to select whole paragraph.\nAnother paragraph for testing.\n" +
"And another one."
)
Row {
DisableSelection {
val textState = remember {
mutableStateOf(
buildString {
repeat(3) {
appendLine("Text line $it")
}
}
)
}
ClearFocusBox {
Column {
Button(onClick = { count++ }) {
Text("Outside Count: $count")
}
SelectionContainer(
Modifier.padding(24.dp).fillMaxWidth()
) {
Column {
TextField(
textState.value, { textState.value = it },
)
Text(
"I'm a selection container. Double tap on word to select a word." +
" Triple tap on content to select whole paragraph.\nAnother paragraph for testing.\n" +
"And another one."
)
Row {
DisableSelection {
Button(onClick = { count++ }) {
Text("DisableSelection Count: $count")
}
}
Button(onClick = { count++ }) {
Text("DisableSelection Count: $count")
Text("SelectionContainer Count: $count")
}
}
Button(onClick = { count++ }) {
Text("SelectionContainer Count: $count")
}
Text("I'm another Text() block. Let's try to select me!")
Text("I'm yet another Text() with multiparagraph structure block.\nLet's try to select me!")
}
Text("I'm another Text() block. Let's try to select me!")
Text("I'm yet another Text() with multiparagraph structure block.\nLet's try to select me!")
}
}
Column(
Modifier
.height(100.dp)
.padding(2.dp)
.border(1.dp, Color.Blue)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
SelectionContainer {
Text(
text = "Select text and scroll\n".repeat(100),
modifier = Modifier.fillMaxWidth(),
)
Column(
Modifier
.height(100.dp)
.padding(2.dp)
.border(1.dp, Color.Blue)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
SelectionContainer {
Text(
text = "Select text and scroll\n".repeat(100),
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,32 @@ import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.input.*
import androidx.compose.ui.scene.getConstraintsToFillParent
import androidx.compose.ui.text.input.CommitTextCommand
import androidx.compose.ui.text.input.EditCommand
import androidx.compose.ui.text.input.EditProcessor
import androidx.compose.ui.text.input.FinishComposingTextCommand
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.ImeOptions
import androidx.compose.ui.text.input.PlatformTextInputService
import androidx.compose.ui.text.input.SetComposingRegionCommand
import androidx.compose.ui.text.input.SetComposingTextCommand
import androidx.compose.ui.text.input.SetSelectionCommand
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.asCGRect
import androidx.compose.ui.unit.toDpRect
import androidx.compose.ui.window.FocusStack
import androidx.compose.ui.window.IntermediateTextInputUIView
import androidx.compose.ui.window.KeyboardEventHandler
import androidx.compose.ui.scene.getConstraintsToFillParent
import androidx.compose.ui.unit.Density
import kotlin.math.absoluteValue
import kotlin.math.min
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import org.jetbrains.skia.BreakIterator
import platform.UIKit.*
import platform.UIKit.NSLayoutConstraint
import platform.UIKit.UIView
import platform.UIKit.reloadInputViews

internal class UIKitTextInputService(
private val updateView: () -> Unit,
Expand Down Expand Up @@ -111,17 +125,7 @@ internal class UIKitTextInputService(
currentImeOptions = imeOptions
currentImeActionHandler = onImeActionPerformed

textUIView?.removeFromSuperview()
textUIView = IntermediateTextInputUIView(
viewConfiguration = viewConfiguration
).also {
it.keyboardEventHandler = keyboardEventHandler
rootView.addSubview(it)
it.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activateConstraints(
getConstraintsToFillParent(it, rootView)
)
}
attachIntermediateTextInputView()
textUIView?.input = createSkikoInput(value)
textUIView?.inputTraits = getUITextInputTraits(imeOptions)

Expand All @@ -137,14 +141,7 @@ internal class UIKitTextInputService(

textUIView?.inputTraits = EmptyInputTraits
textUIView?.input = null
textUIView?.keyboardEventHandler = null
textUIView?.let { view ->
mainScope.launch {
view.resignFirstResponder()
view.removeFromSuperview()
}
}
textUIView = null
detachIntermediateTextInputView()
}

override fun showSoftwareKeyboard() {
Expand Down Expand Up @@ -274,32 +271,34 @@ internal class UIKitTextInputService(
onCutRequested: (() -> Unit)?,
onSelectAllRequested: (() -> Unit)?
) {
textUIView?.let {
val skiaRect = with(densityProvider()) {
org.jetbrains.skia.Rect.makeLTRB(
l = rect.left / density,
t = rect.top / density,
r = rect.right / density,
b = rect.bottom / density,
)
}
it.showTextMenu(
targetRect = skiaRect,
textActions = object : TextActions {
override val copy: (() -> Unit)? = onCopyRequested
override val cut: (() -> Unit)? = onCutRequested
override val paste: (() -> Unit)? = onPasteRequested
override val selectAll: (() -> Unit)? = onSelectAllRequested
}
)
if (textUIView == null) {
// If showMenu() is called and textUIView is not created,
// then it means that showMenu() called in SelectionContainer without any textfields,
// and IntermediateTextInputView must be created to show an editing menu
attachIntermediateTextInputView()
textUIView?.becomeFirstResponder()
updateView()
}
textUIView?.showTextMenu(
targetRect = rect.toDpRect(densityProvider()).asCGRect(),
textActions = object : TextActions {
override val copy: (() -> Unit)? = onCopyRequested
override val cut: (() -> Unit)? = onCutRequested
override val paste: (() -> Unit)? = onPasteRequested
override val selectAll: (() -> Unit)? = onSelectAllRequested
}
)
}

/**
* TODO on UIKit native behaviour is hide text menu, when touch outside
*/
override fun hide() {
textUIView?.hideTextMenu()
if ((textUIView != null) && (currentInput == null)) { // means that editing context menu shown in selection container
textUIView?.resignFirstResponder()
detachIntermediateTextInputView()
}
}

override val status: TextToolbarStatus
Expand All @@ -308,6 +307,29 @@ internal class UIKitTextInputService(
else
TextToolbarStatus.Hidden

private fun attachIntermediateTextInputView() {
textUIView?.removeFromSuperview()
textUIView = IntermediateTextInputUIView(
viewConfiguration = viewConfiguration
).also {
it.keyboardEventHandler = keyboardEventHandler
rootView.addSubview(it)
it.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activateConstraints(
getConstraintsToFillParent(it, rootView)
)
}
}

private fun detachIntermediateTextInputView() {
textUIView?.let { view ->
view.keyboardEventHandler = null
mainScope.launch {
view.removeFromSuperview()
}
}
textUIView = null
}

private fun createSkikoInput(value: TextFieldValue) = object : IOSSkikoInput {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -501,14 +501,8 @@ internal class IntermediateTextInputUIView(
* @param targetRect - rectangle of selected text area
* @param textActions - available (not null) actions in text menu
*/
fun showTextMenu(targetRect: org.jetbrains.skia.Rect, textActions: TextActions) {
val cgRect = CGRectMake(
x = targetRect.left.toDouble(),
y = targetRect.top.toDouble(),
width = targetRect.width.toDouble(),
height = targetRect.height.toDouble()
)
val isTargetVisible = CGRectIntersectsRect(bounds, cgRect)
fun showTextMenu(targetRect: CValue<CGRect>, textActions: TextActions) {
val isTargetVisible = CGRectIntersectsRect(bounds, targetRect)

if (isTargetVisible) {
// TODO: UIMenuController is deprecated since iOS 17 and not available on iOS 12
Expand All @@ -519,7 +513,7 @@ internal class IntermediateTextInputUIView(
cancelContextMenuUpdate()
CoroutineScope(Dispatchers.Main + menuMonitoringJob).launch {
delay(viewConfiguration.doubleTapTimeoutMillis)
menu.showMenuFromView(targetView = this@IntermediateTextInputUIView, cgRect)
menu.showMenuFromView(targetView = this@IntermediateTextInputUIView, targetRect)
}
_currentTextMenuActions = textActions
} else {
Expand Down
Loading