AndroidX Compose UI 1.8.0에서 Bullet을 지원하기 시작했습니다.
기존 View 시스템에서의 Bullet 텍스트 적용 방법을 소개한 글이 있으니 관심 있는 분은 읽어보시길 바랍니다.
본 글에서는 다루지 않지만, Jetpack Compose도 내부적으로 비슷한 Span을 사용합니다.
Jetpack Compose에서 Bullet 사용 시에는 AnnotatedString 생성에 사용하는 buildAnnotatedString Builder를 사용합니다.
// 위 샘플에 해당하는 코드
Text(
text = buildAnnotatedString {
withBulletList {
withBulletListItem {
append("Text")
}
}
}
)
먼저 withBulletList API를 사용해서 Bullet에 대한 공통 들여쓰기 및 Bullet 기호를 정의할 수 있는 DSL을 만듭니다.
fun <R : Any> withBulletList(
indentation: TextUnit = Bullet.DefaultIndentation,
bullet: Bullet = Bullet.Default,
block: AnnotatedString.Builder.BulletScope.() -> R
): R
withBulletListItem API는 내부 블록에서 정의한 콘텐츠 주위에 Bullet 기호를 생성합니다. 기본적으로 이전 Builder.withBulletList API에서 정의된 Bullet 기호와 들여쓰기를 적용하여 별도의 단락을 생성합니다.
fun <R : Any> AnnotatedString.Builder.BulletScope.withBulletListItem(
bullet: Bullet? = null,
block: AnnotatedString.Builder.() -> R
): R
실제로 withBulletList 내부 block에서 withBulletListItem API 호출없이 append만 한다면, indentation(들여쓰기)만 적용됩니다. Bullet 사용에는 withBulletListItem API가 무조건 사용된다고 생각하면 됩니다.
Text(
text = buildAnnotatedString {
withBulletList {
append("ABCD") // Bullet 미노출
withBulletListItem {
append("ABCD") // Bullet 노출
}
}
}
)
아래 샘플 코드에 fontSize에 10sp ~ 20sp를 적용하면, 기본 Bullet
이더라도 폰트 크기에 맞춰서 Bullet의 크기와 여백이 적용됩니다.
@Composable
fun TextSample(
fontSize: TextUnit = LocalTextStyle.current.fontSize
) {
Text(
text = buildAnnotatedString {
withBulletList {
withBulletListItem {
append("Text")
}
}
},
fontSize = fontSize,
...
)
}
Bullet의 기본 값들은 em
이라는 Text Unit을 사용하므로 텍스트 크기에 상대적으로 맞춰집니다.
class Bullet(
...
) : AnnotatedString.Annotation {
companion object {
/** Indentation required to fit [Default] bullet. */
val DefaultIndentation = 1.em
/** Height and width for [Default] bullet. */
val DefaultSize = 0.25.em
/** Padding between bullet and start of paragraph for [Default] bullet */
val DefaultPadding = 0.25.em
/** Default bullet used in AnnotatedString's bullet list */
val Default = Bullet(CircleShape, DefaultSize, DefaultSize, DefaultPadding)
}
}
Bullet 소스 출처 : 링크
HTML 태그가 있는 String의 경우에는 AnnotatedString.fromHtml을 사용하면 쉽게 AnnotatedString을 얻을 수 있습니다. AnnotatedString을 지원하는 Text/BasicText에 전달하면 Bullet이 노출됩니다.
Text(
text = AnnotatedString.fromHtml(
"<ul><li>가나다라<ul><li>ABCD</li></ul></li><ul>"
)
)
추가로 기본 Bullet 스타일을 사용한 withBulletList과 비교하면 결과는 동일한 것을 볼 수 있습니다.
Column(...) {
// withBulletList 샘플
Text(
text = buildAnnotatedString {
withBulletList {
withBulletListItem {
append("가나다라")
withBulletList {
withBulletListItem {
append("ABCD")
}
}
}
}
}
)
// AnnotatedString.fromHtml을 사용한 샘플
Text(
text = AnnotatedString.fromHtml(
"<ul><li>가나다라<ul><li>ABCD</li><ul></li><ul>"
)
)
}
AnnotatedString의 DSL 패턴인 buildAnnotatedString과 AnnotatedString.fromHtml 코드는 작성 방법은 다르지만, 최종적으로 AnnotatedString을 만드는 Spanned.toAnnotatedString 확장 함수를 호출하고 있습니다.
// buildAnnotatedString Builder
inline fun buildAnnotatedString(builder: (Builder).() -> Unit): AnnotatedString =
Builder().apply(builder).toAnnotatedString()
// AnnotatedString.fromHtml 확장 함수
fun AnnotatedString.Companion.fromHtml(
...
): AnnotatedString {
...
val spanned =
HtmlCompat.fromHtml(stringToParse, HtmlCompat.FROM_HTML_MODE_COMPACT, null, TagHandler)
return spanned.toAnnotatedString(linkStyles, linkInteractionListener)
}
Spanned.toAnnotatedString 확장 함수는 internal 접근 제한자로 정의되어 있습니다.
@VisibleForTesting
internal fun Spanned.toAnnotatedString(
linkStyles: TextLinkStyles? = null,
linkInteractionListener: LinkInteractionListener? = null,
): AnnotatedString {
return AnnotatedString.Builder(capacity = length)
.append(this)
.also { it.addSpans(this, linkStyles, linkInteractionListener) }
.toAnnotatedString()
}
Spanned.toAnnotatedString 소스 출처 : 링크
Text(
text = buildAnnotatedString {
withBulletList {
withBulletListItem { append("Item 1") }
withBulletList {
withBulletListItem { append("Item 2") }
withBulletListItem { append("Item 3") }
withBulletList {
withBulletListItem { append("Item 4") }
}
}
withBulletListItem { append("Item 5") }
}
}
)
val bullet1 = Bullet.Default.copy(
shape = MaterialShapes.Clover4Leaf.toShape(),
width = Bullet.DefaultSize * 2,
height = Bullet.DefaultSize * 2,
)
val bullet2 = bullet1.copy(drawStyle = Stroke(2f))
val bullet3 = bullet1.copy(
brush = Brush.horizontalGradient(
0.0f to Color.Red,
0.3f to Color.Green,
1.0f to Color.Blue
)
)
Text(
text = buildAnnotatedString {
withBulletList(bullet = bullet1) {
withBulletListItem { append("Item 1") }
withBulletList(bullet = bullet2) {
withBulletListItem { append("Item 2") }
withBulletListItem { append("Item 3") }
withBulletList(bullet = bullet3) {
withBulletListItem { append("Item 4") }
}
}
withBulletListItem { append("Item 5") }
}
}
)
Subscribe to this blog via RSS.
[발표자료] Google I/O Extended Incheon 2025 ~ What's new in Android development tools
Posted on 16 Aug 2025