발음 오류를 문장 위에 그리기 — Canvas와 getBoundingBox로 좌표 맞추기
이 글은 ShadowEng 프로젝트에서 작성되었습니다.
목차
한 줄 요약
Compose Canvas와 TextLayoutResult.getBoundingBox로 발음 평가 결과(dragged/rushed/missed)를 문장 위에 화살표·곡선·하이라이트로 그린 방법을 정리하고, 이 렌더링이 실제로는 서버 쪽 평가 스텁 때문에 끝까지 이어지지 않는다는 한계도 함께 짚는다.
왜 (배경/문제 상황)
발음 평가 결과를 “72점”처럼 숫자로만 보여주면 사용자는 정확히 어디를 고쳐야 하는지 알기 어렵다. ShadowEng은 이 문제를 “단어별로 무엇이 잘못됐는지 문장 위에 직접 그려서 보여주기”로 풀었다. 그런데 Compose Text는 글자 하나하나가 화면 어디에 그려졌는지를 호출부에 그냥 알려주지 않는다 — 이 좌표를 직접 구해야 그 위에 화살표나 곡선을 겹쳐 그릴 수 있다.
본문
1. 평가 결과 → 시각 요소 매핑
EvaluationAnnotationMapper.kt가 서버가 내려주는 단어별 상태를 시각 요소 타입으로 변환한다.
| 상태 | 시각 요소 | 표현 |
|---|---|---|
| dragged (길게 끌었다) | CURVE_LONG | 완만한 물결 곡선 |
| rushed (급하게 지나갔다) | CURVE_SHORT | 짧은 V자 곡선 |
| missed (아예 빠졌다) | HIGHLIGHT | 배경 하이라이트 |
| good (문제 없음) | 없음 | 표시하지 않음 |
곡선·화살표(CURVE_LONG/CURVE_SHORT/ARROW_UP/ARROW_DOWN)는 Canvas에 Path로 직접 그리고, HIGHLIGHT는 Canvas가 아니라 SpanStyle의 배경색으로 처리한다. 같은 “표시”라도 구현 계층이 다르기 때문이다 — 하이라이트는 텍스트 배경일 뿐이라 Text의 표준 스타일링 기능으로 충분하지만, 곡선·화살표는 문장 흐름 위에 임의의 곡선을 그려야 해서 텍스트 API로는 표현할 수 없고 별도 캔버스가 필요하다.
2. Text는 글자 좌표를 모른다 — TextLayoutResult가 다리를 놓는다
Text composable은 “무엇을 그릴지”는 알지만, 그 결과가 화면 어디에 그려졌는지는 기본적으로 호출부에 넘겨주지 않는다. onTextLayout 콜백으로 받는 TextLayoutResult가 이 간극을 메운다.
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = sentence,
onTextLayout = { layoutResult = it },
)
TextLayoutResult.getBoundingBox(charIndex)를 부르면 그 문자 인덱스가 텍스트 레이아웃의 로컬 좌표계에서 차지하는 사각형(Rect, 픽셀 단위)을 돌려준다. Text와 Canvas가 같은 위치에서 시작하는 경우(이 글의 Box 겹침 구조처럼)라면 이 좌표를 그대로 써도 되지만, 서로 원점이 다르면 LayoutCoordinates로 좌표계를 변환해야 한다. 어노테이션의 시작 글자와 끝 글자 각각에 이 함수를 불러 두 사각형을 얻으면, 그 사이에 곡선이나 화살표를 그릴 좌표 기준이 생긴다.
val startRect = layoutResult.getBoundingBox(annotation.startIndex)
val endRect = layoutResult.getBoundingBox(annotation.endIndex - 1)
val mid = (startRect.top + startRect.bottom) / 2
val centerX = (startRect.left + endRect.right) / 2
3. 그 위에 Canvas로 겹쳐 그리기
Text 위에 같은 크기의 Canvas를 겹쳐두고, 위에서 구한 좌표로 Path를 그린다.
Box {
Text(text = sentence, onTextLayout = { layoutResult = it })
Canvas(modifier = Modifier.matchParentSize()) {
annotations.forEach { ann ->
val startRect = layoutResult?.getBoundingBox(ann.startIndex) ?: return@forEach
val endRect = layoutResult?.getBoundingBox(ann.endIndex - 1) ?: return@forEach
when (ann.type) {
AnnotationType.CURVE_LONG -> drawLongCurve(startRect, endRect)
AnnotationType.CURVE_SHORT -> drawShortCurve(startRect, endRect)
AnnotationType.ARROW_UP -> drawArrow(startRect, endRect, pointingUp = true)
AnnotationType.ARROW_DOWN -> drawArrow(startRect, endRect, pointingUp = false)
AnnotationType.HIGHLIGHT -> Unit // SpanStyle에서 처리 (Canvas 아님)
}
}
}
}
Canvas는 Text가 실제로 어떤 글자를 그렸는지 전혀 모른다 — TextLayoutResult가 넘겨주는 좌표만 갖고 그 위에 도형을 그릴 뿐이다. 이 둘을 잇는 다리가 getBoundingBox(charIndex)다.
4. 직접 눌러보기 — 좌표를 구하는 과정 그대로 재현
아래 데모는 위 2·3번 과정에서 좌표를 구하는 흐름(시작/끝 인덱스 → 두 사각형 → 중점·중심 X좌표 → Path)을 웹 브라우저에서 그대로 흉내 낸 것이다. 실제로는 Compose TextLayoutResult.getBoundingBox()를 호출하는 게 아니라, CanvasRenderingContext2D.measureText()로 글자별 사각형을 직접 계산해 boxes[startIndex]/boxes[endIndex - 1]로 근사한다 — 글꼴 셰이핑·줄바꿈·유니코드 폭 처리 방식이 달라서 실제 Compose 결과와 정확히 같은 수치가 나오지는 않지만, “좌표를 구해서 그 위에 그린다”는 흐름 자체는 동일하다.
5. 결함: 같은 단어가 두 번 나오면
어노테이션의 시작 위치는 sentence.indexOf(word)로 찾는데, 이 함수는 문장에서 첫 번째로 일치하는 위치만 돌려준다. 같은 단어가 문장에 두 번 이상 나오면, 서버가 두 번째 등장 위치를 가리켰더라도 화면에는 첫 번째 등장 위치에 곡선이 그려진다. Canvas 렌더링 로직 자체는 좌표만 정확하면 문제없이 동작하지만, 그 좌표를 찾는 단계에서 이미 잘못된 인덱스를 넘겨받으면 엉뚱한 곳에 그려진다 — 렌더링과 좌표 탐색은 별개 문제라는 걸 보여주는 사례다.
예제
세 요소를 하나로 합치면, 어노테이션 하나를 그리는 전체 흐름은 다음과 같다.
@Composable
fun AnnotatedSentenceView(sentence: String, annotations: List<Annotation>) {
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Box {
Text(text = sentence, onTextLayout = { layoutResult = it })
Canvas(modifier = Modifier.matchParentSize()) {
val layout = layoutResult ?: return@Canvas
annotations.forEach { ann ->
val startRect = layout.getBoundingBox(ann.startIndex)
val endRect = layout.getBoundingBox(ann.endIndex - 1)
val mid = (startRect.top + startRect.bottom) / 2
when (ann.type) {
AnnotationType.CURVE_LONG -> drawPath(
path = buildWaveCurve(startRect.left, endRect.right, mid, amplitude = 6f),
color = Color(0xFFFFB800),
style = Stroke(width = 3f),
)
AnnotationType.CURVE_SHORT -> drawPath(
path = buildVCurve(startRect.left, endRect.right, mid),
color = Color(0xFF1565C0),
style = Stroke(width = 3f),
)
AnnotationType.ARROW_UP, AnnotationType.ARROW_DOWN -> drawPath(
// 시작/끝 사각형 사이 중심에 위·아래를 가리키는 화살표 Path를 그린다 (곡선과 같은 좌표 기준 사용)
path = buildArrowPath(startRect, endRect, mid, pointingUp = ann.type == AnnotationType.ARROW_UP),
color = Color(0xFF2EB872),
style = Stroke(width = 3f),
)
AnnotationType.HIGHLIGHT -> Unit // Canvas가 아니라 SpanStyle에서 처리
}
}
}
}
}
주의사항
- 이 Canvas 시각화는 클라이언트 쪽에서는 완성돼 있지만, 이 스냅샷 기준으로 서버의 발음 평가 로직은 점수가 하드코딩된 스텁이었다(
EvaluationService.kt—// TODO: AI 분석 연동 시 실제 점수로 교체). “화면에 정확히 그려지는가”와 “그 평가 결과 자체가 진짜인가”는 별개 문제이고, 이 글은 전자만 다룬다. sentence.indexOf(word)는 첫 번째 일치 위치만 찾으므로, 같은 단어가 문장에 반복되면 좌표가 어긋날 수 있다. 정확히 하려면 서버가 인덱스를 함께 내려주거나, 이전에 이미 매칭된 위치 이후부터 다시 탐색해야 한다.- 하이라이트(
SpanStyle)와 곡선·화살표(Canvas)는 같은 “시각 요소”로 뭉뚱그리기 쉽지만 구현 계층이 다르다. 텍스트 스타일링으로 충분한 것까지 Canvas로 옮기면 코드만 복잡해진다. getBoundingBox()자체는Rect를 반환하며 null이 아니다. 다만onTextLayout이 아직 한 번도 호출되지 않은 첫 프레임에는layoutResult변수 자체가 null이므로,getBoundingBox를 부르기 전에layoutResult를 null 체크해야 한다.