본문으로 바로가기
반응형

Photo by unsplash

지난번 2.layout의 기본(1/2) [2]에 이어서 가장 흔하게 쓰이는 list에 대한 layout에 대해서 얘기해 봅니다.

화면에서 item을 구성하는 리스트는 흔하게 존재하는 UX입니다. 많은 데이터를 보여주기에 적합한 형태이기 때문에 Compose에서는 기본적으로 ColumnRow로 이를 구현하며, 화면에만 보이는 compose만 그리도록 하는 lazy list를 이용하여 기존의 RecyclerView를 대체합니다.

이 글은 Android developer 공식 사이트에서 제공하는 문서를 기반으로 의역, 번역 하였습니다.[1]

먼저 100개의 item을 표시하는 순서대로 (Vertically) list로 만들어 보여주기 위해 아래와 같이 구현할 수 있습니다.

@Composable
fun SimpleList() {
    Column {
        repeat(100) {
            Text("Item $it")
        }
    }
}

하지만, 첫 번째로 화면에 보이지 않는 모든 compose를 생성하기 때문에 비효율적이고, 두 번째로 Column은 기본적으로 scroll을 지원하지 않기 때문에 실제로 아래의 item을 볼수가 없습니다. 먼저 두번째 문제를 해결하기 위해서는 scroll 가능하도로 Modifier에 추가적인 작업을 해야 합니다.

@Composable
fun SimpleList1() {
    // 스크롤의 position의 상태를 저장.
    val scrollState = rememberScrollState()

    Column(Modifier.verticalScroll(scrollState)) {
        repeat(100) {
            Text("Item $it")
        }
    }
}

이제 스크롤은 가능하지만 보게 될지 아닐지 모를 모든 compose를 생성하는 문제는 여전히 남습니다.

Lazy list

사실 위와 같은 Column이나 Row를 사용하는 경우는 단순히 composable을 배치하기 위함일 뿐 실제 recylerview를 대체하기 위해서 LazyColumn, LazyRow를 사용합니다.

@Composable
fun SimpleList() {
    // 스크롤의 position의 상태를 저장.
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(100) {
            Text("Item $it")
        }
    }
}

LazyColumn은 DSL 형태로 list의 item을 넘겨받습니다. 따라서 items에 list의 크기나, Array, List를 넘겨서 데이터를 lazy 하게 그리도록 합니다. 이전 기본편#2 [3]에서 언급했듯이 화면에 보이는 만큼만 compose가 실행되면서 화면에 그려지기 때문에 효율적입니다만 recyclerView가 view를 재활용 하는것 처럼 compose를 재활용하지는 않습니다.

// item list를 넘기는 경우
@Composable
fun SimpleList(itemList: List<String>) {
    // 스크롤의 position의 상태를 저장.
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(items = itemList){
            Text("Item $it")
        }
    }
}

일반적인 Composable의 경우 param으로 사용되는 content는 @Composable block을 넘겨받습니다. 하지만 Lazy component들은 DSL로 구성된 LazyListScope.() 제공합니다. 개발자가 block 내부에 표현하고자 하는 list를 item 형태로 넣어주고 나면 layout이나 스크롤에 따라서 이를 그려야 할지 말지는 lazy component가 결정합니다. [8]

fun LazyColumnTest() {
    LazyColumn(modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
        item {
            Text("First item")
        }

        val list = listOf("second", "third", "fourth")
        items(list) {
            Text("$it item")
        }

        itemsIndexed(list) { index, item ->
            Text("Added $item list index:$index")
        }
    }
}

item을 표시하기 위해 item, items, itemsIndexed 등 다양한 형태의 override 함수를 제공합니다. 위와 같이 만들었다면 아래와 같이 배치한 순서대로 출력됩니다. [8]

위에서는 전체 화면에 리스트를 그리는데, 화면에 상, 하에 딱 붙어서 그려집니다. 따라서 list 전체에 padding을 주기 위해서 contentPadding 속성을 이용합니다.

 LazyColumn(modifier = Modifier.fillMaxWidth().fillMaxHeight(),
        contentPadding = PaddingValues(horizontal = 20.dp, vertical = 10.dp)
    ) {
    ...
 }

상/하단 과 좌/우의 여백을 위와 같이 지정했습니다. 만약 좌, 우, 상, 하의 여백을 모두 다르게 주려면 PaddingValues(top=..., bottom..) 형태의 다른 PaddingValues (overloading 된) 함수를 사용하면 됩니다.

