[Jetpack Compose] buildAnnotatedString으로 텍스트 하이라이팅 컴포넌트 개발

코코아의 앱 개발일지·2025년 7월 21일
0

Jetpack Compose

목록 보기
4/4

안녕하세요, 오늘은 동아리 프로젝트에서 buildAnnotatedString으로 '텍스트 하이라이팅' 기능을 개발했던 과정을 적어보려고 합니다!
단순 하이라이팅 코드 뿐만 아니라 컴포넌트를 구현하면서 어떤 점들을 고민했는지 같이 적어볼게요.

✍🏻 요구사항 분석

기획 & 디자인

앱에서 구현해야하는 기능 중 텍스트 하이라이팅 기능이 있었습니다.
내가 일기를 쓰면 AI가 해당 일기를 보고 피드백을 해주는데요, ‘AI가 고쳐준 문장은 하이라이팅 표시를 해줘야한다!’가 요구사항이었습니다.

디자인을 보면 피드백 된 문장은 주황색 + 살짝의 볼드가 들어간 걸 확인할 수 있었습니다.

기획과 디자인은 살펴봤으니,, 이제 어떤 형태로 응답이 올지 API 명세서를 봐야겠죠?

서버 응답

아직 API가 배포되기 전이었지만, 개발 파트 리드(안드-아요-서버)끼리 이미 기능 구현 관련해서 여러 번 회의를 진행한 상태였고, 이를 바탕으로 서버 측에서는 API 명세서를 미리 만들어 주셨습니다. (서쌤들 최고!!)

텍스트 하이라이팅이 필요한 API의 응답값은 아래와 같았습니다.

서버 및 iOS 측과 논의한 결과, 서버에서는 하이라이팅이 필요한 부분은 diffRanges로,
rewriteText(AI가 피드백해준 문장)에서 고쳐진 부분의 start-end index를 넘겨주기로 했습니다.

이에 하이라이팅의 관건은 ‘index 범위를 통해 텍스트에서 스타일을 바꿔주는 것’이라고 생각했습니다.

안드 리드님이 컨펌하신 구조이니 ‘index를 통해 텍스트를 하이라이팅할 방법은 무조건 있을 것이다’라는 믿음으로 저는 컴포넌트부터 구현해보기로 했습니다.

저는 우선 아래와 같은 과정으로 구현 계획을 세웠어요.

아래 과정을 보시면 아시겠지만 어려워보이는 건 최대한 미뤄두는 편..

  1. 기본 컴포넌트 구현 (이미지 및 텍스트, 글자수)
  2. 스위치의 토글값(isAIWritten)에 따른 분기 처리
    a. maxLength 변경
    b. isAIWritten에 따른 찐 하이라이팅 적용 코드 추가

자, 이제 분석이 끝났으니 이제 진짜 구현을 하러 가볼까요??

💻 구현 과정

1. 기본 컴포넌트 구현

originat/rewrittenText에 따라 달라질 게 없는, 기본 요구사항을 먼저 반영했어요.

[⚙️ 공통 요구사항]
1. 이미지 유무
a. 유저가 일기에 이미지를 첨부했을 경우
- 원본 사진 비율과 관계없이 가로 100% 기준 세로, 60%로 미리보기 표시(중앙 정렬 crop)
- 이미지 클릭 시 원본 이미지를 볼 수 있음
b. 유저가 일기에 이미지를 첨부하지 않은 경우
- 이미지를 제외한 내용 교정만 제공
2. 일기 내용 텍스트 글자수 표시
- 내가 쓴 일기의 텍스트 필드 최대 글자수는 1000자

우선은 ‘내가 쓴 일기’ 기준으로 코드를 작성해보았습니다!

@Composable
fun DiaryCard(
    content: String,
    modifier: Modifier = Modifier,
    imageUrl: String? = null
) {
    Column(
        modifier = modifier
            .clip(RoundedCornerShape(8.dp))
            .background(HilingualTheme.colors.white)
            .fillMaxWidth()
            .padding(12.dp)
    ) {
        if (imageUrl != null) { // 이미지
            NetworkImage(
                imageUrl = imageUrl,
                shape = RoundedCornerShape(8.dp),
                contentScale = ContentScale.Crop,
                modifier = Modifier
                    .fillMaxWidth()
                    .aspectRatio(1f / 0.6f)
            )
        }
        Text( // 일기 내용
            text = content,
            style = HilingualTheme.typography.bodyR16,
            color = HilingualTheme.colors.black,
            modifier = Modifier.fillMaxWidth()
        )
        Text( // 글자수
            text = "${content.length}/1000",
            style = HilingualTheme.typography.captionR12,
            color = HilingualTheme.colors.gray400,
            textAlign = TextAlign.End,
            modifier = Modifier.fillMaxWidth()
        )
    }
}

