필자가 회사에서 업무를 진행하다 보면, 가끔 디자이너로부터 "이 텍스트가 물결처럼 움직였으면 좋겠어요" 또는 "타자기처럼 한 글자씩 타이핑되는 효과를 넣어주세요" 같은 요청을 받게 된다. 일반 Text 컴포넌트로는 절대 만들 수 없는 효과들이고, 이런 요청을 받을 때마다 어떻게 해야 할지 고민이 깊어지는 것 같다.
Jetpack Compose는 기본 Text 컴포넌트 외에도 저수준 텍스트 API를 제공하고 있는데, 최근 기술 블로그를 확인하다가 Segun Famisa의 글에서 TextMeasurer와 Canvas를 조합해 커스텀 텍스트 효과를 구현하는 방법을 알게 되었다.
이번 글에서는 TextMeasurer와 Canvas를 활용해 페이드, 웨이브, 타이핑 등의 텍스트 효과를 직접 구현해보고자 한다.
핵심 API 살펴보기
먼저 사용하게 될 세 가지 핵심 API를 간단히 정리해보자.
1. TextMeasurer
TextMeasurer는 텍스트를 실제로 그리기 전에 미리 측정할 수 있게 해주는 API이다. 스타일, 텍스트, 제약 조건을 받아서 텍스트의 크기와 레이아웃 정보를 계산한다.
val textMeasurer = rememberTextMeasurer()
val textLayout = remember(text, textStyle, constraints) {
textMeasurer.measure(
text = text,
style = textStyle,
constraints = constraints
)
}
2. TextLayoutResult
측정 결과로 나오는 TextLayoutResult에는 텍스트 레이아웃에 대한 상세 정보가 담겨 있다. 이게 사실상 커스텀 렌더링의 핵심이라고 봐도 무방한 것 같다.
lineCount: 텍스트가 그려질 라인 수getLineStart(idx)/getLineEnd(idx): 라인의 시작·끝 문자 인덱스getLineLeft/Right/Top/Bottom(idx): 각 라인의 좌표getBoundingBox(charIdx): 특정 문자의 바운딩 박스
3. Canvas의 drawText
실제로 텍스트를 그릴 때는 Canvas의 drawText를 사용한다. TextMeasurer와 함께 호출하면 임의의 좌표에 원하는 스타일로 텍스트를 그릴 수 있다.
1. FadedText - 라인별 페이드 효과
가장 간단한 예제부터 시작해보자. 멀티라인 텍스트에서 위쪽 라인은 거의 투명하고, 아래로 내려갈수록 점점 진해지는 효과이다.
핵심 아이디어는 단순하다. lineCount 만큼 반복문을 돌면서 각 라인의 알파값을 점진적으로 증가시키면 된다.
@Composable
fun FadedText(
text: String,
textStyle: TextStyle,
modifier: Modifier = Modifier
) {
BoxWithConstraints(modifier) {
val density = LocalDensity.current
val constraints = constraints
val textMeasurer = rememberTextMeasurer()
val textLayout = remember(text, textStyle, constraints) {
textMeasurer.measure(
text = text,
style = textStyle,
constraints = constraints
)
}
val canvasSize = with(density) {
DpSize(textLayout.size.width.toDp(), textLayout.size.height.toDp())
}
Canvas(modifier = Modifier.size(canvasSize)) {
for (lineIndex in 0 until textLayout.lineCount) {
val startCharIndex = textLayout.getLineStart(lineIndex)
val endCharIndex = textLayout.getLineEnd(lineIndex)
val lineLeftCoordinate = textLayout.getLineLeft(lineIndex)
val lineTopCoordinate = textLayout.getLineTop(lineIndex)
// 각 라인의 알파값 계산 (0부터 시작해서 점진적으로 증가)
val alpha = textStyle.color.alpha * lineIndex.toFloat() / textLayout.lineCount
val lineText = text.substring(startCharIndex, endCharIndex)
drawText(
textMeasurer = textMeasurer,
text = lineText,
topLeft = Offset(x = lineLeftCoordinate, y = lineTopCoordinate),
style = textStyle.copy(color = textStyle.color.copy(alpha = alpha))
)
}
}
}
}
위 코드에서 주목할 점은 BoxWithConstraints로 감싸서 constraints를 TextMeasurer에 전달했다는 것이다. 이렇게 해야 부모 레이아웃의 너비 제약을 반영해서 정확한 라인 수가 계산된다.
2. WarpedText - 문자별 웨이브 효과
이번에는 한 단계 더 들어가서, 라인이 아닌 문자 단위로 제어해보자. 각 문자가 사인파처럼 위아래로 배치되어 물결치는 듯한 효과를 만든다.
여기서 활용하는 핵심 API가 getBoundingBox(charIndex)이다. 이걸로 각 문자의 좌표를 받아서, sin 함수로 Y 오프셋을 계산해 주면 된다.
Canvas(modifier = Modifier.size(canvasSize)) {
for (lineIndex in 0 until textLayout.lineCount) {
val startCharIndex = textLayout.getLineStart(lineIndex)
val endCharIndex = textLayout.getLineEnd(lineIndex)
for (charIndex in startCharIndex until endCharIndex) {
val rect = textLayout.getBoundingBox(charIndex)
val char = textLayout.layoutInput.text[charIndex].toString()
withTransform({
// 문자 인덱스를 사인파의 입력으로 사용
translate(
left = 0f,
top = 5 * sin(charIndex * 0.7).toFloat()
)
}) {
drawText(
textMeasurer = textMeasurer,
text = char,
topLeft = Offset(x = rect.left, y = rect.top),
style = textStyle
)
}
}
}
}
withTransform 블록 안에서 translate를 적용하면, 그 블록 안에서만 좌표 변환이 적용된다. 한 문자를 그린 뒤에는 변환이 자동으로 해제되므로, 다음 문자에 영향을 주지 않는다.
charIndex * 0.7 부분은 사인파의 주기를 결정한다. 값을 키우면 파장이 짧아지고, 줄이면 파장이 길어진다. 필자가 직접 값을 바꿔보면서 테스트해 본 결과, 0.7 정도가 가장 자연스러운 물결 효과를 만들어 주는 것 같았다.
3. AnimatedWarpedText - 움직이는 웨이브
정적인 웨이브도 멋있지만, 실제로 움직여야 더 와닿는 법이다. rememberInfiniteTransition으로 사인파의 진폭(amplitude)을 무한히 애니메이션시키면 된다.
val infiniteTransition = rememberInfiniteTransition(label = "wave")
val sinusoidalAmplitude by infiniteTransition.animateFloat(
initialValue = with(density) { -5.dp.toPx() },
targetValue = with(density) { 5.dp.toPx() },
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "amplitude"
)
// ... 그리기 시 amplitude 사용
withTransform({
translate(
left = 0f,
top = sinusoidalAmplitude * sin(charIndex * 0.7).toFloat()
)
}) {
drawText(...)
}
고정값이었던 5를 sinusoidalAmplitude로 바꾼 것이 전부이다. 진폭이 -5에서 +5 사이를 오가면서 텍스트 전체가 부드럽게 흔들리는 효과가 만들어진다.
4. TypewriterText - 타이핑 효과
마지막은 타자기 효과이다. 텍스트가 한 글자씩 순차적으로 나타난다. 이건 메시지 앱이나 챗봇 UI 같은 곳에서 자주 보게 되는 효과이다.
구현은 의외로 단순하다. Animatable로 "현재 보여줄 문자 수"를 0에서 전체 길이까지 애니메이션시키고, 그 값만큼만 잘라서 그리면 된다.
val animatedCharacterCount = remember { Animatable(0f) }
LaunchedEffect(text, repeat) {
if (repeat) {
// 무한 반복
while (true) {
animatedCharacterCount.animateTo(
targetValue = text.length.toFloat(),
animationSpec = tween(
durationMillis = text.length * 50,
easing = LinearEasing
)
)
animatedCharacterCount.snapTo(0f)
}
} else {
// 1회 실행
animatedCharacterCount.snapTo(0f)
animatedCharacterCount.animateTo(
targetValue = text.length.toFloat(),
animationSpec = tween(
durationMillis = text.length * 50,
easing = LinearEasing
)
)
}
}
Canvas(modifier = Modifier.size(canvasSize)) {
val visibleChars = animatedCharacterCount.value.toInt()
for (lineIndex in 0 until textLayout.lineCount) {
val startCharIndex = textLayout.getLineStart(lineIndex)
val endCharIndex = textLayout.getLineEnd(lineIndex)
if (visibleChars > startCharIndex) {
val topCoordinate = textLayout.getLineTop(lineIndex)
val leftCoordinate = textLayout.getLineLeft(lineIndex)
// 현재 라인에서 표시할 마지막 문자 인덱스
val displayedEndIndex = min(endCharIndex, visibleChars)
val displayedText = textLayout.layoutInput.text.substring(
startCharIndex,
displayedEndIndex
)
drawText(
textMeasurer = textMeasurer,
text = displayedText,
topLeft = Offset(x = leftCoordinate, y = topCoordinate),
style = textStyle
)
}
}
style = textStyle
)
}
}
}
전체 길이 × 50ms 만큼의 시간을 주면, 자연스러운 타이핑 속도가 나오는 것 같다. 너무 빠르면 효과가 무의미하고, 너무 느리면 답답하기 때문에 50ms 정도가 적당하다고 느껴졌다.
구현하면서 신경 써야 했던 부분
1. BoxWithConstraints는 거의 필수
처음에 BoxWithConstraints 없이 그냥 작성해보았는데, 텍스트가 한 줄에 다 들어가지 않으면 클리핑되어 버리는 문제가 있었다. 결국 부모의 너비 제약을 TextMeasurer에 전달해야 정확한 라인 분할이 일어난다는 점을 알게 되었다.
2. remember로 측정 결과 캐싱
TextMeasurer.measure()는 비용이 큰 연산이다. 매 리컴포지션마다 호출되면 성능이 안 좋아질 수 있어서, 반드시 remember로 캐싱해야 한다. 의존성 배열은 text, textStyle, constraints 정도로 충분하다.
3. 문자별 그리기는 비용이 큼
웨이브 효과처럼 문자별로 drawText를 호출하면, 짧은 텍스트에서는 문제가 없지만 긴 텍스트에서는 성능 저하가 있을 수 있다. 가능하면 라인 단위로 처리하고, 정말 필요한 경우에만 문자 단위로 내려가는 게 좋을 것 같다.
마무리
이번 글에서는 TextMeasurer와 Canvas를 조합해 페이드, 웨이브, 타이핑 등의 커스텀 텍스트 효과를 구현해 보았다.
처음 TextMeasurer를 봤을 때는 단순히 "텍스트 크기 재는 도구" 정도로 생각했는데, TextLayoutResult가 제공하는 라인·문자 단위 정보가 생각보다 풍부해서 응용할 수 있는 범위가 매우 넓다는 것을 알게 되었다. 그라디언트 텍스트, 아웃라인 텍스트, 3D 효과 같은 것들도 같은 방식으로 만들 수 있을 것 같다.
다음에는 이 API들을 응용해서 텍스트에 그라디언트를 입히거나, 문자별로 다른 색상을 적용하는 효과도 한번 만들어볼 예정이다.
필자도 아직 Compose의 저수준 그래픽 API에 대해서는 공부 중이기 때문에 작성한 개념이나 설명에 오류가 있을 가능성이 있다는 점을 감안하고 읽어주었으면 좋겠다.
해당 게시글에 사용한 예제는 다음 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample
GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.
This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample
github.com
'Android > Jetpack Compose' 카테고리의 다른 글
| [Compose] TabRow와 ScrollableTabRow 사이에서 고민될 때, SubcomposeLayout으로 반응형 탭을 만들어보자 (0) | 2026.05.01 |
|---|---|
| [Compose] Canvas로 도형 그리기와 애니메이션 기초를 알아보자 (0) | 2026.04.26 |
| [Compose] TopAppBarScrollBehavior로 Fancy TopAppBar를 구현해보자 (0) | 2026.04.23 |
| [Compose] Quick Settings Tile로 마이크로 인터랙션 패턴을 구현해보자 (0) | 2026.04.19 |
| [Compose] 커스텀 TopAppBarScrollBehavior 구현하기 - 부분 렌더링 없는 부드러운 숨김/표시 (1) | 2026.04.18 |