많은 앱에서 항목의 컬렉션을 표시해야 합니다. 이 문서에서는 Jetpack Compose에서 이 작업을 효율적으로 처리하는 방법을 설명합니다.
스크롤이 필요하지 않은 경우 (방향에 따라) 간단한 Column
또는 Row
를 사용하여 다음과 같이 목록을 반복하여 각 항목의 콘텐츠를 내보낼 수 있습니다.
@Composable fun MessageList(messages: List<Message>) { Column { messages.forEach { message -> MessageRow(message) } } }
verticalScroll()
수정자를 사용하여 Column
을 스크롤 가능하게 만들 수 있습니다.
지연 목록
많은 수의 항목이나 길이를 알 수 없는 목록을 표시해야 하는 경우 Column
과 같은 레이아웃을 사용하면 모든 항목이 표시 가능 여부와 관계없이 구성되고 배치되므로 성능 문제가 발생할 수 있습니다.
Compose는 구성요소의 표시 영역에 표시되는 항목만 구성하여 배치하는 구성요소 집합을 제공합니다. 이러한 구성요소에는 LazyColumn
및 LazyRow
가 포함됩니다.
이름에서 알 수 있듯이 LazyColumn
과 LazyRow
의 차이점은 항목을 배치하고 스크롤하는 방향입니다. 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 */ LazyColumn { items(messages) { message -> MessageRow(message) } }
색인을 제공하는 itemsIndexed()
라고 하는 items()
확장 함수의 버전도 있습니다. 자세한 내용은 LazyListScope
참조를 확인하세요.
지연 그리드
LazyVerticalGrid
및 LazyHorizontalGrid
컴포저블은 그리드로 항목 표시를 지원합니다. 지연 세로 그리드는 여러 열에 걸쳐 세로로 스크롤 가능한 컨테이너에 항목을 표시하는 반면, 지연 가로 그리드는 가로축을 중심으로 동일하게 동작합니다.
그리드는 목록과 동일한 강력한 API 기능을 가지며 콘텐츠를 설명하기 위한 매우 유사한 DSL(LazyGridScope.()
)을 사용합니다.
LazyVerticalGrid
의 columns
매개변수와 LazyHorizontalGrid
의 rows
매개변수는 셀이 열이나 행으로 형성되는 방식을 제어합니다. 다음 예에서는 항목을 그리드로 표시하고 GridCells.Adaptive
를 사용하여 각 열의 너비를 128.dp
이상으로 설정합니다.
LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 128.dp) ) { items(photos) { photo -> PhotoItem(photo) } }
LazyVerticalGrid
를 사용하면 항목의 너비를 지정할 수 있고 그러면 그리드는 가능한 한 많은 열에 맞습니다. 남은 너비는 열 수가 계산된 후 열 간에 균등하게 분배됩니다.
이러한 적응형 크기 조절 방법은 다양한 화면 크기에서 항목 집합을 표시하는 데 특히 유용합니다.
사용할 열의 정확한 수를 알고 있으면 필요한 수의 열이 포함된 GridCells.Fixed
의 인스턴스를 대신 제공할 수 있습니다.
디자인에서 특정 항목만 비표준 측정기준이 있어야 하는 경우 그리드 지원을 사용하여 항목에 맞춤 열 스팬을 제공할 수 있습니다.
LazyGridScope DSL
item
및 items
메서드의 span
매개변수를 사용하여 열 스팬을 지정합니다.
스팬 범위의 값 중 하나인 maxLineSpan
은 적응형 크기 조절을 사용할 때 특히 유용합니다. 열 수가 고정되어 있지 않기 때문입니다.
다음 예는 전체 행 스팬을 제공하는 방법을 보여줍니다.
LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 30.dp) ) { item(span = { // LazyGridItemSpanScope: // maxLineSpan GridItemSpan(maxLineSpan) }) { CategoryCard("Fruits") } // ... }
지연 시차 그리드
LazyVerticalStaggeredGrid
및 LazyHorizontalStaggeredGrid
는 지연 로드된 비슷한 간격의 항목 그리드를 만들 수 있는 컴포저블입니다.
지연 세로 지그재그형 그리드는 여러 열에 걸쳐 세로로 스크롤 가능한 컨테이너에 항목을 표시하며, 개별 항목의 높이를 다르게 지정할 수 있습니다. 지연된 가로 그리드는 너비가 다른 항목이 있는 가로축에서 동일한 동작을 보입니다.
다음 스니펫은 항목당 200.dp
너비로 LazyVerticalStaggeredGrid
를 사용하는 기본 예시입니다.
LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Adaptive(200.dp), verticalItemSpacing = 4.dp, horizontalArrangement = Arrangement.spacedBy(4.dp), content = { items(randomSizedPhotos) { photo -> AsyncImage( model = photo, contentScale = ContentScale.Crop, contentDescription = null, modifier = Modifier .fillMaxWidth() .wrapContentHeight() ) } }, modifier = Modifier.fillMaxSize() )
고정된 수의 열을 설정하려면 StaggeredGridCells.Adaptive
대신 StaggeredGridCells.Fixed(columns)
를 사용하면 됩니다.
이렇게 하면 사용 가능한 너비를 열 수 (가로 그리드의 경우 행 수)로 나누고 각 항목이 해당 너비 (가로 그리드의 경우 높이)를 차지하게 됩니다.
LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Fixed(3), verticalItemSpacing = 4.dp, horizontalArrangement = Arrangement.spacedBy(4.dp), content = { items(randomSizedPhotos) { photo -> AsyncImage( model = photo, contentScale = ContentScale.Crop, contentDescription = null, modifier = Modifier .fillMaxWidth() .wrapContentHeight() ) } }, modifier = Modifier.fillMaxSize() )
콘텐츠 패딩
콘텐츠 가장자리 주변에 패딩을 추가해야 하는 경우가 있습니다. 지연 구성요소를 사용하면 일부 PaddingValues
을 contentPadding
매개변수에 전달하여 이 작업을 지원할 수 있습니다.
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), ) { // ... }
그러나 그리드는 세로 및 가로 정렬을 모두 허용합니다.
LazyVerticalGrid( columns = GridCells.Fixed(2), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { items(photos) { item -> PhotoItem(item) } }
항목 키
기본적으로 각 항목의 상태는 목록이나 그리드에 있는 항목의 위치를 기준으로 키가 지정됩니다. 하지만 이 경우 위치를 효율적으로 변경하는 항목에 상태가 저장되지 않아 데이터 세트가 변경되면 문제가 발생할 수 있습니다. LazyColumn
내 LazyRow
시나리오의 경우 행에서 항목 위치가 변경되면 사용자가 행 내에서 스크롤 위치를 잃게 됩니다.
이 문제를 해결하려면 각 항목에 안정적이고 고유한 키를 제공하여 key
매개변수에 블록을 제공하세요. 안정적인 키를 제공하면 데이터 세트가 변경되어도 항목 상태의 일관성이 유지됩니다.
LazyColumn { items( items = messages, key = { message -> // Return a stable + unique key for the item message.id } ) { message -> MessageRow(message) } }
키를 제공하면 Compose가 재정렬을 올바르게 처리할 수 있습니다. 예를 들어 항목에 저장된 상태가 포함되어 있는 경우 키를 설정하면 위치가 변경될 때 Compose가 항목과 함께 이 상태를 옮길 수 있습니다.
LazyColumn { items(books, key = { it.id }) { val rememberedValue = remember { Random.nextInt() } } }
하지만 항목 키로 사용할 수 있는 유형에는 한 가지 제한사항이 있습니다.
키 유형은 Activity가 다시 생성될 때 상태를 유지하는 Android의 메커니즘인 Bundle
에서 지원해야 합니다. Bundle
은 프리미티브, enum, Parcelable과 같은 유형을 지원합니다.
LazyColumn { items(books, key = { // primitives, enums, Parcelable, etc. }) { // ... } }
Activity가 다시 생성될 때 또는 이 항목에서 스크롤하여 벗어났다가 다시 스크롤하여 돌아올 때도 항목 컴포저블 내의 rememberSaveable
을 복원할 수 있도록 키를 Bundle
에서 지원해야 합니다.
LazyColumn { items(books, key = { it.id }) { val rememberedValue = rememberSaveable { Random.nextInt() } } }
항목 애니메이션
RecyclerView 위젯을 사용하는 경우 항목 변경사항이 자동으로 애니메이션 처리됩니다.
지연 레이아웃은 항목 재정렬에 동일한 기능을 제공합니다.
API는 간단합니다. animateItem
수정자를 항목 콘텐츠로 설정하기만 하면 됩니다.
LazyColumn { // It is important to provide a key to each item to ensure animateItem() works as expected. items(books, key = { it.id }) { Row(Modifier.animateItem()) { // ... } } }
다음과 같은 경우 맞춤 애니메이션 사양을 제공할 수도 있습니다.
LazyColumn { items(books, key = { it.id }) { Row( Modifier.animateItem( fadeInSpec = tween(durationMillis = 250), fadeOutSpec = tween(durationMillis = 100), placementSpec = spring(stiffness = Spring.StiffnessLow, dampingRatio = Spring.DampingRatioMediumBouncy) ) ) { // ... } } }
이동한 요소의 새 위치를 찾을 수 있도록 항목의 키를 제공해야 합니다.
고정 헤더(실험용)
'고정 헤더' 패턴은 그룹화된 데이터 목록을 표시할 때 유용합니다. 다음은 각 연락처의 이니셜별로 그룹화된 '연락처 목록'의 예입니다.
LazyColumn
이 있는 고정 헤더를 표시하려면 헤더 콘텐츠를 제공하는 실험용 stickyHeader()
함수를 사용하세요.
@OptIn(ExperimentalFoundationApi::class) @Composable fun ListWithHeader(items: List<Item>) { LazyColumn { stickyHeader { Header() } items(items) { item -> ItemRow(item) } } }
위의 '연락처 목록' 예와 같이 여러 헤더가 있는 목록을 표시하려면 다음 안내를 따르세요.
// 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) } } } }
스크롤 위치에 반응
많은 앱이 스크롤 위치와 항목 레이아웃 변경사항에 반응하고 이를 수신 대기해야 합니다.
지연 구성요소는 LazyListState
를 호이스팅하여 이 사용 사례를 지원합니다.
@Composable fun MessageList(messages: List<Message>) { // Remember our own LazyListState val listState = rememberLazyListState() // Provide it to LazyColumn LazyColumn(state = listState) { // ... } }
간단한 사용 사례의 경우 앱에서 일반적으로 첫 번째로 표시되는 항목에 관한 정보만 알면 됩니다. 이를 위해 LazyListState
는 firstVisibleItemIndex
및 firstVisibleItemScrollOffset
속성을 제공합니다.
사용자가 첫 번째 항목을 지나 스크롤했는지 여부에 따라 버튼을 표시하고 숨기는 예를 사용하는 경우:
@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 } .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()
확장 함수를 사용한 다음 반환된 LazyPagingItems
를 LazyColumn
의 items()
에 전달하면 됩니다. 뷰의 Paging 지원 기능과 마찬가지로 item
이 null
인지 확인하여 데이터가 로드되는 동안 자리표시자를 표시할 수 있습니다.
@Composable fun MessageList(pager: Pager<Int, Message>) { val lazyPagingItems = pager.flow.collectAsLazyPagingItems() LazyColumn { items( lazyPagingItems.itemCount, key = lazyPagingItems.itemKey { it.id } ) { index -> val message = lazyPagingItems[index] if (message != null) { MessageRow(message) } else { MessagePlaceholder() } } } }
지연 레이아웃 사용 관련 도움말
지연 레이아웃이 의도한 대로 작동하도록 하기 위해 고려할 수 있는 몇 가지 도움말이 있습니다.
크기가 0픽셀인 항목을 사용하지 않습니다
이는 예를 들어 이미지와 같은 일부 데이터를 비동기식으로 검색하여 이후 단계에서 목록의 항목을 채우려고 하는 시나리오에서 발생할 수 있습니다. 그러면 지연 레이아웃은 첫 번째 측정에서 모든 항목을 구성합니다. 높이가 0픽셀이고 표시 영역에 모두 맞을 수 있기 때문입니다. 항목이 로드되고 높이가 확장되면 지연 레이아웃은 처음에 불필요하게 구성된 다른 모든 항목을 삭제합니다. 실제로 표시 영역에 맞출 수 없기 때문입니다. 이러한 문제를 방지하려면 표시 영역에 실제로 넣을 수 있는 항목 수를 올바르게 계산할 수 있도록 항목의 기본 크기를 설정해야 합니다.
@Composable fun Item(imageUrl: String) { AsyncImage( model = rememberAsyncImagePainter(model = imageUrl), modifier = Modifier.size(30.dp), contentDescription = null // ... ) }
데이터가 비동기식으로 로드된 후 항목의 대략적인 크기를 알고 있다면 일부 자리표시자를 추가하는 등의 방식으로 로드 전후에 항목의 크기를 동일하게 유지하는 것이 좋습니다. 그러면 스크롤 위치를 올바르게 유지하는 데 도움이 됩니다.
동일한 방향으로 스크롤 가능한 구성요소를 중첩하지 않습니다
이는 동일한 방향으로 스크롤 가능한 다른 상위 요소 내에 사전 정의된 크기가 없는 스크롤 가능한 하위 요소를 중첩하는 경우에만 적용됩니다. 예를 들어 고정된 높이가 없는 하위 요소 LazyColumn
을 세로로 스크롤 가능한 상위 요소 Column
내에 중첩하려는 경우입니다.
// throws IllegalStateException Column( modifier = Modifier.verticalScroll(state) ) { LazyColumn { // ... } }
대신 모든 컴포저블을 하나의 상위 요소 LazyColumn
안에 래핑하고 다양한 유형의 콘텐츠를 전달하는 데 DSL을 사용하여 같은 결과를 얻을 수 있습니다. 이렇게 하면 단일 항목과 여러 목록 항목을 모두 한곳에서 내보낼 수 있습니다.
LazyColumn { item { Header() } items(data) { item -> PhotoItem(item) } item { Footer() } }
다른 방향 레이아웃(예: 스크롤 가능한 상위 요소 Row
및 하위 요소 LazyColumn
)을 중첩하는 경우가 허용됩니다.
Row( modifier = Modifier.horizontalScroll(scrollState) ) { LazyColumn { // ... } }
동일한 방향 레이아웃을 계속 사용하지만 고정 크기를 중첩 하위 요소로 설정하는 경우도 있습니다.
Column( modifier = Modifier.verticalScroll(scrollState) ) { LazyColumn( modifier = Modifier.height(200.dp) ) { // ... } }
한 항목에 여러 요소를 넣을 때 주의해야 합니다
이 예에서 두 번째 항목 람다는 한 블록에서 항목 2개를 내보냅니다.
LazyVerticalGrid( columns = GridCells.Adaptive(100.dp) ) { item { Item(0) } item { Item(1) Item(2) } item { Item(3) } // ... }
지연 레이아웃은 예상대로 이를 처리합니다. 마치 다른 항목인 것처럼 요소를 차례로 배치합니다. 그러나 여기에는 몇 가지 문제가 있습니다.
여러 요소가 한 항목의 일부로 내보내지면 한 항목으로 처리되므로 더 이상 개별적으로 구성할 수 없습니다. 한 요소가 화면에 표시되면 이 항목에 해당하는 모든 요소를 구성하고 측정해야 합니다. 과도하게 사용되면 성능이 저하될 수 있습니다. 모든 요소를 한 항목에 배치하는 극단적인 경우에는 지연 레이아웃 사용의 목적이 완전히 무효화됩니다. 잠재적인 성능 문제 외에도 한 항목에 더 많은 요소를 넣으면 scrollToItem()
및 animateScrollToItem()
도 방해합니다.
그러나 한 항목에 여러 요소를 배치하는 유효한 사용 사례도 있습니다(예: 목록 안에 구분선을 두는 경우). 구분선으로는 스크롤 색인이 변경되지 않아야 합니다. 스크롤 색인은 독립적인 요소로 간주되어서는 안 되기 때문입니다. 또한 구분선이 작으므로 성능에 영향을 미치지 않습니다. 구분선은 그 앞의 항목이 표시될 때 표시되어야 하므로 이전 항목의 일부가 될 수 있습니다.
LazyVerticalGrid( columns = GridCells.Adaptive(100.dp) ) { item { Item(0) } item { Item(1) Divider() } item { Item(2) } // ... }
맞춤 정렬을 사용하는 것이 좋습니다
일반적으로 지연 목록에는 항목이 많으며 스크롤 컨테이너의 크기보다 더 많이 차지합니다. 그러나 목록이 소수 항목으로 채워져 있는 경우 디자인할 때 표시 영역에 이를 배치하는 방식에 관한 더 구체적인 요구사항이 있을 수 있습니다.
맞춤 세로 Arrangement
를 사용하고 LazyColumn
에 전달하면 됩니다. 다음 예에서 TopWithFooter
객체는 arrange
메서드만 구현하면 됩니다. 첫째, 항목이 차례로 배치됩니다. 둘째, 사용된 총 높이가 표시 영역 높이보다 낮은 경우 바닥글이 하단에 배치됩니다.
object TopWithFooter : Arrangement.Vertical { override fun Density.arrange( totalSize: Int, sizes: IntArray, outPositions: IntArray ) { var y = 0 sizes.forEachIndexed { index, size -> outPositions[index] = y y += size } if (y < totalSize) { val lastIndex = outPositions.lastIndex outPositions[lastIndex] = totalSize - sizes.last() } } }
contentType
을 추가하는 것이 좋습니다
Compose 1.2부터 지연 레이아웃의 성능을 극대화하려면 목록 또는 그리드에 contentType
을 추가하는 것이 좋습니다. 이를 통해 여러 항목 유형으로 구성된 목록이나 그리드를 구성하는 경우 레이아웃의 각 항목에 콘텐츠 유형을 지정할 수 있습니다.
LazyColumn { items(elements, contentType = { it.type }) { // ... } }
contentType
을 제공하면 Compose는 같은 유형의 항목 간에만 컴포지션을 재사용할 수 있습니다. 재사용은 유사한 구조의 항목을 구성할 때 더 효율적이므로 콘텐츠 유형을 제공하면 Compose에서 A 유형의 항목을 B 유형의 완전히 다른 항목 위에 구성하려고 하지 않습니다. 그러면 컴포지션 재사용과 지연 레이아웃 성능의 이점을 극대화할 수 있습니다.
성능 측정
출시 모드에서 그리고 R8 최적화를 사용 설정한 상태에서 실행할 때만 지연 레이아웃의 성능을 안정적으로 측정할 수 있습니다. 디버그 빌드에서는 지연 레이아웃 스크롤이 더 느리게 표시될 수 있습니다. 자세한 내용은 Compose 성능을 참고하세요.
추천 서비스
- 참고: JavaScript가 사용 중지되어 있으면 링크 텍스트가 표시됩니다.
RecyclerView
를 지연 목록으로 이전- Compose에 UI 상태 저장
- Jetpack Compose용 Kotlin