해시태그(Hashtag)
시작하며
제작 중인 앱 Stashit(스태싯, 전 가칭 ReReminder)은 서랍, 창고 등 다양한 장소에 보관해 둔 물건들을 기록할 수 있는 앱이다. 카테고리와 보관 장소 외에도 추가로 사용자가 직접 태그를 설정할 수 있도록 하기 위해 해시태그 입력 기능을 만들게 되었다.
사용화면&기능 소개
• 단어를 입력하고 확인(화면에서는 다음)을 누르면 단어 앞에 #(해시)를 붙이고 끝에 공백을 추가한다.
- '안드로이드', '코틀린', '컴포즈'를 해시태그로 만들었다.
- 단어를 입력할 때 앞뒤 공백은 제거한다.
• 확인을 눌렀을 때 기존에 존재하던 태그가 없다면 삭제한다.
- '컴포즈'에서 '즈'를 지우고 확인을 눌러 '컴포즈'가 삭제되고 '컴포'가 해시태그가 되었다.
• 확인을 눌렀을 때 단어 없이 #만 있다면 #을 삭제한다.
- '#컴포'에서 '컴포'를 지우고 #만 남긴 후 확인을 눌러 '컴포'가 삭제되고 #이 사라졌다.
구현기
처음에는 태그 리스트에 있는 항목과 현재 텍스트에 포함된 태그를 비교해서 현재 필드에 존재하지 않는 태그를 지우고 새롭게 태그를 추가하는 방식으로 구현하려 했지만 입력하는 태그가 많지 않기에 전체 항목을 대체하는 방식으로도 괜찮을 것이라 생각해 바꾸었다.(비교에 어려움을 겪기도 하였다. 중복 처리 등등...)
그리고 일반 String의 값을 변경하면 커서의 위치가 새롭게 추가되는 #과 공백으로 마지막에 위치하지 않아 문제가 생겨 커서의 위치를 설정할 수 있는 TextFieldValue 또는 TextFieldState를 사용해야 했는데 recomposition 최적화를 하는 김에 TextFieldState를 사용하는 텍스트필드로 구현하였다.(추후에 TextFieldState 관련 글도 작성할 예정이다)
코드 설명
- 전체 코드
// viewModel(Composable 함수에서 rememeber로 생성해도 무방)
val tags = mutableStateListOf<String>()
val tagText = mutableStateOf(TextFieldState())
// composable
BasicTextField(
state = tagText,
textStyle = textFieldTextStyle, // 개인 커스텀
onKeyboardAction = {
tags.clear()
tags.addAll(tagText.text.split("\\s+".toRegex()).map { it.removePrefix("#").trim() }.filter { it.isNotEmpty() }.distinct().map { "#$it" })
tagText.edit {
replace(0, tagText.text.length, tags.joinToString(" "))
if (tags.isNotEmpty()) append(" ")
}
},
lineLimits = TextFieldLineLimits.SingleLine,
interactionSource = state.tagInteraction, // rememeber { MutableInteractionSource() }로 대체
decorator = { innerTextField ->
val isFocused by state.tagInteraction.collectIsFocusedAsState()
val color by animateColorAsState(Color(0xFFE0E0E0).takeIf { !isFocused } ?: Color.Black)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.drawBehind {
drawLine(
color = color,
start = Offset(0f, size.height - 0.5.dp.toPx()),
end = Offset(size.width, size.height - 0.5.dp.toPx()),
strokeWidth = 1.dp.toPx()
)
}
) {
Text(
text = "태그",
fontSize = 16.sp,
lineHeight = 16.sp,
color = animateColorAsState(Color(0xFFA0A0A0).takeIf { !isFocused } ?: Color.Black).value
)
Spacer(
modifier = Modifier
.width(40.dp)
)
Box(
content = { innerTextField() },
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
)
if (tags.isNotEmpty() && isFocused) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_close_circle_thin),
contentDescription = "지우기",
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {
tagText.clearText()
tags.clear()
}
)
)
}
}
}
)
먼저 태그를 저장할 리스트와 태그 텍스트를 만든다. 뷰모델이 아닌 composable에서 선언하려면 rememberTextFieldState()라는 세이버를 내장한 함수를 사용하면 되고 세이브가 필요하지 않다면 간단하게 remember로 선언해서 사용해도 된다.
- 필자는 mutableStateListOf<String>()를 사용해서 SnapshotStateList를 만들었지만 일반 mutableStateOf<List<String>>(emptyList())를 사용해도 무방하다. 하지만 clear 함수와 addAll 함수를 사용하려면 mutableStateListOf()를 사용하는 것을 추천한다.
- TextFieldState를 사용하는 텍스트필드를 사용해 기능을 구현하였지만 TextFieldValue를 사용하는 텍스트필드에서도 사용은 가능하다. TextFieldValue를 사용하는 텍스트필드에서는 onKeyboardAction이 아닌 keyboardAction에서 KeyboardAction을 호출하면서 onDone(next 등등) 파라미터에서 사용하면 된다.
- 자세한 설명
tags.clear()
tags.addAll(tagText.text.split("\\s+".toRegex()).map { it.removePrefix("#").trim() }.filter { it.isNotEmpty() }.distinct().map { "#$it" })
tagText.edit {
replace(0, tagText.text.length, tags.joinToString(" "))
if (tags.isNotEmpty()) append(" ")
}
완료를 눌렀을 때 동작하는 코드이다.
1) clear 함수를 사용해 저장된 태그 리스트를 초기화한다.
2) 텍스트를 공백을 기준으로 나누고 최초 실행이 아니어서 태그가 존재하는 경우를 대비해 모든 # 기호를 앞에서 뗀다. 그리고 #과 공백으로만 이루어진 태그를 막기 위해 isNotEmpty로 필터링한다. 그 후 distinct로 중복 제거 후 앞에 #을 붙여 태그로 만든다.
3) replace 함수로 텍스트 전체를 태그 사이에 공백을 추가한 텍스트로 교체한다.(edit과 replace는 TextFieldState 함수이다) 그리고 태그가 비어있지 않아 텍스트가 여백이 아니라면 공백을 추가해 새로운 텍스트 입력이 마지막 태그에서 떨어진 곳에서 시작하게 한다.
마치며
최대한 발생할 수 있는 상황을 예측하면서 예외 처리를 하였지만 미처 예상하지 못한 상황이 있을 수 있기에 그런 상황을 발견한다면 말씀해주시면 감사하겠습니다. 그 외에도 궁금한 점이나 피드백은 댓글이나 카톡으로 연락해 주시면 최대한 빠르게 답변해 드리겠습니다.