A style applied on the text that is tracked by TextFieldBuffer, returned by TextFieldBuffer.addStyle.
BasicTextFieldTrackedRangeSample
@Composable
fun BasicTextFieldTrackedRangeSample() {
// 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,
)
}
}
@Composable
fun BasicTextFieldTrackedRangeTextRangeSetterSample() {
// 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)
}
}
}
}
}
BasicTextFieldTrackedRangeToggleBoldSample
@Composable
fun BasicTextFieldTrackedRangeToggleBoldSample() {
// 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)
}
}