리스트와 그리드

손현수·2024년 3월 25일

안드로이드 Compose

목록 보기
12/25

Row나 Column 컴포넌트는 자식 컴포저블을 수직 및 수평 리스트로 제공할 목적으로 이용된다. 그러나 데이터가 매우 많은 경우 Row, Column을 이용해 렌더링하는 것은 성능 저하를 일으킨다. Compose는 수많은 아이템을 포함하는 리스트를 다룰 수 있는 LazyColumn, LazyRow 컴포저블을 제공한다. 또한 그리드 기반 레이아웃을 위한 LazyVerticalGrid 컴포저블을 이용할 수 있다.

표준 리스트와 지연 리스트

1000개의 이미지를 표시하는 리스트가 있다고 가정하자. 애플리케이션이 1000개의 아이템을 미리 만든다면 기기는 메모리 부족과 성능 제한이라는 상황을 겪게 된다.

길이가 긴 리스트를 다룰 때는 LazyColumn, LazyRow, LazyVerticalGrid를 이용하는 것이 좋다. 이 컴포넌트들은 실제 사용자에게 보이는 아이템들만 만든다. 사용자가 스크롤을 하면 표시 영역에서 벗어나는 아이템들은 파괴하고 리소스를 확보하며, 아이템들은 표시되는 시점에 만들어진다. 이를 이용하면 잠재적으로 무한한 길이의 리스트도 성능 저하 없이 표시할 수 있다.

Column, Row 리스트 다루기

Row, Column 컴포저블은 LazyRow, LazyColumn에 비해 성능상 우위점은 적지만 짧고 기본적인 아이템 리스트를 표시할 때는 매우 좋은 옵션이다. 리스트는 각 아이템이 프로그래밍 방식으로 생성된다.

Column {
	repeat(100) {
    	MyListItem()
    }
}

위의 코드는 Column을 활용하여 MyListItem이라는 컴포저블 인스턴스를 100개 포함하는 수직 리스트이다.

Row{
	repeat(100) {
    	MyListItem()
    }
}

위의 코드는 Row를 활용하여 MyListItem 컴포저블 인스턴스를 100개 포함하는 수평 리스트이다.

LazyList 만들기

LazyList는 LazyRow, LazyColumn 컴포저블을 이용해 만든다. LazyListScope의 item() 함수를 호출하면 LazyList에 개별 아이템을 추가할 수 있다.

LazyColumn {
	item {
    	MyListItem()
    }
}

다음과 같이 items() 함수를 호출하면 여러 아이템을 한 번에 추가할 수 있다.

LazyColumn {
	items(1000) { index -> 
    	Text("This is item $index")
    }
}

LazyListScope가 제공하는 itemsIndexed() 함수를 이용하면 아이템의 콘텐츠와 인덱스값을 함께 얻을 수 있다.

val colorNamesList = listOf("Red", "Green", "Blue", "Indigo")

LazyColumn {
	itemsIndexed(colorNamesList) { index, item ->
    	Text("$index = $item")
    }
}
  • 위의 코드를 실행하면 다음과 같이 나타난다.

ScrollState를 이용해 스크롤 활성화하기

Row, Column을 활용해 리스트를 표시하는 경우 기본적으로 스크롤을 지원하지 않기 때문에 스크롤을 설정해야 한다. 반면에 LazyRow, LazyColumn은 스크롤을 기본으로 지원한다.
Row, Column에 스크롤을 활성화하려면 ScrollState 인스턴스를 만들어야 한다. 이를 이용하면 Row, Column 부모가 재구성을 통해 현재 스크롤 위치를 기억하게 할 수 있다.
다음과 같이 ScrollState 인스턴스를 생성하고 활용할 수 있다.

Column(Modifier.verticalScroll(scrollState)) {
	repeat (100) {
    	MyListItem()
    }
}

Row(Modifier.horizontalScroll(scrollState)) {
	repeat(1000) {
    	MyListItem()
    }
}

프로그래밍적 스크롤

코드 안에서 현재 스크롤 위치를 변경하는 방법을 아는 것은 중요하다. 앱 화면에는 리스트의 처음 혹은 끝으로 이동하는 버튼들을 포함할 수도 있다. 이런 동작은 Row, Column 기반 리스트와 지연 리스트에서 각각 다르게 구현한다.

Row, Column 기반 리스트에서 프로그래밍적 스크롤은 ScrollState 인스턴스의 다음 함수들을 호출하여 실행한다.

  • animateScrollTo(value: Int): 애니메이션을 이용해 지정한 픽셀 위치까지 부드럽게 스크롤한다.
  • scrollTo(value: Int): 지정한 픽셀 위치까지 곧바로 스크롤한다.