이미지 유무에 따라 아래처럼 나오는 모습을 확인할 수 있었습니다.

  • Preview 코드
    @Preview(showBackground = true, backgroundColor = 0x000000)
    @Composable
    private fun FeedbackContentPreview() {
        HilingualTheme {
            Column(
                verticalArrangement = Arrangement.spacedBy(4.dp)
            ) {
                DiaryContentCard(
                    imageUrl = "",
                    content = "텍스트",
                )
                DiaryContentCard(
                    content = "I want to become a teacher future. Because I like child."
                )
            }
        }
    }

2. 스위치의 토글값(isAIWritten)에 따른 분기 처리

토글 여부에 따라 바꿔주어야 할 값은 아래의 두 가지 부분이었습니다.

💡 토글에 따른 변경
1. maxLength 변경 (AI: 1500자, MY: 1000자)
2. diffRanges에 해당하는 텍스트 하이라이팅

a. maxLength 변경

텍스트 하이라이팅은 조금 더 미뤄두고, maxLength부터 적용을 해보기로 했습니다.

⚙️ [추가한 요구사항] - 텍스트 필드 글자수 표시
1. 토글 active: AI 수정 피드백
- AI가 수정한 일기의 텍스트 필드 최대 글자수는 1500자
- 1500자 이상 생성 시 1500자 초과된 글자는 표시하지 않음
2. 토글 inactive: 내가 쓴 일기
- 내가 쓴 일기의 텍스트 필드 최대 글자수는 1000자

이를 위해 우선은 DiaryCard에 isAIWritten 값을 추가해 보았습니다.

@Composable
fun DiaryCard(
    content: String,
    isAIWritten: Boolean,
    modifier: Modifier = Modifier,
    imageUrl: String? = null
) {
    val maxContentLength = if (isAIWritten) 1500 else 1000

		val clipContent = diaryContent.run {
        if (length > maxContentLength) this.take(maxContentLength) else this
    }
    
    Column(
        modifier = modifier
            .clip(RoundedCornerShape(8.dp))
            .background(HilingualTheme.colors.white)
            .fillMaxWidth()
            .padding(12.dp)
    ) {
        if (imageUrl != null) {
            NetworkImage(
                imageUrl = imageUrl,
                shape = RoundedCornerShape(8.dp),
                modifier = Modifier
                    .fillMaxWidth()
                    .aspectRatio(1f / 0.6f)
            )
        }
        Text(
            text = clipContent,
            style = HilingualTheme.typography.bodyR16,
            color = HilingualTheme.colors.black,
            modifier = Modifier
                .heightIn(min = 45.dp)
                .fillMaxWidth()
        )
        Text(
            text = "${clipContent.length}/${maxContentLength}",
            style = HilingualTheme.typography.captionR12,
            color = HilingualTheme.colors.gray400,
            textAlign = TextAlign.End,
            modifier = Modifier.fillMaxWidth()
        )
    }
}

isAIWritten에 따른 일기 내용의 최대 글자수인 maxContentLength(AI: 1500자, MY: 1000자)를 선언해주고, ‘1500자 이상 생성 시 1500자 초과된 글자는 표시하지 않음’ 이라는 요구사항에 맞게 텍스트 표시 시에 maxContentLength를 넘어가는 텍스트는 take를 통해 잘라주게끔 구현했습니다.

AI 피드백에 대한 처리는 서버에서 진행했어요.
즉, 프론트에서는 서버에서 넘겨준 텍스트를 그대로 받아서 사용해야 했습니다.

그래서 저는 서버 측에서도 DB 저장 시에 AI 피드백 일기를 1500자로 잘라서 저장할지가 궁금했고, 바로 문의를 해봤었는데요,

서버에서도 1500자를 잘라서 저장한다는 답변이 돌아왔습니다!

그래도 혹시 모르니.. clipContent를 통해 안드에서도 1000자/15000자를 한 번 더 잘라서 표시하게끔 했습니다.

.

.

.

이제 진짜………………………

하이라이팅을…….적용할………………..시간입니다.

과연,,,, 순탄하게 구현할 수 있을 것인가?

두려운 마음으로…. 일단은 시작~!

b. 텍스트 하이라이팅 적용

🤚🏻 잠깐!!

아까의 요구사항을 잠시 복기하면서 어떻게 구현할지 잠시 생각을 해봅시다.

  1. 서버에서는 하이라이팅이 필요한 문장의 start와 endIndex(+ 고쳐진 부분)을 리스트로 내려준다.

    → forEach로 diffRanges를 돌리면 변화된 부분을 하이라이팅 시킬 수 있지 않을까?

  2. 하이라이팅 문장은 mainColor로 표시해줘야 한다.

    → 솝커톤에서 텍스트 더보기 기능 구현한다고 사용해봤던 buildAnnotatedString을 활용할 수 있으려나?

