[Android Compose] LazyColumn에 스티키 헤더(Sticky Header)와 클릭 이벤트 구현하는 법
일반적인 리사이클러뷰에 스티키 헤더와 헤더별로 그룹화된 아이템들이 들어가도록 구현하려면 둘 이상의 뷰홀더를 구현해야 하고, 복잡한 코드를 작성해야 하는 불편함과 공수가 걸린다.
그러나 Compose에선 이 작업을 아주 많이 간소화시켜서 구현할 수 있다. 클릭 이벤트를 적용하는 것도 쉽다.
말은 필요없고 코드부터 본다. 먼저 res/values 패키지 안에 적당히 이름 붙인 values resource file을 만들고 아래 코드를 붙여넣는다. 서버에서 받아오는 대신 하드코딩한 문자열을 사용할 것이다.
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="item_list">
<item>First Item</item>
<item>First Item1</item>
<item>First Item2</item>
<item>Second Item</item>
<item>Second Item2</item>
<item>Second Item3</item>
<item>Second Item4</item>
<item>Third</item>
<item>Fourth</item>
<item>Fifth</item>
<item>Fifth Item1</item>
<item>Fifth Item2</item>
<item>Sixth</item>
<item>Sixth Item1</item>
<item>Sixth Item2</item>
<item>Seventh</item>
<item>Seventh Item1</item>
<item>Seventh Item2</item>
<item>Seventh Item3</item>
</string-array>
</resources>
그리고 액티비티에 적절하게 아래 코드들을 복붙한다.
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.example.composebookexample.ui.theme.ComposeBookExampleTheme
class TestActivity : ComponentActivity() {
private lateinit var itemArray: ArrayList<String>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
itemArray = ArrayList(resources.getStringArray(R.array.item_list).toList())
setContent {
ComposeBookExampleTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
StickyTest(items = itemArray)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun StickyTest(items: ArrayList<String>) {
val context = LocalContext.current
val onItemClick = { text: String ->
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
}
val groupedItems = items.groupBy { it.substringBefore(" ") }
LazyColumn {
groupedItems.forEach { (headerTitle, models) ->
stickyHeader {
Text(
text = headerTitle,
color = Color.White,
modifier = Modifier
.background(Color.Gray)
.padding(5.dp)
.fillMaxWidth()
)
}
items(models) { model ->
MyItem(
item = model,
onItemClick = onItemClick
)
}
}
}
}
@Composable
fun MyItem(item: String, onItemClick: (String) -> Unit) {
Card(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
.clickable { onItemClick(item) },
shape = RoundedCornerShape(10.dp),
elevation = 5.dp
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = item,
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(8.dp)
)
}
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview21() {
ComposeBookExampleTheme {
}
}
이게 스티키 헤더를 갖는 LazyColumn과 여기에 클릭 이벤트로 클릭한 아이템의 토스트를 띄우게 하는 전체 코드다.
실행해 보면 아래처럼 작동한다.
코드가 필요하다면 여기까지만 보면 될 것이다. 이 아래부터는 위 코드를 확인하면서 어떻게 위 영상과 같이 작동하는지 확인한다.
먼저 MyItem 컴포저블부터 확인한다.
@Composable
fun MyItem(item: String, onItemClick: (String) -> Unit) {
Card(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
.clickable { onItemClick(item) },
shape = RoundedCornerShape(10.dp),
elevation = 5.dp
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = item,
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(8.dp)
)
}
}
}
기존 리사이클러뷰에선 구현을 위해 아이템의 XML을 사용하는 파일을 만들었다. 데이터 바인딩도 사용했었다.
Compose를 사용하더라도 이 절대적인 흐름은 변하지 않는다. 결국 LazyColumn이란 틀 안에서 세부 아이템들을 어떻게 표시할지는 개발자가 정해줘야 한다. MyItem 컴포저블이 LazyColumn 안에서 아이템을 어떻게 표시하는지 정하는 컴포저블이란 것만 알면 된다. Card가 어떻고 Row가 어떻고는 이 글을 검색해서 찾았다면 대충은 알 테니 생략한다. Icon은 이미지를 넣고 싶은데 귀찮아서 적당히 내장 드로어블 쓰기 위해 사용했다.
다음은 StickyTest 컴포저블이다.
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun StickyTest(items: ArrayList<String>) {
val context = LocalContext.current
val onItemClick = { text: String ->
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
}
val groupedItems = items.groupBy { it.substringBefore(" ") }
LazyColumn {
groupedItems.forEach { (headerTitle, models) ->
stickyHeader {
Text(
text = headerTitle,
color = Color.White,
modifier = Modifier
.background(Color.Gray)
.padding(5.dp)
.fillMaxWidth()
)
}
items(models) { model ->
MyItem(
item = model,
onItemClick = onItemClick
)
}
}
}
}
@OptIn은 현재 stickyHeader {}가 Compose에서 실험적인 기능이기 때문에 꼭 붙여야 하는 어노테이션이다. 아래의 안드로이드 디벨로퍼 링크에서 오늘 날짜로 최신 안정화 버전인 1.3.3으로 바꿔도 꼭 써야 한다. 쓰지 않는다면 컴파일 에러가 발생하면서 앱 실행이 되지 않고 아래 에러 문구가 표시된다.
This foundation API is experimental and is likely to change or be removed in the future
이 기초 API는 실험적이며 향후 변경되거나 제거될 가능성이 있다
https://developer.android.com/jetpack/androidx/releases/compose?hl=ko
때문에 해당 어노테이션이 만약 거슬리더라도 꼭 필요한 것이니 써준다.
먼저 함수 본문의 첫 2줄은 토스트를 표시하기 위해 Context를 얻고, Toast에 이 Context와 표시할 문자열을 넣어준다. 이 문자열은 MyItem 컴포저블의 2번째 인자인 onItemClick 람다의 매개변수다. StickyTest()의 마지막을 보면 "item = model"이란 코드가 있는데, 여기서 model이 토스트에 표시되는 문자열이다.
이후 groupBy {}를 사용해서 ArrayList 안의 요소들을 특정 기준으로 그룹화한다. substringBefore(" ")을 썼기 때문에 item_list.xml에 선언한 문자열들의 첫 단어를 그룹화의 기준으로 삼는다.
다시 item_list.xml을 확인해 본다.
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="item_list">
<item>First Item</item>
<item>First Item1</item>
<item>First Item2</item>
<item>Second Item</item>
<item>Second Item2</item>
<item>Second Item3</item>
<item>Second Item4</item>
<item>Third</item>
<item>Fourth</item>
<item>Fifth</item>
<item>Fifth Item1</item>
<item>Fifth Item2</item>
<item>Sixth</item>
<item>Sixth Item1</item>
<item>Sixth Item2</item>
<item>Seventh</item>
<item>Seventh Item1</item>
<item>Seventh Item2</item>
<item>Seventh Item3</item>
</string-array>
</resources>
First, Second 우측에 공백이 있고 그 뒤에 Item, Item1 등의 문자열이 있다. 여기서 공백 기준으로 왼쪽의 단어, 즉 First, Second만 얻어오는 것이다.
groupedItems에 로그를 찍어 보면 아래처럼 표시된다.
groupedItems : {First=[First Item, First Item1, First Item2], Second=[Second Item, Second Item2, Second Item3, Second Item4], Third=[Third], Fourth=[Fourth], Fifth=[Fifth, Fifth Item1, Fifth Item2], Sixth=[Sixth, Sixth Item1, Sixth Item2], Seventh=[Seventh, Seventh Item1, Seventh Item2, Seventh Item3]}
First라는 키에 First 문자열을 갖는 아이템들이 대괄호 안에 들어가고, Second라는 키에 Second 문자열을 갖는 아이템들이 대괄호 안에 들어가는 식으로 표시되는 걸 볼 수 있다. 이 때 First, Second, Third 글자들은 key고 대괄호 안의 문자열들은 value라고 보면 된다.
참고로 groupBy {}와 substringBefore()를 꼭 사용할 필요는 없다. 스티키 헤더를 구현하기 위해 사용할 수 있는 수많은 방법 중 하나기 때문에, 쓸데없이 많은 노력을 기울여 죽자고 이해하려 들 필요는 없다. 그냥 이렇게 하면 저렇게 되는구나 정도로만 이해하고 넘어가면 좋겠다.
이후 LazyColumn 안에서 groupedItems에 forEach를 걸어 groupedItems 안의 요소들에 대해 반복할 내용을 코드로 적는다. 이 곳에서 headerTitle, models라는 키밸류 형태로 썼는데, 눈치챘겠지만 headerTitle은 스티키 헤더에 들어갈 문자열이고 models는 MyItem 안에 표시할 문자열인 동시에 토스트로 표시할 문자열이다.
그리고 "items(models)"를 통해서 models, 즉 groupedItems의 value에 대해 수행할 작업을 코드로 적는다. 리시버 이름은 model이고 MyItem 컴포저블을 초기화할 때 첫 번째 인자로 넘겨서, MyItem의 Card 안에 있는 clickable {} 안으로 들어가게 한다. 추가로 items()를 쓰려면 꼭 import 해야 한다. import하지 않으면 컴파일 에러가 발생한다.
이렇게 해서 스티키 헤더의 기본 예제 구현이 끝났다. 전역 private 필드로 선언한 itemArray와 MyItem 안의 Icon에는 서버에서 받아온 값들을 넣도록 자신의 프로젝트에 맞게 수정하면 될 것이다.