위 함수의 value 파라미터는 아이템 인덱스가 아니라 픽셀 위치라는 것을 주의한다. 리스트의 시작점은 픽셀 위치 0이라 가정하고 리스트의 끝을 나타내는 픽셀 위치는 ScrollState 인스턴스의 maxValue 프로퍼티를 통해 최대 스크롤 위치를 얻어낼 수 있다.

val scrollState = rememberScrollState()

val maxScrollPosition = scrollState.maxValue

LazyRow, LazyColumn 리스트를 프로그래밍적으로 스크롤할 때는 LazyListState 인스턴스가 제공하는 함수들을 호출하면 된다.

  • animateScrollToItem(index: Int): 지정한 리스트 아이템까지 부드럽게 스크롤한다. 이때 첫번째 아이템이 0번이다.
  • scrollToItem(index: Int): 지정한 리스트 아이템까지 곧바로 스크롤한다. 이때 첫번째 아이템이 0번이다.

LazyList에서는 픽셀 위치가 아닌 아이템의 인덱스를 이용해 스크롤 위치를 참조한다. 지금가지 살펴본 4개의 함수 모두에서 공통적으로 복잡한 한 가지는 코루틴 함수다. 코루틴 함수의 핵심 요구사항 중 하나는 이들을 코루틴 스코프 안에서 실행해야만 한다는 점이다.

ScrollState 및 LazyListState를 이용할 때는 재구성을 통해 기억되는 CoroutineScope 인스턴스에 접근해야 한다. 이를 위해 다음과 같이 rememberCoroutineScope() 함수를 호출한다.

val coroutineScope = rememberCoroutineScope()

코루틴 스코프를 얻었다면 이를 이용해 스크롤 함수들을 실행한다. 다음은 코루틴 스코프 안에서 animateScrollTo() 함수를 실행하는 Button 컴포넌트 선언 예시 코드다. 버튼을 클릭하면 리스트의 마지막 위치까지 스크롤한다.

Button(onClick = {
	coroutineScope.launch {
    	scrollState.animateScrollTo(scrollState.maxValue)
    }
})

스티키 헤더

스티키 헤더는 LazyList에서만 이용할 수 있는 기능이다. 이를 이용하면 리스트 아이템들을 한 헤더 그룹 아래 모을 수 있다. 스티키 헤더는 LazyListScope의 stickyHeader() 함수를 이용해 만든다.

이 헤더들은 현재 그룹이 스크롤되는 동안 화면에서 계속 표시되기 때문에 스티키 헤더라고 불린다. 그룹이 뷰에서 모두 사라지면 다음 그룹의 헤더가 해당 위치를 차지한다.

스티키 헤더를 이용할 때는 리스트 콘텐츠를 groupBy() 함수를 이용해 매핑한 Array 또는 List에 저장해야 한다. groupBy() 함수는 람다를 받는다. 이 람다는 데이터의 그룹핑 방법을 정의하는 기준을 받는다.

val phones = listOf("Apple iPhone 12", "Google Pixel 4", "Samsung Galaxy 6s",
					"Apple iPhone 7", "Google Pixel 6", "OnePlus 7", "OnePlus 9 Pro", "Samsung Galaxy Z Flip",
					"Apple iPhone 13", "Google Pixel 4a", "Apple iPhone 8")

이 상태에서 제조사에 따라 그룹핑한다면 각 문자열의 첫 번째 단어를 이용할 수 있다.

val groupedPhones = phones.groupBy {it.substringBefore(' ')}
  • 위의 코드로 그룹핑을 하게 되면 제조사 이름을 key값으로 사용하고 각각의 key를 포함하는 스마트폰 모델 배열을 얻을 수 있다.

그룹핑 후에는 forEach 문을 사용하여 스티키 헤더를 만들고 리스트 아이템으로 표시할 수 있다.

val phones = listOf("Apple iPhone 12", "Google Pixel 4", "Samsung Galaxy 6s",
    "Apple iPhone 7", "Google Pixel 6", "OnePlus 7", "OnePlus 9 Pro", "Samsung Galaxy Z Flip",
    "Apple iPhone 13", "Google Pixel 4a", "Apple iPhone 8")

