Skip to content

Commit 87d4f14

Browse files
authored
[web] Fix Backspace behaviour on mobile iOS (#2330) (#2338)
## Release Notes #2330
2 parents c189d10 + df8435d commit 87d4f14

File tree

3 files changed

+147
-148
lines changed

3 files changed

+147
-148
lines changed

compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/DomInputStrategy.kt

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,29 +50,31 @@ internal class DomInputStrategy(
5050
})
5151

5252
htmlInput.addEventListener("keydown", { evt ->
53-
nativeInputEventsProcessor.addKeyEvent(evt as KeyboardEvent)
53+
nativeInputEventsProcessor.registerEvent(evt as KeyboardEvent)
5454
})
5555

5656
htmlInput.addEventListener("keyup", { evt ->
57-
nativeInputEventsProcessor.addKeyEvent(evt as KeyboardEvent)
57+
nativeInputEventsProcessor.registerEvent(evt as KeyboardEvent)
5858
})
5959

6060
htmlInput.addEventListener("beforeinput", { evt ->
6161
if (evt is InputEvent) {
6262
htmlInput as HTMLElementWithValue
6363
val deleteContentBackwardSize = htmlInput.selectionEnd - htmlInput.selectionStart
64-
nativeInputEventsProcessor.addInputEvent(
65-
event = evt, deleteContentBackwardSize = deleteContentBackwardSize
66-
)
64+
65+
if (deleteContentBackwardSize > 0) {
66+
evt.deleteContentBackwardSize = deleteContentBackwardSize
67+
}
68+
nativeInputEventsProcessor.registerEvent(evt)
6769
}
6870
})
6971

7072
htmlInput.addEventListener("compositionstart", { evt ->
71-
nativeInputEventsProcessor.addCompositionEvent(evt as CompositionEvent)
73+
nativeInputEventsProcessor.registerEvent(evt as CompositionEvent)
7274
})
7375

7476
htmlInput.addEventListener("compositionend", { evt ->
75-
nativeInputEventsProcessor.addCompositionEvent(evt as CompositionEvent)
77+
nativeInputEventsProcessor.registerEvent(evt as CompositionEvent)
7678
})
7779
}
7880
}

compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt

Lines changed: 88 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,11 @@ import androidx.compose.ui.input.key.toComposeEvent
2121
import androidx.compose.ui.text.input.BackspaceCommand
2222
import androidx.compose.ui.text.input.CommitTextCommand
2323
import androidx.compose.ui.text.input.DeleteSurroundingTextCommand
24-
import androidx.compose.ui.text.input.EditCommand
2524
import androidx.compose.ui.text.input.SetComposingTextCommand
2625
import androidx.compose.ui.text.input.TextFieldValue
2726
import org.w3c.dom.events.CompositionEvent
28-
import org.w3c.dom.events.Event
2927
import org.w3c.dom.events.KeyboardEvent
28+
import org.w3c.dom.events.UIEvent
3029