list의 전체의 여백이 아니라 item 간의 간격을 띄우기 위해서는 verticalArrangementhorizontalArrangement를 사용합니다.

LazyColumn(
    verticalArrangement = Arrangement.spacedBy(4.dp),
) {
    ...
}

//또는

LazyRow(
    horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
    ...
}

이를 전체적으로 적용하면 아래와 같이 표현됩니다.

@Composable
fun LazyColumnTest() {
    LazyColumn(modifier = Modifier.fillMaxWidth().fillMaxHeight(),
        contentPadding = PaddingValues(horizontal = 20.dp, vertical = 10.dp),
        verticalArrangement = Arrangement.spacedBy(4.dp)
    ) {
      ...
    }
}

 

RecyclerView에서는 아이템의 추가, 삭제, 이동시에 기본적인 애니메이션이 동작합니다. item list를 변경 후 notifyItemInserted, notifyItemRemoved, notifyItemMovedm notifyItemChanged 등을 호출하면 해당 요건에 맞는 적절한 animation이 동작 하나, compose에서는 지원하지 않습니다. 하지만, 추후 적용되지 않을까 싶네요.

Image Loading in Lazy List

list의 item에 image를 넣기 위해서는 Bitmap이나 Vector image 표시가 가능한 Image composable을 사용합니다. 만약 network을 통한 이미지 로딩이 필요한 경우 이전에는 여러 추가적인 작업이 필요했지만, compose를 지원하는 coil library를 사용함으로써 아주 간단하게 이미지를 표시할 수  있습니다. [4]

Image composable에는 painter 속성에 이미지 resource를 넣어주도록 되어 있습니다. 따라서 아래와 같이 painterResource 객체를 이용하여 local에 존재하는 이미지를 가져왔습니다.

Image(painter = painterResource(id = R.drawable.ic_launcher_foreground),
      contentDescription = "")

원격 이미지를 가져올 경우 coil에서 제공하는 rememberImagePainter를 사용하며, 먼저 gradle에 dependency를 추가합니다.

dependency {
	...
	implementation("io.coil-kt:coil-compose:1.3.2")
    ...
}

물론 internet 사용을 해야 하니 AndroidManifest.xml에 아래 permission도 추가해야 합니다.

<uses-permission android:name="android.permission.INTERNET" />

 

@Composable
fun SimpleList() {
    // 스크롤의 position의 상태를 저장.
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(100) {
            ImageListItem(it)
        }
    }
}

@Composable
fun ImageListItem(index: Int) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Image(
            painter = rememberImagePainter("https://developer.android.com/images/brand/Android_Robot.png"),
            contentDescription = "",
            modifier = Modifier.size(50.dp)
        )
        Spacer(Modifier.width(10.dp))
        Text("Item $index", style = MaterialTheme.typography.subtitle2)
    }
}

또한 coil에서 아래와 같은 추가적인 옵션도 사용할 수 있습니다.[10]

@Composable
fun ImageListItem(index: Int) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Image(
            painter = rememberImagePainter(
                data = "https://developer.android.com/images/brand/Android_Robot.png",
                // image painter가 호출하기 전에 수행되며 true이면 request, false이면 request를 skip 한다.
                onExecute = ImagePainter.ExecuteCallback { previous, current -> true },
                builder = {
                    crossfade(true)
//                    palceholder (R.drawable.placeholder)
                    transformations(CircleCropTransformation()) //이미지를 원형으로 crop
                }
            ),
            contentDescription = "",
            modifier = Modifier.size(50.dp)
        )
      ...
    }
}

List scrolling

위에서 계속 추가되어 있었지만 사용되지 않았던 scrollState (rememberLazyListState or rememberScrollState)는 현재 스크롤되고 있는 상태를 저장합니다. 따라서 이 변수를 이용하여 코드에서 스크롤을 원하는 위치로 이동시킬 수 있습니다.

기존 position을 저장했다가 다시 돌아올 때 복원시켜주거나, 맨 위로, 맨 아래로 이동하는 버튼을 만들어 줄 수 있겠죠?

scrollState의 animationScrollToItem(position) 함수를 사용면 smooth 하게 scroll을 할 수 있으나, 스크롤로 인한 list의 rendering을 block 시키지 않기 위해서 suspend function으로 되어 있습니다. 따라서 CoroutineScope 안에서 호출해야 합니다.