@Composable
fun MyListItem(model: String) {
    Text(model, color = Color.White)
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LazyColumnExample() {
    val groupedPhones = phones.groupBy {it.substringBefore(' ')}
    val listState = rememberLazyListState()

    LazyColumn(state = listState) {
        groupedPhones.forEach { (manufacturer, models) ->
            stickyHeader {
                Text(
                    text = manufacturer,
                    color = Color.White,
                    modifier = Modifier
                        .background(Color.Gray)
                        .padding(5.dp)
                        .fillMaxWidth()
                )
            }

            items(models) {model ->
                MyListItem(model)
            }
        }
    }
}
  • 위의 코드를 실행하면 다음과 같이 렌더링된다.

스크롤 위치에 반응하기

LazyRow, LazyColumn을 이용하면 리스트를 특정한 아이템 위치까지 스크롤했을 때 특정한 액션을 수행할 수 있다. 사용자가 리스트의 끝까지 스크롤했을 때만 "맨 처음으로 스크롤하기" 버튼을 표시하는 경우 등에 매우 유용하다.

이 동작은 LazyListState 인스턴스의 firstVisibleItemIndex 프로퍼티에 접근해서 구현할 수 있다. firstVisibleItemIndex는 현재 화면에 보이는 리스트 중 가장 위에 있는 아이템의 인덱스를 나타낸다. 예를 들어, 전체 리스트의 세번째 아이템이 화면상에서 가장 위에 있을 때 firstVisibleItemIndex의 값은 2이다. 이를 활용하여 특정 인덱스값을 기준으로 이를 초과할 때 "맨 처음으로 스크롤하기" 버튼이 등장하도록 구현할 수 있다.

val firstVisible = listState.firstVisibleItemIndex

if (firstVisible > 3) {
	// 맨 처음으로 스크롤하기 버튼 표시
}

지연 그리드 만들기

그리드 레이아웃은 LazyVerticalGrid 컴포저블을 이용해 만들 수 있다. 그리드의 형태는 두가지가 존재한다.

  • adaptive mode
  • fixed mode

adaptive mode

@Composable
fun LazyVerticalGridExample() {
    LazyVerticalGrid(
        columns = GridCells.Adaptive(minSize = 60.dp),
        state = rememberLazyGridState(),
        contentPadding = PaddingValues(10.dp)
    ) {
        items(30) { index ->
            Card(
                modifier = Modifier
                    .padding(5.dp)
                    .fillMaxSize()
            ) {
                Text(
                    "$index",
                    fontSize = 35.sp,
                    color = Color.White,
                    textAlign = TextAlign.Center,
                    modifier = Modifier.fillMaxSize().background(Color.Blue),
                )
            }
        }
    }
}

adaptive mode에서는 columns 파라미터에 주어지는 최소 폭을 기준으로 채울 수 있을 만큼 아이템들을 자동으로 채워준다.


위의 두 사진은 각각 minSize가 60dp, 80dp일 때를 나타낸다.

fixed mode

@Composable
fun LazyVerticalGridExample2() {
    LazyVerticalGrid(
        columns = GridCells.Fixed(3),
        state = rememberLazyGridState(),
        contentPadding = PaddingValues(10.dp)
    ) {
        items(30) { index ->
            Card(
                modifier = Modifier
                    .padding(5.dp)
                    .fillMaxSize()
            ) {
                Text(
                    "$index",
                    fontSize = 35.sp,
                    color = Color.White,
                    textAlign = TextAlign.Center,
                    modifier = Modifier.fillMaxSize().background(Color.Blue),
                )
            }
        }
    }
}

fixed mode에서는 고정된 숫자를 전달하면 이용할 수 있는 공간의 폭을 채우기 위해 각 열의 폭을 동일한 크기로 조정한다.


위의 두 사진은 각각 cells 파라미터에 3, 4를 전달했을 때를 나타낸다.

Card 컴포저블은 단일한 콘텐츠 주제와 관련된 콘텐츠 및 작업들을 그룹화하는 공간을 제공하며 리스트 항목의 기반으로 자주 이용된다. Card 컴포저블에는 Row, Column, Box 레이아웃 등 어떤 컴포저블이라도 카드의 콘텐츠로 이용할 수 있다. Card의 핵심 기능은 elevation을 지정해 그림자 효과를 만들 수 있다는 것이다.

다음은 그 예시이다.

@Composable
fun MyCard() {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(15.dp),
        elevation = CardDefaults.cardElevation(
            defaultElevation = 6.dp
        )
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = Modifier.padding(15.dp)
        ) {
            Text("Jetpack Compose", fontSize = 30.sp)
            Text("Card Example", fontSize = 20.sp)
        }
    }
}

profile
안녕하세요.

0개의 댓글