Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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 @@ -31,16 +31,6 @@ internal interface IOSSkikoInput {

fun endFloatingCursor()

/**
* Delays all edit commands until [endEditBatch] is being called.
*/
fun beginEditBatch()

/**
* Performs all editing commands, starting from the [beginEditBatch] call.
*/
fun endEditBatch()

/**
* A Boolean value that indicates whether the text-entry object has any text.
* https://developer.apple.com/documentation/uikit/uikeyinput/1614457-hastext
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,11 @@ internal class UIKitTextInputService(
private var currentImeActionHandler: ((ImeAction) -> Unit)? = null
private var textUIView: IntermediateTextInputUIView? = null
private val scrollView by lazy { IntermediateTextScrollView() }
private var textLayoutResult: TextLayoutResult? = null

private var currentFocusedRect: Rect? = null
private var sessionEditProcessor: EditProcessor? = null

var getTextLayoutResult : () -> TextLayoutResult? = { null }
private val textLayoutResult get() = getTextLayoutResult()

/**
* Matches DefaultCursorThickness
Expand All @@ -116,29 +118,6 @@ internal class UIKitTextInputService(
*/
private val cursorThickness = 2.dp

/**
* Workaround to prevent calling textWillChange, textDidChange, selectionWillChange, and
* selectionDidChange when the value of the current input is changed by the system (i.e., by the user
* input) not by the state change of the Compose side. These 4 functions call methods of
* UITextInputDelegateProtocol, which notifies the system that the text or the selection of the
* current input has changed.
*
* This is to properly handle multi-stage input methods that depend on text selection, required by
* languages such as Korean (Chinese and Japanese input methods depend on text marking). The writing
* system of these languages contains letters that can be broken into multiple parts, and each keyboard
* key corresponds to those parts. Therefore, the input system holds an internal state to combine these
* parts correctly. However, the methods of UITextInputDelegateProtocol reset this state, resulting in
* incorrect input. (e.g., 컴포즈 becomes ㅋㅓㅁㅍㅗㅈㅡ when not handled properly)
*
* @see sessionEditProcessor holds the same text and selection of the current input. It is used
* instead of the old value passed to updateState. When the current value change is due to the
* user input, updateState is not effective because _tempCurrentInputSession holds the same value.
* However, when the current value change is due to the change of the user selection or to the
* state change in the Compose side, updateState calls the 4 methods because the new value holds
* these changes.
*/
private var sessionEditProcessor: EditProcessor? = null

/**
* Workaround to prevent IME action from being called multiple times with hardware keyboards.
* When the hardware return key is held down, iOS sends multiple newline characters to the application,
Expand Down Expand Up @@ -167,7 +146,7 @@ internal class UIKitTextInputService(
value: TextFieldValue,
imeOptions: ImeOptions,
onEditCommand: (List<EditCommand>) -> Unit,
onImeActionPerformed: (ImeAction) -> Unit
onImeActionPerformed: (ImeAction) -> Unit,
) {
sessionEditProcessor = EditProcessor().apply {
reset(value, null)
Expand All @@ -186,11 +165,9 @@ internal class UIKitTextInputService(
}

override fun stopInput() {
flushEditCommandsIfNeeded(force = true)
sessionEditProcessor = null
currentImeOptions = null
currentImeActionHandler = null
textLayoutResult = null

hideSoftwareKeyboard()

Expand Down Expand Up @@ -236,9 +213,6 @@ internal class UIKitTextInputService(
if (selectionChanged) {
textUIView?.selectionDidChange()
}
if (!usingNativeTextInput && (textChanged || selectionChanged)) {
updateView()
}
}

fun onPreviewKeyEvent(event: KeyEvent): Boolean {
Expand Down Expand Up @@ -322,9 +296,9 @@ internal class UIKitTextInputService(
}
}

fun updateTextLayoutResult(textLayoutResult: TextLayoutResult) {
this.textLayoutResult = textLayoutResult
}
// fun updateTextLayoutResult(textLayoutResult: TextLayoutResult) {
// this.textLayoutResult = textLayoutResult
// }

private fun handleEnterKey(event: KeyEvent): Boolean {
_tempImeActionIsCalledWithHardwareReturnKey = false
Expand Down Expand Up @@ -360,34 +334,12 @@ internal class UIKitTextInputService(
}
}

private val editCommandsBatch = mutableListOf<EditCommand>()
private var editBatchDepth: Int = 0
set(value) {
field = value
flushEditCommandsIfNeeded()
}

private fun sendEditCommand(vararg commands: EditCommand) {
sessionEditProcessor?.apply(commands.toList())

editCommandsBatch.addAll(commands)
flushEditCommandsIfNeeded()

if (usingNativeTextInput) {
// For Native Text Input it's essential to trigger view update right after send edit command,
// otherwise UIKit calls may use an invalid layout state
coroutineScope.launch {
updateView()
}
}
}

fun flushEditCommandsIfNeeded(force: Boolean = false) {
if ((force || editBatchDepth == 0) && editCommandsBatch.isNotEmpty()) {
val commandList = editCommandsBatch.toList()
editCommandsBatch.clear()

currentOnEditCommand?.invoke(commandList)
currentOnEditCommand?.let {
val commandsList = commands.toList()
sessionEditProcessor?.apply(commandsList)
it.invoke(commandsList)
updateView()
}
}

Expand Down Expand Up @@ -478,7 +430,6 @@ internal class UIKitTextInputService(
// then it means that showMenu() called in SelectionContainer without any textfields,
// and IntermediateTextInputView must be created to show an editing menu
attachIntermediateTextInputView()
updateView()
}
showMenuOrUpdatePosition = {
textUIView?.let { textUIView ->
Expand Down Expand Up @@ -564,7 +515,6 @@ internal class UIKitTextInputService(
textUIView = IntermediateTextInputUIView(
doubleTapTimeoutMillis = viewConfiguration.doubleTapTimeoutMillis,
usingNativeTextInput = usingNativeTextInput,
coroutineScope = coroutineScope
).also {
view.addSubview(scrollView)
scrollView.textView = it
Expand All @@ -583,7 +533,6 @@ internal class UIKitTextInputService(
textUIView = IntermediateTextInputUIView(
doubleTapTimeoutMillis = viewConfiguration.doubleTapTimeoutMillis,
usingNativeTextInput = usingNativeTextInput,
coroutineScope = coroutineScope
).also {
it.setAutoresizingMask(
UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight
Expand Down Expand Up @@ -686,14 +635,6 @@ internal class UIKitTextInputService(
floatingCursorTranslation = null
}

override fun beginEditBatch() {
editBatchDepth++
}

override fun endEditBatch() {
editBatchDepth--
}

/**
* A Boolean value that indicates whether the text-entry object has any text.
* https://developer.apple.com/documentation/uikit/uikeyinput/1614457-hastext
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import androidx.compose.ui.uikit.LocalUIView
import androidx.compose.ui.uikit.OnFocusBehavior
import androidx.compose.ui.uikit.density
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.uikit.toNanoSeconds
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntRect
Expand Down Expand Up @@ -105,7 +106,6 @@ import org.jetbrains.skiko.OSVersion
import org.jetbrains.skiko.available
import platform.CoreGraphics.CGPoint
import platform.QuartzCore.CACurrentMediaTime
import platform.QuartzCore.CATransaction
import platform.UIKit.UIEvent
import platform.UIKit.UIEventButtonMaskPrimary
import platform.UIKit.UIEventButtonMaskSecondary
Expand Down Expand Up @@ -364,19 +364,12 @@ internal class ComposeSceneMediator(
)
}

private var lastRenderTime = CACurrentMediaTime().toNanoSeconds()
private val textInputService: UIKitTextInputService by lazy {
UIKitTextInputService(
updateView = {
if (usingNativeTextInput) {
// Too heavy method for this purpose
// we actually do not need to re-render the scene -
// just flush all events and update its state.
// https://youtrack.jetbrains.com/issue/CMP-9767
redrawer.draw(false)
} else {
redrawer.setNeedsRedraw()
}
CATransaction.flush()
scene.recomposeAndLayout(lastRenderTime)
redrawer.setNeedsRedraw()
},
view = _overlayView,
viewConfiguration = viewConfiguration,
Expand Down Expand Up @@ -598,7 +591,7 @@ internal class ComposeSceneMediator(
}

fun render(canvas: Canvas, nanoTime: Long) {
textInputService.flushEditCommandsIfNeeded(force = true)
lastRenderTime = nanoTime
scene.render(canvas, nanoTime)
}

Expand Down Expand Up @@ -752,11 +745,6 @@ internal class ComposeSceneMediator(
textInputService.updateState(oldValue = null, newValue = it)
}
}
launch {
snapshotFlow { request.textLayoutResult() }.filterNotNull().collect {
textInputService.updateTextLayoutResult(it)
}
}
launch {
snapshotFlow {
Triple(
Expand All @@ -780,15 +768,18 @@ internal class ComposeSceneMediator(
}
}
suspendCancellableCoroutine<Nothing> { continuation ->
textInputService.getTextLayoutResult = {
request.textLayoutResult()
}
textInputService.startInput(
value = request.value(),
imeOptions = request.imeOptions,
onEditCommand = request.onEditCommand,
onImeActionPerformed = request.onImeAction ?: {}
onImeActionPerformed = request.onImeAction ?: {},
)

continuation.invokeOnCancellation {
textInputService.stopInput()
textInputService.getTextLayoutResult = { null }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ import kotlinx.cinterop.COpaquePointer
import kotlinx.cinterop.CValue
import kotlinx.cinterop.readValue
import kotlinx.cinterop.useContents
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.jetbrains.skia.BreakIterator
import org.jetbrains.skiko.OS
import org.jetbrains.skiko.OSVersion
Expand Down Expand Up @@ -117,7 +115,6 @@ private val NoOpOnKeyboardPresses: (Set<*>) -> Unit = {}
internal class IntermediateTextInputUIView(
private val doubleTapTimeoutMillis: Long,
private val usingNativeTextInput: Boolean,
private val coroutineScope: CoroutineScope
) : CMPEditMenuView(frame = CGRectZero.readValue()),
UIKeyInputProtocol, UITextInputProtocol {
private var _inputDelegate: UITextInputDelegateProtocol? = null
Expand Down Expand Up @@ -287,9 +284,7 @@ internal class IntermediateTextInputUIView(
*/
override fun replaceRange(range: UITextRange, withText: String) {
val textRange = range.toTextRange() ?: return
input?.withBatch {
input?.replaceRange(textRange, withText)
}
input?.replaceRange(textRange, withText)
}

override fun setSelectedTextRange(selectedTextRange: UITextRange?) {
Expand All @@ -303,17 +298,13 @@ internal class IntermediateTextInputUIView(
if (notifySelectionChanges) {
selectionWillChange()
}
input?.withBatch {
input?.setSelectedTextRange(range)
}
input?.setSelectedTextRange(range)
if (notifySelectionChanges) {
selectionDidChange()
}
}
} else {
input?.withBatch {
input?.setSelectedTextRange(range)
}
input?.setSelectedTextRange(range)
}
}