compose 안에서 coroutineScope을 만드는 방법은 아주 간단합니다. rememberCoroutineScope()을 사용하여 만들고 이 corouitineScope은 해당 composable의 lifecycle과 같은 lifecycle을 가집니다. [5]

@Composable
fun SimpleList() {
    val listSize = 100
    // 스크롤의 position의 상태를 저장.
    val scrollState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()

    Column(Modifier.padding(start = 10.dp, end = 10.dp)) {
        Row {
            Button(onClick = {
                coroutineScope.launch { scrollState.animateScrollToItem(0) }
            }, Modifier.weight(1f)) {
                Text("Scroll to the top")
            }
            Spacer(Modifier.width(10.dp))
            Button(onClick = {
                coroutineScope.launch { scrollState.scrollToItem(listSize - 1) }
            }, Modifier.weight(1f)) {
                Text("Go to the bottom")
            }
        }
        LazyColumn(state = scrollState) {
            items(listSize) {
                ImageListItem(it)
            }
        }
    }
}

Top으로의 이동은 smooth scroll을 이용했지만 아래로 이동하는 건 scrollToItem()으로 단번에 이동하도록 했습니다. 물론 이 함수 역시 suspend function이므로 coroutineScope내부에서 호출해야 합니다.

이번에는 실제로 많이 구현하는 UX 중 하나로 scroll이 발생하면 오른쪽 하단에 최 상단으로 이동하는 버튼을 추가해 보도록 하겠습니다. 우선 위에 붙여 두었던 두 개의 버튼은 떼내고 최상단 이동 버튼을 배치하는 작업을 합니다.

@Composable
fun SimpleList() {
    Box {
        val listSize = 100
        // 스크롤의 position의 상태를 저장.
        val scrollState = rememberLazyListState()

        Column(Modifier.fillMaxWidth()) {
            LazyColumn(
                state = scrollState,
                contentPadding = PaddingValues(bottom = 50.dp)
            ) {
                items(listSize) {
                    ImageListItem(it)
                }
            }
        }
                
        ScrollToTopButton(scrollState, Modifier.align(Alignment.BottomEnd))
    }
}

@Composable
fun ScrollToTopButton(scrollState: LazyListState, modifier: Modifier = Modifier) {
   //TODO
}

버튼은 list위에 떠야 합니다. 따라서 화면을 일단 Box로 묶습니다. Box는 FrameLayout 같은 형태의 layout을 구성하기 위한 composable입니다. Box 내부에서 ScrollToTopButton의 위치를 Modifier.align으로 지정해 줍니다.

버튼이 보여야 할 state에 따라서 visibility를 결정하기 위해 AnimatedVisibility composable을 사용해 봅니다. 이는 아직 Experimental API 이므로 함수 상단에 @OptIn(ExperimentalAnimationApi::class)를 추가시킵니다.

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun SimpleList() {
    Box {
        ...
        val showButton = remember {
            derivedStateOf {
                scrollState.firstVisibleItemIndex > 0
            }
        }
        
        AnimatedVisibility(visible = showButton.value,
                           modifier = Modifier.align(Alignment.BottomEnd)) {
            ScrollToTopButton(scrollState)
        }        
    }
}

추가적으로 첫 번째 버튼이 보이지 않을 때만 버튼을 노출하기 위해서 showButton state를 하나 만듭니다. 이때 사용하는 derivedStateOf [7]는 여러 개의 다른 state의 조합을 통해서 자신의 상태를 변경시킵니다. 즉, scrollState가 변할 때마다 showButton의 상태도 변경됩니다. 이는 LiveData에서 MediateLiveData와 유사한 역할이라고 생각하면 이해하기 쉽습니다.

// DO NOT USE
val showButton = scrollState.firstVisibleItemIndex > 0

firstVisibleItemIndexmutableState 타입이므로 위와 같이 처리한다고 해도 동작에는 문제가 없습니다.  단순하지만 비효율적인기 때문에 derivedStateOf를 이용해서 firstVisibleItemIndex의 변경이 생길때마다 showButton값을 계산하도록 합니다.

이제 마지막으로 스크롤 버튼을 처리할 함수를 생성합니다.

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun SimpleList() {
    Box {
      ...
         AnimatedVisibility(visible = showButton.value,
                            modifier = Modifier.align(Alignment.BottomEnd)) {
            ScrollToTopButton(scrollState)
         }
    }
}

