프로그래밍/Android

[Compose] 빨간 점 시스템 만들기

Lou Park 2024. 10. 20. 16:29

https://www.reddit.com/r/facebook/comments/1emt6v4/messenger_on_android_has_a_red_dot_in_the_3_bars/

 

UI에 작게 보이는 빨간 점은 유저에게 이것을 따라 가보라는 작은 넛지를 준다. 카카오톡에서도 내 친구들이 프로필을 업데이트했을때의 빨간 점을 못이기고 클릭했던 경험도 있을것이다.  빨간 점 시스템을 가장 잘 만들고 사용하고있는 쪽이 어딜까? 바로 게임이다! 

원신 UI

 

게임덕후이자 개발자로서 평소에 게임을 하면서도 이건 어떻게 구현했을까, 상태관리 어떻게 하는걸까 감탄/고민을 자주한다. 요즘은 원신을 정말 재미있게 즐기고있는데, 원신은 유저가 빨간 점을 누르면 "원석"(게임 내 중요 재화)이 생긴다는걸 정말 잘 훈련시켜서 은근슬쩍 원신에서 일어나는 모든 이벤트들을 선전한다. 

 

이런 빨간 점 시스템을 안드로이드에서 구현해보기위해, 내가 게임 개발자다...생각하고 자료를 찾아보았다.  유니티 에셋스토어에 올라온 Unity Red Dot Notify System과 레딧 포스트가 특히 도움되었다. 빨간 점 시스템에서 다루는 데이터는 트리처럼 계층적 구조로 되어있고, 이벤트는 노드의 알림 수에 변화가 생겼을 경우 이를 전파하는 형태다. 그리고 이 알림을 숫자로 보이게 할 것인가, 그냥 느낌표로 띄울 것인가는 별도의 UI 레이어에서 처리한다. 

 

원신을 예시로 구현해보기

앞에서 살짝 원신에 대해서 얘기했으니, 원신을 예시로 빨간 점 시스템을 안드로이드에서 직접 만들어보도록하자. 일반적인 게임들처럼 퀘스트 메뉴에는 메인 퀘스트, 일일 퀘스트가 있다. 메인 퀘스트에는 "페이몬을 도와줘!"라는 퀘스트가 새로 떠서 진행중이라고 해보자.

 

다음의 계층구조가 그려지게 된다.

.
└── 퀘스트
├── 메인 퀘스트
│ └── 페이몬을 도와줘!
└── 일일 퀘스트

 

이런 계층적 관계를 표현하기 위해서 `DotNode`라는 인터페이스를 준비했다. Node는 다른 `DotNode`들을 자식으로 가질 수 있고, `LeafNode`는 자식이 없다. `match`는 나중에 사용할것이지만, 어떤 `LeafNode`가 자신 또는 자식이 맞는지 체크하는 함수다.

 

sealed interface DotNode {
interface LeafNode : DotNode
open class Node(vararg val nodes: DotNode) : DotNode
fun match(node: LeafNode): Boolean {
return when (this) {
is LeafNode -> node == this
is Node -> this.nodes.any { it.match(node) }
}
}
}
view raw DotNode.kt hosted with ❤ by GitHub

 

그러면 원신의 빨간점 시스템을 방금 정의한 `DotNode`로 표현해보자. 

이렇게 선언해두면 다음에 `HelpPaimon`에 대한 빨간 점을 조작하고 싶을때 `GenshinRedDot.Quest.MainQuest.HelpPaimon`로 접근해서 쓸 수 있다. 

object GenshinRedDot {
data object Quest : DotNode.Node(
MainQuest,
DailyQuest
) {
data object MainQuest : DotNode.Node(
HelpPaimon
) {
data object HelpPaimon : DotNode.LeafNode
}
data object DailyQuest : DotNode.LeafNode
}
}

 

 

다음은 `DotNode`들을 관리하고, 업데이트를 처리하는 `DotSystem`의 구현이다.

object DotSystem {
private val onDotChange: MutableList<(DotNode.LeafNode) -> Unit> = mutableListOf()
private val counter = HashMap<DotNode.LeafNode, Int>()
fun addCount(dot: DotNode.LeafNode, count: Int) {
counter[dot] = ((counter[dot] ?: 0) + count).coerceAtLeast(0)
notify(dot)
}
fun setCount(dot: DotNode.LeafNode, count: Int) {
counter[dot] = count.coerceAtLeast(0)
notify(dot)
}
fun clear(dot: DotNode.LeafNode) {
setCount(dot, 0)
}
fun getCount(dot: DotNode): Int {
return when (dot) {
is DotNode.LeafNode -> counter[dot] ?: 0
is DotNode.Node -> dot.nodes.sumOf { getCount(it) }
}
}
fun addListener(listener: (DotNode.LeafNode) -> Unit) {
onDotChange.add(listener)
}
fun removeListener(listener: (DotNode.LeafNode) -> Unit) {
onDotChange.remove(listener)
}
private fun notify(dot: DotNode.LeafNode) {
onDotChange.forEach {
runCatching { it.invoke(dot) }
}
}
}
view raw DotSystem.kt hosted with ❤ by GitHub

 