위처럼 대략적으로 구조를 생각해보고,
바로 제가 생각한 방식으로 구현이 될지 검색을 해봤습니다.

Let’s go 구글랑~!~!!

🔎 ‘compose text highlighting’

이라는 이름으로 검색을 해줄게요.

저는
How to highlight specific word of the text in jetpack compose?
이 스택오버플로 글이 제일 처음 나왔는데요.

달린 답변 중에 아래 코드를 발견했고,

val annotatedString = buildAnnotatedString {
    val str = "Hello World" // or stringResource(id = R.string.hello_world)
    val boldStr = "Hello" // or stringResource(id = R.string.hello)
    val startIndex = str.indexOf(boldStr)
    val endIndex = startIndex + boldStr.length
    append(str)
    addStyle(style = SpanStyle(color = Color.Red), start = startIndex, end = endIndex)
}
Text(
    text = annotatedString,
)

startIndex, endIndex가 있는 걸 보고 ‘이거구나!!’ 싶었습니다. (역시 갓택오버플로우~!)

buildAnnotatedString에서 원본 텍스트를 append하고, 하이라이팅 시킬 부분만 addStyle안에 작성해주면 금방 구현할 수 있을 것 같았어요. (그럼 forEach 바로 돌려도 될 거 같은데?? 야호 ٩( ᐛ )و)

위에서는 볼드 처리를 시켜줄 텍스트를 따로 String으로 받고 있었지만, 실질적으로 addStyle에 들어가야 할 값은 startIndex, endIndex일 뿐이니까 아래의 diffRanges 서버 응답 중 correctedText는 안 써도 되겠다 싶었습니다. (서버에서도 그냥 확인 편하게 하려는 용도로 주는 값이라고 했었어요.)

"diffRanges": [
      {
        "start": 79,
        "end": 140,
        "correctedText": "arrive at 1:30 p.m., but I ended up getting there at 2:20"
      },
      {
      //...
      },
]

그래서 diffRangesList<Pair<Int, Int>>로 넘겨줘야겠다! 생각했습니다.

diffRanges: ImmutableList<Pair<Int, Int>> = persistentListOf(),

아래 코드를 작성해서 테스트를 진행해봤는데, 하이라이팅이 잘 되는 것을 확인할 수 있었습니다.

val annotatedString = buildAnnotatedString {
        append(content)
        diffRanges.forEach {
            addStyle(
                style = SpanStyle(color = HilingualTheme.colors.hilingualOrange),
                start = it.first,
                end = it.second
            )
        }
    }

너무 잘 되지용??

테스트가 끝났으니~ 코드를 완성해봅시다.

DiaryCard에서 텍스트 하이라이팅이 적용된 코드입니다!

getAnnotatedString()에서 diffRanges 대로 하이라이팅을 적용한 전체 buildAnnotatedString을 반환하고,
isAIWritten 여부에 따라 하이라이팅을 적용한 텍스트를 보여줄지, 아니면 그냥 텍스트를 보여줄지 displayText로 분기 처리를 해줍니다.

@Composable
internal fun DiaryCard(
    isAIWritten: Boolean,
    diaryContent: String,
    modifier: Modifier = Modifier,
    diffRanges: ImmutableList<Pair<Int, Int>> = persistentListOf(),
    imageUrl: String? = null
) {
    val maxContentLength = if (isAIWritten) 1500 else 1000

    val displayText: AnnotatedString = if (isAIWritten) {
        getAnnotatedString(clipContent, diffRanges)
    } else {
        AnnotatedString(clipContent)
    }

    Column(
        modifier = modifier
            .clip(RoundedCornerShape(8.dp))
            .background(HilingualTheme.colors.white)
            .fillMaxWidth()
            .padding(12.dp)
    ) {
        // ... 이미지

        Text(
            text = displayText,
            style = HilingualTheme.typography.bodyR16,
            color = HilingualTheme.colors.black,
            modifier = Modifier.fillMaxWidth()
        )

        // ... 글자수 표시 텍스트
    }
}

@Composable
private fun getAnnotatedString(
    content: String,
    diffRanges: ImmutableList<Pair<Int, Int>>
): AnnotatedString {
    return buildAnnotatedString {
        append(content)
        diffRanges.forEach {
            addStyle(
                style = SpanStyle(
                    color = HilingualTheme.colors.hilingualOrange,
                    fontFamily = SuitMedium
                ),
                start = it.first,
                end = it.second
            )
        }
    }
}

📱 완성 화면

PreviewParameterProvider를 활용해 여러 케이스를 확인해 본 모습입니다!

이미지 및 diffRanges, isAIWritten에 따라 달라지는 모습을 확인할 수 있었습니다.

~끝~

profile
안드로이드 개발자를 꿈꾸는 학생입니다

0개의 댓글