@Composable
fun ScrollToTopButton(scrollState: LazyListState, modifier: Modifier = Modifier) {
    val scope = rememberCoroutineScope()
    Image(
        painter = painterResource(id = R.drawable.btn_top),
        contentDescription = "",
        modifier
            .padding(20.dp)
            .background(color = Color.Gray, shape = CircleShape)
            .clickable {
                scope.launch {  scrollState.scrollToItem(0) }
            }
    )
}

item 0번이 사라지면 버튼이 visible 되고 다시 item 0이 나타나면 해당 버튼은 사라집니다.

Sticky headers (experimental)

아이폰의 UX에서 흔히 사용하는 sticky header를 안드로이드에서 직접 구현하기는 쉽지 않습니다. 직접 구현하지 않을 거라면 다른 library를 가져와서 사용해야 합니다. compose에서는 아직은 experimental이지만 sticky header를 지원합니다.

출처: https://developer.android.com/images/jetpack/compose/lists-scrolling.gif

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ListWithHeader(items: List<Item>) {
    LazyColumn {
        stickyHeader {
            Header()
        }

        items(items) { item ->
            ItemRow(item)
        }
    }
}

기본 구성은 위와 같으며, 만약 샘플의 동작처럼 연락처 리스트를 보여준다면 아래와 같이 구성할 수 있습니다.

// TODO: ViewModel에서 처리되어야 할 코드
val grouped = contacts.groupBy { it.firstName[0] }

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactsList(grouped: Map<Char, List<Contact>>) {
    LazyColumn {
        grouped.forEach { (initial, contactsForInitial) ->
            stickyHeader {
                CharacterHeader(initial)
            }

            items(contactsForInitial) { contact ->
                ContactListItem(contact)
            }
        }
    }
}

Item keys

item, items의 overloading 함수를 따라가 보면 param으로 key를 넘겨받습니다.

interface LazyListScope {
...

    fun item(key: Any? = null, content: @Composable LazyItemScope.() -> Unit)

   ...
    fun items(
        count: Int,
        key: ((index: Int) -> Any)? = null,
        itemContent: @Composable LazyItemScope.(index: Int) -> Unit
   ...

물론 optional이기 때문에 key를 지정하지 않으면, position이 해당 item의 상태를 나타내는 key가 됩니다. 하지만 아이템의 순서가 변경되거나 추가, 삭제되는 경우 (또는 item 전체가 변경되거나) position은 변동의 여지가 생기므로 이 position을 기준으로 상태를 저장하고 이를 로직적으로 이용할 경우  버그가 발생되거나 추가적인 작업이 더 필요할 수 있습니다. (이건 recycler view도 만찬가지 입니다.)

또한 key를 저장하지 않을 경우 item data set의 변경 시 recomposition의 대상이 될 수 있기 때문에 recomposition을 효율적으로 발생시키기 위해서도 list내에서 고유한 값으로 지정해 주는 게 좋습니다. (이는 추후 recomposition 상태 변화 부분에서 상세히 다루겠습니다.)[9]

코드에서 보듯이 key는 Any type으로 되어있지만 bundle에 저장할 수 있는 type이 사용되어야 합니다. String과 primitive type이 가능하겠죠?

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        items(
            items = messages,
            key = { message ->
                // Return a stable + unique key for the item
                message.id
            }
        ) { message ->
            MessageRow(message)
        }
    }
}

 

Reference

[1] https://developer.android.com/codelabs/jetpack-compose-layouts?continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fcompose%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fjetpack-compose-layouts#5 

[2] https://tourspace.tistory.com/406

[3] https://tourspace.tistory.com/404?category=788397

[4] https://coil-kt.github.io/coil/compose/

[5] https://developer.android.com/jetpack/compose/kotlin

[6] https://developer.android.com/jetpack/compose/lists

[7] https://developer.android.com/jetpack/compose/side-effects 

[8] https://developer.android.com/reference/kotlin/androidx/compose/foundation/lazy/LazyListScope

[9] 2021.09.19 - [개발이야기/Android] - [Compose] 4. 상태관리 - hoisting, mutableState, remember, rememberSaveable, Parcelize, MapSaver, ListSaver

[10] https://coil-kt.github.io/coil/compose/

 

반응형