빨간 점의 숫자는 `LeafNode`만 가질 수 있도록 설계되었다. 일반 노드는 자식들의 합으로만 빨간 점 숫자를 표현가능하다. 숫자를 읽는 것은 어느 노드든 가능하지만, 숫자를 할당하는 것은 `LeafNode`로만 가능하다. 그리고 어떤 `LeafNode`든 업데이트가 일어날 경우 모든 리스너들에게 이 변화를 알린다.

유저가 "페이몬을 도와줘" 퀘스트를 완료할 수 있을 경우, 이 값을 1로 만들어주면 될 것이다.

DotSystem.setCount(GenshinRedDot.Quest.MainQuest.HelpPaimon, 1)

 

이것으로 빨간 점 시스템은 완성되었다. 
이제 이것을 어떻게 표현하면 좋을지 Compose 함수를 작성해보자.

@Composable
fun Dot(
dot: DotNode,
content: @Composable (Int) -> Unit,
) {
var count by remember { mutableIntStateOf(0) }
OnDotChangedEffect(
dot = dot,
onCountChanged = { count = it },
)
if (count > 0) {
content(count)
}
}
@Composable
private fun OnDotChangedEffect(
dot: DotNode,
onCountChanged: (Int) -> Unit,
) = DisposableEffect(Unit) {
val onDotChange = { updatedDot: DotNode.LeafNode ->
if (dot.match(updatedDot)) {
onCountChanged(DotSystem.getCount(dot))
}
}
onCountChanged(DotSystem.getCount(dot))
DotSystem.addListener(onDotChange)
onDispose {
DotSystem.removeListener(onDotChange)
}
}
view raw Dot.kt hosted with ❤ by GitHub

핵심은 `OnDotChangedEffect`다. 

`DisposableEffect`를 이용해 컴포저블이 떨어졌을때 리스너도 깔끔하게 제거될 수 있도록 만들었다. 어떤 `LeafNode`의 숫자에 대한 업데이트가 일어날때, 내가 관찰해야하는 변화인지 `match` 함수를 통해 체크하고, 새로운 `count` 변화를 상위 컴포저블에게 알려준다.

 

이제 이 모든 코드를 합쳐서 Preview로 직접 확인해보자.

 

Daily Quest의 [+] 버튼을 눌러 3으로 만들면 상위 계층인 Group: Quest에도 해당 숫자가 반영된다.

유저가 "페이몬을 도와줘" 퀘스트를 완료할 수 있는 상태가 됨을 가정하고 HelpPaimon 옆의 [+] 버튼을 눌러서 숫자를 올려보면 상위 계층인 MainQuest와 Quest각각에 일어난 변화를 관찰 할 수 있다.

 

@Composable
fun RedDot(
dot: DotNode
) {
Dot(dot = dot) { count ->
Text(
modifier = Modifier
.size(14.dp)
.background(
color = Color.Red,
shape = CircleShape
),
text = count.toString(),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
textAlign = TextAlign.Center,
)
}
}
@Preview(showBackground = true)
@Composable
fun RedDotPreview() {
MaterialTheme {
Column {
RedDotTree(node = GenshinRedDot.Quest)
}
}}
@Composable
fun RedDotTree(
depth: Int = 1,
node: DotNode
) {
when (node) {
is DotNode.Node -> {
Column {
Row(
modifier = Modifier.padding(start = 20.dp.times(depth)),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "Group: ${node.javaClass.simpleName}")
RedDot(dot = node)
}
node.nodes.forEach { child ->
RedDotTree(depth = depth + 1, node = child)
}
} }
is DotNode.LeafNode -> {
Row(
modifier = Modifier.padding(start = 20.dp.times(depth)),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = node.javaClass.simpleName)
RedDot(dot = node)
Spacer(modifier = Modifier.weight(1F))
Button(onClick = { DotSystem.addCount(node, -1) }) {
Text("-")
}
Button(onClick = { DotSystem.addCount(node, 1) }) {
Text("+")
}
}
}
}
}
view raw DotPreview.kt hosted with ❤ by GitHub