Expand Down Expand Up @@ -363,17 +354,7 @@ internal class IntermediateTextInputUIView(
}
val relativeTextRange = TextRange(locationRelative, locationRelative + lengthRelative)

// Due to iOS specifics, [setMarkedText] can be called several times in a row. Batching
// helps to avoid text input problems, when Composables use parameters set during
// recomposition instead of the current ones. Example:
// 1. State "1" -> TextField(text = "1")
// 2. setMarkedText "12" -> Not equal to TextField(text = "1") -> State "12"
// 3. setMarkedText "1" -> Equal to TextField(text = "1") -> State remains "12"
// scene.render() - Recomposes TextField
// 4. State "12" -> TextField(text = "12") - Invalid state. Should be TextField(text = "1")
input?.withBatch {
input?.setMarkedText(markedText, relativeTextRange)
}
input?.setMarkedText(markedText, relativeTextRange)
}

/**
Expand Down Expand Up @@ -789,14 +770,6 @@ internal class IntermediateTextInputUIView(
onKeyboardPresses = NoOpOnKeyboardPresses
}

private fun IOSSkikoInput.withBatch(update: () -> Unit) {
beginEditBatch()
update()
coroutineScope.launch {
endEditBatch()
}
}

private fun UITextRange.isValid(): Boolean {
val range = this.toTextRange() ?: return false
val textEndPos = input?.endOfDocument() ?: 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ internal abstract class BaseComposeScene(
recomposer.performScheduledRecomposerTasks()
}

override fun recomposeAndLayout(nanoTime: Long) {
if (isClosed) return
postponeInvalidation("BaseComposeScene:drainPendingWork") {
recompose(nanoTime)
doMeasureAndLayout()
}
}

override fun render(canvas: Canvas, nanoTime: Long) {
// This is a no-op if the scene is closed, this situation can happen if the scene is
// in the list for rendering, but recomposition in another scene from the same list
Expand All @@ -164,15 +172,9 @@ internal abstract class BaseComposeScene(

postponeInvalidation("BaseComposeScene:render") {
// We try to run the phases here in the same order Android does.
recompose(nanoTime)

// Flush composition effects (e.g. LaunchedEffect, coroutines launched in
// rememberCoroutineScope()) before everything else
recomposer.performScheduledEffects()

recomposer.performScheduledRecomposerTasks()
frameClock.sendFrame(nanoTime) // withFrameMillis/Nanos and recomposition

doMeasureAndLayout() // Layout
doMeasureAndLayout()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lines 179-195 (updatePointerPosition and the second layout) are part of the layout step - we need to include them into it.


// Schedule synthetic events to be sent after `render` completes
if (inputHandler.needUpdatePointerPosition) {
Expand Down Expand Up @@ -294,6 +296,15 @@ internal abstract class BaseComposeScene(
}
}

private fun recompose(nanoTime: Long) {
// Flush composition effects (e.g. LaunchedEffect, coroutines launched in
// rememberCoroutineScope()) before everything else
recomposer.performScheduledEffects()

recomposer.performScheduledRecomposerTasks()
frameClock.sendFrame(nanoTime) // withFrameMillis/Nanos and recomposition
}

protected fun doMeasureAndLayout() {
snapshotInvalidationTracker.onMeasureAndLayout()
measureAndLayout()
Expand Down
Loading
Loading