문제 상황
Compose로 UI를 구성하던 중에 문제 상황이 발생하였습니다.
위의 이미지와 같이 검색 결과 중 검색어와 같은 글자의 텍스트 색상을 변경하는 디자인을 구성해야 했습니다.
xml로 작업할 때는 RecyclerView의 adapter에서 함수를 사용해서 쉽게 변경할 수 있었지만,
compose를 배우고 있는 터라 방법을 찾기 어려웠습니다.
구글링을 통해서 관련 라이브러리나 함수를 찾아보았지만 마땅한 방법이 없어서 직접 알고리즘을 구현하기로 결심하였습니다.
알고리즘 고민
단순히 검색 글자와 같은 부분을 찾아서 색상을 변경하기에는 아래와 같은 문제가 있었습니다.
1. 검색 내용을 여러개 포함하는 경우
- "바다" 단어로 검색을 하였을 때, "바다 앞 바다 카페" 의 검색 결과의 경우 2가지 검색 내용을 탐색해야 합니다.
2. 검색 내용이 없는 경우
- "바다" 단어로 검색을 하였을 때, "바닷가"와 같은 검색 결과의 경우 검색 내용을 포함하지 않습니다.
검색 결과 텍스트에서 .indexOf() 함수를 통해서 검색 내용을 포함하는 모든 인덱스를 찾는 방법이 필요했습니다.
방법을 구상하던 중에 확장함수(Extension Functions)를 활용하기로 했습니다.
Extension Functions
코틀린은 상속하거나 디자인 패턴을 사용하지 않고도 기능을 확장하는 방법을 제공합니다.
수정할 수 없는 타사 라이브러리나 기존의 코틀린 함수에 새로운 함수를 작성할 수 있으며,
이러한 함수는 원래 클래스의 메소드인 것처럼 일반적인 방식으로 호출할 수 있습니다.
이러한 방식을 확장 함수라고 하며, 기존 클래스에 대한 새로운 속성을 적용하는 확장 속성도 사용할 수 있습니다.
<확장함수를 활용한 모든 인덱스 탐색>
fun String.indexOfAll(str : String) : MutableList<Int>{
var index = this.indexOf(str)
val returnIndex = mutableListOf<Int>()
while (index != -1){
returnIndex.add(index)
index = this.indexOf(str, index+1)
}
return returnIndex
}
String 코틀린 자료형에 새로운 확장 함수를 적용하였습니다.
indexOfAll을 통해서 해당 스트링에서 모든 str의 index를 반환할 수 있습니다.
while 반복문을 통해서 String을 탐색하고 모든 인덱스를 반환하는 구조입니다.
각 검색 결과 아이템은 2가지 파라미터를 받습니다.
fun SearchResultItem(
searchLog: SearchLog,
currentText : MutableState<TextFieldValue>,
)
serachLog : 검색 결과
currentText : 사용자가 입력한 검색 내용
확장 함수를 통해서 검색 결과에 사용자가 입력한 검색 내용을 모두 추출합니다.
var indexList = searchLog.text.indexOfAll(currentText.value.text)
이 후에 사용자가 입력한 검색 내용의 길이를 측정합니다.
val currentTextSize = currentText.value.text.length
이제 Text를 보여주는 compose material인 Text에 알고리즘을 적용시킵니다.
이 때 buildAnnotatedString을 사용하는데, 이는 분할 텍스트 스타일 적용에 도움을 줍니다.
https://developer.android.com/reference/kotlin/androidx/compose/ui/text/AnnotatedString.Builder
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
buildAnnotatedString {
append("Hello")
// push green text style so that any appended text will be green
pushStyle(SpanStyle(color = Color.Green))
// append new text, this text will be rendered as green
append(" World")
// pop the green style
pop()
// append a string without style
append("!")
// then style the last added word as red, exclamation mark will be red
addStyle(SpanStyle(color = Color.Red), "Hello World".length, this.length)
toAnnotatedString()
}
위의 코드는 샘플 예시이며, 텍스트 스타일을 다르게 지정하고 있습니다.
buildAnnotatedString을 통해서 확장함수에서 받은 인덱스별로 텍스트 색상을 적용합니다.
indexList가 비어있는 경우, 검색 내용에 텍스트가 없는 경우이므로 모든 데이터를 색상 변화 없이 나타냅니다.
이 외의 경우는 인덱스 리스트를 탐색하면서 검색 내용의 길이만큼 텍스트 색상을 변경합니다.
중요한 점은,
인덱스 리스트 + 현재 검색 내용의 길이 만큼 색상을 변경하고, 다음 인덱스까지는 원래 색상을 적용합니다.
만약에 다음 인덱스가 없는 경우 (i == indexList.size-1)는 더 이상 일치 값이 없는 경우이므로 나머지 부분을 원래 색상으로 적용합니다.
Text(
text = buildAnnotatedString {
if (indexList.isEmpty()) { append(searchLog.text) }
else {
for (i in 0 until indexList.size){
withStyle(
SpanStyle(color = colorResource(id = R.color.secondary_1))
){
append(searchLog.text.substring(indexList[i],indexList[i]+currentTextSize))
}
if (i == indexList.size-1){ //마지막
append(searchLog.text.substring(indexList[i]+currentTextSize))
}else{
append(searchLog.text.substring(indexList[i]+currentTextSize,indexList[i+1]))
}
}
}
},
fontSize = 14.sp,
fontFamily = pretendardRegular,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis // ... 처리
)
이제 LazyColum을 통해서 아이템을 뿌려주면, 검색 내용과 결과에 따라서 색상 변화 된 텍스트를 얻을 수 있습니다.
인덱스를 다루는 만큼 index 범위를 넘어가지 않도록 예외처리 하는 것이 중요할 것 같습니다.
이상으로 커스텀 텍스트를 다루는 방법을 알아보았습니다.