A style applied on the text that is tracked by TextFieldBuffer, returned by TextFieldBuffer.addStyle.

TrackedRange acts as a unique handle to a specific style range. Its properties (such as the style object, its range, and expand policy) can be queried and updated using extension properties on TextFieldBuffer:

  • TrackedRange<*>.textRange

  • TrackedRange<*>.expandPolicy

  • TrackedRange<*>.exists

  • TrackedRange<SpanStyle>.spanStyle

  • TrackedRange<ParagraphStyle>.paragraphStyle

All the extension properties reflect the up-to-date state of the style range. e.g. The textRange of this TrackedRange will automatically update when the text is edited. If the style's range collapses to zero length due to text edits, the style will cease to exist and exists will return false.

This object's lifecycle is bound to the TextFieldBuffer which is returned by TextFieldState.edit, InputTransformation.transformInput and OutputTransformation.transformOutput. Do not keep a reference of it.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.delete
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight

// This sample demonstrates how to use the `TrackedRange` API to track and modify text ranges
// dynamically. It implements a basic Markdown-like behavior where text typed inside double
// asterisks (e.g., **bold**) is automatically bolded, and the asterisks are removed.
val state = rememberTextFieldState("")

fun IntRange.toTextRange(): TextRange {
    // Unlike IntRange, TextRange is exclusive at the end.
    return TextRange(first, last + 1)
}

val inputTransformation = remember {
    InputTransformation {
        val text = asCharSequence().toString()
        val matches = "\\*\\*([^*]+)\\*\\*".toRegex().findAll(text).toList()
        matches
            .map { match ->
                val contentRange = match.groups[0]!!.range
                // Apply bold style to the text inside asterisks (including the
                // asterisks for now).
                addStyle(
                    SpanStyle(fontWeight = FontWeight.Bold),
                    contentRange.toTextRange(),
                    ExpandPolicy.InsideOnly,
                )
            }
            .forEach { trackedRange ->
                // Remove the asterisks here.

                // `trackedRange` simplifies this logic: normally, deleting characters at
                // the start would shift the end index. However, because `trackedRange`
                // automatically tracks text updates and adjusts its offsets
                // dynamically, we can safely delete the target range without having to
                // calculate the offset manually.
                delete(trackedRange.textRange.start, trackedRange.textRange.start + 2)
                delete(trackedRange.textRange.end - 2, trackedRange.textRange.end)
            }
    }
}

Column {
    Text("Type **text** below to automatically bold it.")

    BasicTextField(
        state = state,
        textStyle = LocalTextStyle.current,
        inputTransformation = inputTransformation,
    )
}
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.Button
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// This sample demonstrates a realistic rich-text editor scenario using the `TrackedRange` and
// `TextFieldTextStyles` APIs. It implements a "Toggle Bold" formatting function on the current
// selection.

// For simplicity, this sample keeps bold styles non-overlapping and contiguous, assuming they
// are
// applied exclusively through this method.
val state = rememberTextFieldState("Hello World")

// This derived state calculates whether the current selection is completely covered by
// bold text styles. This ensures the "Bold" toggle button accurately reflects the
// state of the selected text.
val isSelection100PercentBold by derivedStateOf {
    val selection = state.selection
    if (selection.collapsed) {
        false
    } else {
        val spanStyles = state.textStyles.getSpanStyles(selection.min, selection.max)
        var boldCoverage = 0
        for (style in spanStyles) {
            if (style.item.fontWeight == FontWeight.Bold) {
                val overlapStart = maxOf(style.start, selection.min)
                val overlapEnd = minOf(style.end, selection.max)
                if (overlapEnd > overlapStart) {
                    boldCoverage += (overlapEnd - overlapStart)
                }
            }
        }
        boldCoverage == selection.length
    }
}

fun TextFieldBuffer.unBoldSelection() {
    // Query existing bold styles in the selection
    val intersectingStyles =
        getSpanStyles(selection.min, selection.max).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
    // We modify or remove existing styles to exclude the selected range
    for (style in intersectingStyles) {
        val range = style.textRange
        if (range.start >= selection.min && range.end <= selection.max) {
            // The style is fully inside the selection. Remove it.
            removeStyle(style)
        } else if (range.start < selection.min && range.end > selection.max) {
            // The style completely covers the selection. We need to split it.
            val oldEnd = range.end
            // Truncate the start part
            style.textRange = TextRange(range.start, selection.min)
            // Add a new style for the end part
            addStyle(
                SpanStyle(fontWeight = FontWeight.Bold),
                TextRange(selection.max, oldEnd),
                ExpandPolicy.AtEnd,
            )
        } else if (range.start < selection.min) {
            // The style overlaps with the start of the selection. Truncate it.
            style.textRange = TextRange(range.start, selection.min)
        } else {
            // The style overlaps with the end of the selection. Truncate it.
            style.textRange = TextRange(selection.max, range.end)
        }
    }
}

fun TextFieldBuffer.boldSelection() {
    // Query existing bold styles in the selection
    val intersectingStyles =
        getSpanStyles(selection.min, selection.max).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
    // To keep bold styles non-overlapping, we merge any intersecting bold
    // styles with the new selection range into a single contiguous bold style.
    var mergedStart = selection.min
    var mergedEnd = selection.max

    for (style in intersectingStyles) {
        mergedStart = minOf(mergedStart, style.textRange.start)
        mergedEnd = maxOf(mergedEnd, style.textRange.end)
        // Remove the fragmented style
        removeStyle(style)
    }

    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        TextRange(mergedStart, mergedEnd),
        ExpandPolicy.AtEnd,
    )
}

Column {
    Button(
        onClick = {
            state.edit {
                val selection = this.selection
                if (selection.collapsed) return@edit
                if (isSelection100PercentBold) {
                    unBoldSelection()
                } else {
                    boldSelection()
                }
            }
        }
    ) {
        Text(
            "B",
            fontWeight = if (isSelection100PercentBold) FontWeight.Bold else FontWeight.Normal,
        )
    }

    BasicTextField(state = state, textStyle = LocalTextStyle.current)
}
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material.Text
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// Wipe the bold style on a given range using the TrackedRange.textRange API
val state = TextFieldState("Hello World")

state.edit {
    // Assume we want to "wipe" all bold styles from the first 5 characters.
    val rangeToWipe = TextRange(0, 5)

    // Get all span styles that intersect with the wipe range.
    getSpanStyles(rangeToWipe.start, rangeToWipe.end).forEach { trackedRange ->
        if (trackedRange.spanStyle.fontWeight == FontWeight.Bold) {
            val current = trackedRange.textRange

            if (rangeToWipe.start <= current.start && current.end <= rangeToWipe.end) {
                // Case 1: The bold style is entirely within the wipe range, remove it.
                removeStyle(trackedRange)
            } else if (current.start < rangeToWipe.start && rangeToWipe.end < current.end) {
                // Case 2: The wipe range is in the middle: split the style into two parts.
                val oldEnd = current.end
                // Truncate the original style to end at the start of the wipe range.
                trackedRange.textRange = TextRange(current.start, rangeToWipe.start)
                // Add a new bold style starting after the wipe range.
                addStyle(trackedRange.spanStyle, rangeToWipe.end, oldEnd)
            } else if (current.start < rangeToWipe.start) {
                // Case 3: Overlap at the start of wipe: truncate the style's end.
                trackedRange.textRange = TextRange(current.start, rangeToWipe.start)
            } else {
                // Case 4: Overlap at the end of wipe: truncate the style's start.
                trackedRange.textRange = TextRange(rangeToWipe.end, current.end)
            }
        }
    }
}
See also
TextFieldBuffer