3130
/**
3231
* Processes native input events and handles their translation to commands
@@ -41,7 +40,7 @@ internal abstract class NativeInputEventsProcessor(
4140
private val composeSender: ComposeCommandCommunicator
4241
) {
4342

44-
private val collectedEvents = mutableListOf<Event>()
43+
private val collectedEvents = mutableListOf<UIEvent>()
4544
private var isCheckpointScheduled = false
4645
private var lastCompositionEndTimestamp = 0.0 // Double because of k/wasm where Number.toLong() leads to a compilation error
4746

@@ -74,113 +73,117 @@ internal abstract class NativeInputEventsProcessor(
7473
|| it.type == "beforeinput" && (it as InputEvent).isComposing
7574
}
7675

77-
var keydownEvent: KeyboardEvent? = null
78-
var compositionEndEvt: CompositionEvent? = null
76+
var lastProcessedEventIsBackspace: Boolean = false
7977

8078
collectedEvents.forEach { evt ->
81-
val eventName = evt.type
79+
val timestamp = evt.timeStamp.toDouble()
8280

83-
when (eventName) {
81+
when (evt.type) {
8482
"keydown" -> {
85-
keydownEvent = evt as KeyboardEvent
86-
val isTypedEvent = isTypedEvent(keydownEvent)
87-
val isFromLastComposition =
88-
keydownEvent.timeStamp.toDouble() < lastCompositionEndTimestamp
89-
if (!isInIMEComposition && !isTypedEvent && !isFromLastComposition) {
90-
composeSender.sendKeyboardEvent(keydownEvent.toComposeEvent())
83+
if (isInIMEComposition) return@forEach
84+
85+
evt as KeyboardEvent
86+
if (isTypedEvent(evt)) return@forEach
87+
88+
val isFromLastComposition = timestamp < lastCompositionEndTimestamp
89+
90+
// see https://youtrack.jetbrains.com/issue/CMP-8745/Web-Mobile.-iOS.-Composite-input.-Characters-arent-deleted
91+
// on mobile iOS we cannot rely on the timestamp of the "keydown" event, it's always zero
92+
// it might seem strange that we are ignoring the isFromLastComposition safeguard
93+
// which was historically introduced exactly to resolve issues in Safari -
94+
// but isFromLastComposition is needed so that we won't type a number digit if it was pressed during composition mode
95+
// this is something that is not supposed to happen on mobile devices
96+
val shouldBeProcessed = timestamp == 0.0 || !isFromLastComposition
97+
98+
if (shouldBeProcessed) {
99+
lastProcessedEventIsBackspace = evt.key == "Backspace"
100+
composeSender.sendKeyboardEvent(evt.toComposeEvent())
91101
}
92102
}
93103

94104
"compositionend" -> {
95-
compositionEndEvt = evt as CompositionEvent
96-
lastCompositionEndTimestamp = evt.timeStamp.toDouble()
97-
composeSender.sendEditCommand(CommitTextCommand(compositionEndEvt.data, 1))
105+
lastCompositionEndTimestamp = timestamp
106+
composeSender.sendEditCommand(CommitTextCommand((evt as CompositionEvent).data, 1))
98107
}
99108

100109
"beforeinput" -> {
101-
evt as InputEvent
102-
val inputType = evt.inputType
103-
val data = evt.data
104-
105-
val editCommands = mutableListOf<EditCommand>()
106-
when (inputType) {
107-
"insertFromComposition", "deleteCompositionText" -> {
108-
// We see these events in Safari just before 'compositionEnd' event.
109-
// We do nothing here, because Safari also sends 'insertCompositionText' which we handle,
110-
// and the behavior is as expected atm. We also handle 'compositionEnd'.
111-
}
112-
113-
"deleteContentBackward" -> {
114-
// If it's "Backspace", then it's handled earlier in "keydown" above, so skipping it here
115-
if (keydownEvent?.key != "Backspace") {
116-
if (!currentTextFieldValue.selection.collapsed) {
117-
// Likely it's on mobile, where the Backspace has Unidentified key value.
118-
// When Compose TextField shows text selection,
119-
// a good UX for deleteContentBackward would be to emulate Backspace
120-
editCommands.add(BackspaceCommand())
121-
} else {
122-
// This happens when an autocorrection is applied on mobile:
123-
// The system first tells us to delete the old text,
124-
// and then it would send the "insertText" event.
125-
val deleteSize = evt.deleteContentBackwardSize
126-
if (deleteSize > 0) {
127-
editCommands.add(DeleteSurroundingTextCommand(deleteSize, 0))
128-
}
129-
}
130-
}
131-
}
132-
133-
"insertReplacementText" -> if (data != null) {
134-
val deleteSize = evt.deleteContentBackwardSize
135-
if (deleteSize > 0) {
136-
editCommands.add(DeleteSurroundingTextCommand(deleteSize, 0))
137-
}
138-
editCommands.add(CommitTextCommand(data, 1))
139-
}
140-
141-
"insertText" -> if (data != null) {
142-
val deleteSize = evt.deleteContentBackwardSize
143-
if (deleteSize > 0 && currentTextFieldValue.selection.collapsed) {
144-
editCommands.add(DeleteSurroundingTextCommand(deleteSize, 0))
145-
}
146-
147-
editCommands.add(CommitTextCommand(data, 1))
148-
}
149-
150-
"insertCompositionText" -> if (data != null) {
151-
val deleteSize = evt.deleteContentBackwardSize
152-
if (deleteSize > 0) {
153-
editCommands.add(DeleteSurroundingTextCommand(deleteSize, 0))
154-
}
155-
editCommands.add(SetComposingTextCommand(data, 1))
156-
}
157-
}
158-
composeSender.sendEditCommand(editCommands)
110+
(evt as InputEvent).process(
111+
lastProcessedEventIsBackspace = lastProcessedEventIsBackspace,
112+
currentTextFieldValue = currentTextFieldValue
113+
)
159114
}
160115
}
161116
}
162117

163118
collectedEvents.clear()
164119
}
165120

166-
internal fun addInputEvent(event: InputEvent, deleteContentBackwardSize: Int = 0) {
167-
if (deleteContentBackwardSize > 0) {
168-
event.deleteContentBackwardSize = deleteContentBackwardSize
121+
private fun InputEvent.process(lastProcessedEventIsBackspace: Boolean, currentTextFieldValue: TextFieldValue) {
122+
val editCommands = when (inputType) {
123+
"deleteContentBackward" -> buildList {
124+
// this means "deleteContentBackward" happened because of an earlier "keydown" event, so skipping it here
125+
if (lastProcessedEventIsBackspace) return@buildList
126+
127+
if (!currentTextFieldValue.selection.collapsed) {
128+
// Likely it's on mobile, where the Backspace has Unidentified key value.
129+
// When Compose TextField shows text selection,
130+
// a good UX for deleteContentBackward would be to emulate Backspace
131+
add(BackspaceCommand())
132+
} else {
133+
// This happens when an autocorrection is applied on mobile:
134+
// The system first tells us to delete the old text,
135+
// and then it would send the "insertText" event.
136+
val deleteSize = deleteContentBackwardSize
137+
if (deleteSize > 0) {
138+
add(DeleteSurroundingTextCommand(deleteSize, 0))
139+
}
140+
}
141+
}
142+
143+
"insertReplacementText" -> buildList {
144+
if (data == null) return@buildList
145+
val deleteSize = deleteContentBackwardSize
146+
if (deleteSize > 0) {
147+
add(DeleteSurroundingTextCommand(deleteSize, 0))
148+
}
149+
150+
add(CommitTextCommand(data, 1))
151+
}
152+
153+
"insertText" -> buildList {
154+
if (data == null) return@buildList
155+
val deleteSize = deleteContentBackwardSize
156+
if (deleteSize > 0 && currentTextFieldValue.selection.collapsed) {
157+
add(DeleteSurroundingTextCommand(deleteSize, 0))
158+
}
159+
160+
add(CommitTextCommand(data, 1))
161+
}
162+
163+
"insertCompositionText" -> buildList {
164+
if (data == null) return@buildList
165+
val deleteSize = deleteContentBackwardSize
166+
if (deleteSize > 0) {
167+
add(DeleteSurroundingTextCommand(deleteSize, 0))
168+
}
169+
add(SetComposingTextCommand(data, 1))
170+
}
171+
172+
// "insertFromComposition", "deleteCompositionText" are triggered in Safari just before the 'compositionEnd' event.
173+
// They're ignored because Safari also sends 'insertCompositionText' which we handle (alongside 'compositionEnd')
174+
else -> emptyList()
169175
}
170-
collectedEvents.add(event)
171-
internalScheduleCheckpoint()
172-
}
173176

174-
internal fun addKeyEvent(event: KeyboardEvent) {
175-
collectedEvents.add(event)
176-
internalScheduleCheckpoint()
177+
if (editCommands.isNotEmpty()) {
178+
composeSender.sendEditCommand(editCommands)
179+
}
177180
}
178181

179-
internal fun addCompositionEvent(event: CompositionEvent) {
182+
internal fun registerEvent(event: UIEvent) {
180183
collectedEvents.add(event)
181184
internalScheduleCheckpoint()
182185
}
183186

184187
@TestOnly
185-
internal fun getCollectedEvents(): List<Event> = collectedEvents
188+
internal fun getCollectedEvents() = collectedEvents
186189
}

0 commit comments

Comments
 (0)