목록

많은 앱에서 항목의 컬렉션을 표시해야 합니다. 이 문서에서는 Jetpack Compose에서 이 작업을 효율적으로 처리하는 방법을 설명합니다.

스크롤이 필요하지 않은 경우 (방향에 따라) 간단한 Column 또는 Row를 사용하여 다음과 같이 목록을 반복하여 각 항목의 콘텐츠를 내보낼 수 있습니다.

@Composable
fun MessageList(messages: List<Message>) {
    Column {
        messages.forEach { message ->
            MessageRow(message)
        }
    }
}

verticalScroll() 수정자를 사용하여 Column을 스크롤 가능하게 만들 수 있습니다. 자세한 내용은 동작 문서를 참조하세요.

지연 컴포저블

많은 수의 항목이나 길이를 알 수 없는 목록을 표시해야 하는 경우 Column과 같은 레이아웃을 사용하면 모든 항목이 표시 가능 여부와 관계없이 구성되고 배치되므로 성능 문제가 발생할 수 있습니다.

Compose는 구성요소의 표시 영역에 표시되는 항목만 구성하여 배치하는 구성요소 집합을 제공합니다. 이러한 구성요소에는 LazyColumnLazyRow가 포함됩니다.

이름에서 알 수 있듯이 LazyColumnLazyRow의 차이점은 항목을 배치하고 스크롤하는 방향입니다. LazyColumn은 세로로 스크롤되는 목록을 생성하고 LazyRow는 가로로 스크롤되는 목록을 생성합니다.

지연 구성요소는 Compose의 대부분 레이아웃과 다릅니다. 지연 구성 요소는 @Composable 콘텐츠 블록 구성요소를 수락하고 앱에서 직접 컴포저블을 내보낼 수 있도록 허용하는 대신 LazyListScope.() 블록을 제공합니다. 이 LazyListScope 블록은 앱에서 항목 콘텐츠를 설명할 수 있는 DSL을 제공합니다. 그런 다음 지연 구성요소가 레이아웃 및 스크롤 위치에 따라 각 항목의 콘텐츠를 추가합니다.

LazyListScope DSL

LazyListScope의 DSL은 레이아웃의 항목을 설명하는 여러 함수를 제공합니다. 가장 기본적인 item() 함수는 단일 항목을 추가하고 items(Int)는 여러 항목을 추가합니다.

LazyColumn {
    // Add a single item
    item {
        Text(text = "First item")
    }

    // Add 5 items
    items(5) { index ->
        Text(text = "Item: $index")
    }

    // Add another single item
    item {
        Text(text = "Last item")
    }
}

List와 같이 항목 컬렉션을 추가할 수 있는 다양한 확장 함수도 있습니다. 확장 함수를 사용하면 위의 Column 예를 쉽게 이전할 수 있습니다.

import androidx.compose.foundation.lazy.items

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        items(messages) { message ->
            MessageRow(message)
        }
    }
}

색인을 제공하는 itemsIndexed()라고 하는 items() 확장 함수의 버전도 있습니다. 자세한 내용은 LazyListScope 참조를 확인하세요.

콘텐츠 패딩

콘텐츠 가장자리 주변에 패딩을 추가해야 하는 경우가 있습니다. 지연 구성요소를 사용하면 일부 PaddingValuescontentPadding 매개변수에 전달하여 이 작업을 지원할 수 있습니다.

LazyColumn(
    contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
    // ...
}

이 예에서는 가로 가장자리(왼쪽 및 오른쪽)에 16.dp의 패딩을 추가한 다음 콘텐츠의 상단과 하단에 8.dp의 패딩을 추가합니다.

이 패딩은 LazyColumn 자체가 아니라 콘텐츠에 적용됩니다. 위의 예에서 첫 번째 항목이 상단에 8.dp 패딩을 추가하고 마지막 항목이 하단에 8.dp를 추가하며 모든 항목의 왼쪽과 오른쪽에 16.dp 패딩이 추가됩니다

콘텐츠 간격

항목 사이에 간격을 추가하려면 Arrangement.spacedBy()를 사용하세요. 아래 예에서는 각 항목 사이에 4.dp의 간격을 추가합니다.

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

LazyRow의 경우도 마찬가지입니다.

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

항목 애니메이션

RecyclerView 위젯을 사용하는 경우 항목 변경사항이 자동으로 애니메이션 처리됩니다. 지연 레이아웃에서는 아직 이 기능을 제공하지 않습니다. 즉 항목을 변경하면 인스턴트 '스냅'이 발생합니다. 이 버그를 팔로우하여 이 기능의 변경사항을 추적할 수 있습니다.

고정 헤더(실험용)

'고정 헤더' 패턴은 그룹화된 데이터 목록을 표시할 때 유용합니다. 다음은 각 연락처의 이니셜별로 그룹화된 '연락처 목록'의 예입니다.

연락처 목록을 위아래로 스크롤하는 휴대전화의 동영상

LazyColumn이 있는 고정 헤더를 표시하려면 헤더 콘텐츠를 제공하는 실험용 stickyHeader() 함수를 사용하세요.

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

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

위의 '연락처 목록' 예와 같이 여러 헤더가 있는 목록을 표시하려면 다음 안내를 따르세요.

// TODO: This ideally would be done in the 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)
            }
        }
    }
}

