Skip to main content

Edit and display rich text

Use TextDocument when style, paragraph, link, or inline-attachment metadata must survive editing. The model is immutable and platform-neutral; Android Spannable is an adapter detail. See the text input architecture for replacement and offset invariants.

Build one document

fun UiTreeBuilder.RichTextEditor() {
val initialDocument = textDocument {
append("ViewCompose", TextSpanStyle(fontWeight = 700))
append(" editor\n")
appendAttachment(
InlineTextAttachment(
id = "preview",
mimeType = "image/png",
uri = "content://example/preview",
contentDescription = "Preview",
),
)
}
val state = rememberTextFieldState(initialDocument)

Column {
RichText(state.document)
TextField(
state = state,
linePolicy = TextFieldLinePolicy.MultiLine(minLines = 3, maxLines = 8),
)
}
}

RichText and TextField use the same document-to-native adapter. Editing updates the state with a new immutable document; the display observes state.document directly.

Preserve annotations and attachments

All offsets are UTF-16 indices. Use TextDocumentBuilder.length and TextRange rather than counting user-perceived characters. A replacement preserves unaffected annotations, clips covered ranges, shifts following ranges, and removes an attachment only when its object-replacement character is replaced.

An inline attachment stores identity, MIME type, optional URI, and accessibility description. URI loading is best effort. A missing decoder, unresolved URI, or non-image payload renders an inline placeholder but does not erase metadata from the document.

Receive external content

Every editable field defaults to text/* and image/*. Provide ReceiveContentConfiguration to narrow MIME negotiation or synchronously validate the normalized document:

fun UiTreeBuilder.SharedContentField() {
val state = rememberTextFieldState()
val policy = ReceiveContentConfiguration(
mimeTypes = setOf("text/*", "image/png"),
transformation = { received ->
received.document.takeIf { it.text.length <= 4_000 }
},
)

TextField(
state = state,
linePolicy = TextFieldLinePolicy.MultiLine(),
receiveContent = policy,
)
}

Clipboard paste, drag/drop, IME commitContent, and application receive-content calls share one listener. The synchronous transformation returns the document to insert or null to reject the normalized payload; it must not retain platform data, load URIs, block on I/O, or launch work. Accepted content runs through InputTransformation, replaces the selection, ends composition, and forms one undo unit. Unsupported clip items return to the platform as remaining content.

Verify the task

Compile with ./gradlew :samples:tutorials:compileDebugKotlin, then verify:

  1. insert text before and inside a styled range; unaffected styles must move or clip correctly;
  2. edit beside an attachment; its identity and description must remain attached to the same object character;
  3. use an unavailable attachment URI; a placeholder must appear without losing document metadata;
  4. recreate the Activity; rich annotations, attachments, and selection must restore;
  5. paste styled text and drop an image; each accepted payload must replace the selection as one undoable edit while unsupported mixed items remain available to the platform;
  6. enter bidirectional and supplementary Unicode text; selection and edits must use native UTF-16 positions without crashes or split surrogate pairs.

Flattened styling, Android types in shared state, missing attachment metadata, or code-point offsets used as document indices are failed integrations.