fix(android): preserve inline styles during IME composition - #737
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes an Android-only IME composition edge case where predictive keyboards (via repeated setComposingText) can drop previously-applied enriched inline style spans when an inline style is toggled mid-word. The change adds targeted span snapshot/restore logic in the Android InputConnection wrapper so only unchanged parts of the composing word keep their prior inline styling.
Changes:
- Adds an inline-style restoration helper to reapply a specific inline style span over a given range when missing.
- Tracks when inline-style preservation should run (only after an inline style is toggled during an active composing region).
- Snapshots inline spans inside the composing range before
setComposingText/commitText, then restores them over the unchanged prefix/suffix, clearing state when composition is committed/finished.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt | Adds restoreStyleOnRange to reapply a specific inline style span when absent. |
| android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt | Stores the wrapped InputConnection and triggers preservation when toggling inline styles. |
| android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputConnectionWrapper.kt | Implements composing-range snapshot + restore around IME updates and clears preservation state appropriately. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
hejsztynx
left a comment
There was a problem hiding this comment.
Hi @woosanggyu !
First of all, thanks for taking your time to implement that fix.
Unfortunately, it doesn't work quite right - when following the initial test case you provided, it's fine, but when you move the cursor elsewhere and return to the end of that typed word, the inline styles are not preserved.
Screen.Recording.2026-08-04.at.12.59.47.mov
The previous approach only armed span preservation when an inline style was toggled during an active composition. Moving the caret ends the composition and clears that flag, so returning to the end of the word and typing again let the IME replace the whole word with no snapshot taken, dropping the styled run. Gate on the content being replaced instead of on the toggle event: snapshot whenever the composing region carries inline spans, and restore afterwards. captureComposingText() returns null when there is nothing to preserve, so plain typing still costs a single span lookup. This also removes the cached EnrichedTextInputConnectionWrapper on the view, which silently no-opped whenever the cached instance differed from the one the IME was driving. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for catching that — you're right, and the root cause was the gate I used. The old version only armed preservation when an inline style was toggled during The trigger isn't the toggle, it's a composing replacement over text that That removed the whole state machine along with the view-side hook, so Verified on an emulator with Microsoft SwiftKey (English):
|
| editable | ||
| .getSpans(composingStart, composingEnd, EnrichedInlineSpan::class.java) | ||
| .mapNotNull { span -> | ||
| val style = | ||
| EnrichedSpans.inlineSpans.entries | ||
| .firstOrNull { (_, config) -> config.clazz.isInstance(span) } | ||
| ?.key | ||
| ?: return@mapNotNull null | ||
| val start = editable.getSpanStart(span).coerceAtLeast(composingStart) | ||
| val end = editable.getSpanEnd(span).coerceAtMost(composingEnd) | ||
|
|
||
| if (start < end) { | ||
| InlineSpanSnapshot(style, start - composingStart, end - composingStart) | ||
| } else { | ||
| null | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
It would look cleaner and we would get rid of the "guessing" style lookup. What do you think?
| editable | |
| .getSpans(composingStart, composingEnd, EnrichedInlineSpan::class.java) | |
| .mapNotNull { span -> | |
| val style = | |
| EnrichedSpans.inlineSpans.entries | |
| .firstOrNull { (_, config) -> config.clazz.isInstance(span) } | |
| ?.key | |
| ?: return@mapNotNull null | |
| val start = editable.getSpanStart(span).coerceAtLeast(composingStart) | |
| val end = editable.getSpanEnd(span).coerceAtMost(composingEnd) | |
| if (start < end) { | |
| InlineSpanSnapshot(style, start - composingStart, end - composingStart) | |
| } else { | |
| null | |
| } | |
| } | |
| EnrichedSpans.inlineSpans.flatMap { (style, config) -> | |
| editable.getSpans(composingStart, composingEnd, config.clazz).mapNotNull { span -> | |
| val start = editable.getSpanStart(span).coerceAtLeast(composingStart) | |
| val end = editable.getSpanEnd(span).coerceAtMost(composingEnd) | |
| if (start < end) { | |
| InlineSpanSnapshot(style, start - composingStart, end - composingStart) | |
| } else { | |
| null | |
| } | |
| } | |
| } | |
| setAndMergeSpans(spannable, type, start, end) | ||
| } | ||
|
|
||
| fun restoreStyleOnRange( |
There was a problem hiding this comment.
It strongly feels like duplicated logic, maybe instead of defining a new function, let's reuse applyStyleOnRange that we have above?
Summary
Fixes #734.
On Android, predictive keyboards replace the entire active composing word on each
setComposingTextcall. When an inline style was toggled off in the middle of that word, the replacement discarded enriched inline spans that belonged to the unchanged prefix.This change:
setComposingTextorcommitText;The change is limited to the Android text input implementation.
Test Plan
Manual verification in the example Android app with Gboard predictive text / suggestions enabled:
abc.defwithout a space.ghiwithout a space.defremains bold.Automated checks:
yarn lintpassed.yarn typecheckpassed.yarn test --maxWorkers=2passed (415 tests).Screenshots / Videos
See the reproduction video in #734. The fix does not change layout or visual design.
Compatibility
Checklist