그리드(실험용)

LazyVerticalGrid 컴포저블은 항목을 그리드로 표시하기 위한 실험용 지원 기능을 제공합니다.

사진 그리드를 보여주는 휴대전화의 스크린샷

cells 매개변수는 셀을 열로 구성하는 방식을 제어합니다. 다음 예에서는 항목을 그리드로 표시하고 GridCells.Adaptive를 사용하여 각 열의 너비를 128.dp 이상으로 설정합니다.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PhotoGrid(photos: List<Photo>) {
    LazyVerticalGrid(
        cells = GridCells.Adaptive(minSize = 128.dp)
    ) {
        items(photos) { photo ->
            PhotoItem(photo)
        }
    }
}

사용할 열의 정확한 수를 알고 있으면 필요한 수의 열이 포함된 GridCells.Fixed의 인스턴스를 대신 제공할 수 있습니다.

스크롤 위치에 반응

많은 앱이 스크롤 위치와 항목 레이아웃 변경사항에 반응하고 청취해야 합니다. 지연 구성요소는 LazyListState를 호이스팅하여 이 사용 사례를 지원합니다.

@Composable
fun MessageList(messages: List<Message>) {
    // Remember our own LazyListState
    val listState = rememberLazyListState()

    // Provide it to LazyColumn
    LazyColumn(state = listState) {
        // ...
    }
}

간단한 사용 사례의 경우 앱에서 일반적으로 첫 번째로 표시되는 항목에 관한 정보만 알면 됩니다. 이를 위해 LazyListStatefirstVisibleItemIndexfirstVisibleItemScrollOffset 속성을 제공합니다.

사용자가 첫 번째 항목을 지나 스크롤했는지 여부에 따라 버튼을 표시하고 숨기는 예를 사용하는 경우:

@OptIn(ExperimentalAnimationApi::class) // AnimatedVisibility
@Composable
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

컴포지션에서 직접 상태를 읽는 기능은 다른 UI 컴포저블을 업데이트해야 할 때 유용하지만 동일한 컴퍼지션에서 이벤트를 처리할 필요가 없는 시나리오도 있습니다. 이 시나리오의 일반적인 예는 사용자가 특정 지점을 지나 스크롤한 후 분석 이벤트를 보내는 것입니다. 이 시나리오를 효율적으로 처리하기 위해 snapshotFlow()를 사용할 수 있습니다.

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

LazyListState는 또한 layoutInfo 속성을 통해 현재 표시된 모든 항목 및 화면의 경계에 관한 정보를 제공합니다. 자세한 내용은 LazyListLayoutInfo 클래스를 참고하세요.

스크롤 위치 제어

스크롤 위치에 반응하는 것 외에 앱에서 스크롤 위치도 제어할 수 있으면 유용합니다. LazyListState는 스크롤 위치를 '즉시' 스냅하는 scrollToItem() 및 애니메이션을 사용하여 스크롤하는(부드럽게 스크롤하는) animateScrollToItem() 함수를 통해 이 기능을 지원합니다.

@Composable
fun MessageList(messages: List<Message>) {
    val listState = rememberLazyListState()
    // Remember a CoroutineScope to be able to launch
    val coroutineScope = rememberCoroutineScope()

    LazyColumn(state = listState) {
        // ...
    }

    ScrollToTopButton(
        onClick = {
            coroutineScope.launch {
                // Animate scroll to the first item
                listState.animateScrollToItem(index = 0)
            }
        }
    )
}

큰 데이터 세트(페이징)

Paging 라이브러리를 사용하면 앱에서 큰 항목 목록을 지원하며 필요에 따라 작은 목록을 로드하고 표시할 수 있습니다. Paging 3.0 이상에서는 androidx.paging:paging-compose 라이브러리를 통해 Compose 지원 기능을 제공합니다.

페이징된 콘텐츠 목록을 표시하려면 collectAsLazyPagingItems() 확장 함수를 사용한 다음 반환된 LazyPagingItemsLazyColumnitems()에 전달하면 됩니다. 뷰의 Paging 지원 기능과 마찬가지로 itemnull인지 확인하여 데이터가 로드되는 동안 자리표시자를 표시할 수 있습니다.

import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items

@Composable
fun MessageList(pager: Pager<Int, Message>) {
    val lazyPagingItems = pager.flow.collectAsLazyPagingItems()

    LazyColumn {
        items(lazyPagingItems) { message ->
            if (message != null) {
                MessageRow(message)
            } else {
                MessagePlaceholder()
            }
        }
    }
}

항목 키

기본적으로 각 항목의 상태는 목록에 있는 항목의 위치를 기준으로 키가 지정됩니다. 하지만 이 경우 위치를 효율적으로 변경하는 항목에 상태가 저장되지 않아 데이터 세트가 변경되면 문제가 발생할 수 있습니다. LazyColumnLazyRow 시나리오의 경우 행에서 항목 위치가 변경되면 사용자가 행 내에서 스크롤 위치를 잃게 됩니다.

이 문제를 해결하려면 각 항목에 안정적이고 고유한 키를 제공하여 key 매개변수에 블록을 제공하세요. 안정적인 키를 제공하면 데이터 세트가 변경되어도 항목 상태의 일관성이 유지됩니다.

@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)
        }
    }
}