UMC 안드로이드 8주차 미션 블로그 챌린지입니다.
LazyColumn {
item { Text("헤더") }
items(5) { index -> Text("Item $index") }
item { Text("푸터") }
}
LazyRow {
items(photos) { photo -> PhotoCard(photo) }
}
@Composable 콘텐츠 블록을 직접 받지만, Lazy 컴포넌트는 LazyListScope.() DSL 블록을 받음LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) { /* ... */ }
Arrangement.spacedBy() 사용LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { /* ... */ }
LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { /* ... */ }
레이아웃 내에 아이템 관련 다양한 함수 제공
item(){} : 단일 아이템
item(count){} : 개수로 아이템 추가
item(list): 컬렉션으로 아이템 추가
itemIndexed(): 인덱스로 아이템 추가, 아이템의 순서가 필요할 때 사용
contentType: 성능 최적화, 다양한 타입 아이템이 혼재할 때 지정하면 컴포즈가 동일 타입끼리만 컴포즈 재사용해서 성능을 높일 수 있음
GridCells.Adaptive — 최소 너비로 열 수 자동 결정화면 너비에 따라 열 수가 자동으로 결정됨
다양한 화면 크기를 설정할 수 있음
```kotlin
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 128.dp)
) {
items(photos) { photo -> PhotoItem(photo) }
}
```
GridCells.Fixed — 고정 열 수LazyVerticalGrid(
columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(photos) { item -> PhotoItem(item) }
}
span 파라미터를 활용LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 30.dp)) {
item(span = { GridItemSpan(maxLineSpan) }) {
CategoryCard("Fruits") // 전체 행을 차지
}
items(items) { item -> ItemCard(item) }
}
columns = StaggeredGridCells.Adaptive(200.dp)
StaggeredGridCells.Fixed(2)처럼 개수 지정verticalItemSpacing = 4.dp
horizontalArrangement = Arrangement.spacedBy(4.dp)
아이템 블록
modifier = Modifier.fillMaxWidth().wrapContentHeight()
fillMaxWidth(): 컬럼 너비를 꽉 채움 wrapContentHeight(): 이미지 원본 비율에 맞춰 높이 결정, staggered 효과가 생김LazyColumn {
items(
items = messages,
key = { message -> message.id } // key 사용
) { message ->
MessageRow(message)
}
}
animateItem() modifier를 사용하면 아이템 추가·삭제·이동 시 애니메이션을 자동으로 적용할 수 있음, Key와 함게 사용해야 효과적 ```kotlin
LazyColumn {
items(messages, key = { it.id }) { message ->
MessageRow(
message,
modifier = Modifier.animateItem()
)
}
}
```stickyHeader()를 사용 단일 헤더
@Composable
fun ListWithHeader(items: List<Item>) {
LazyColumn {
stickyHeader { Header() }
items(items) { item -> ItemRow(item) }
}
}
여러 헤더 (이름 첫 글자별 그룹)
// 그룹핑은 ViewModel에서 처리하는 것을 권장
val grouped = contacts.groupBy { it.firstName[0] }
@Composable
fun ContactsList(grouped: Map<Char, List<Contact>>) {
LazyColumn {
grouped.forEach { (initial, contactsForInitial) ->
stickyHeader { CharacterHeader(initial) }
items(contactsForInitial) { contact -> ContactListItem(contact) }
}
}
}
주의 - 같은 방향 스크롤 중첩 금지
LazyColumn을 세로 스크롤 Column 안에 중첩하면 IllegalStateException이 발생LazyColumn 은 가능주의 - 하나의 item 블록에 여러 요소 넣지 않기
item { } 블록에 여러 컴포저블을 넣으면 하나의 단위로 취급되어 성능이 안좋아짐@Composable
fun MessageList(messages: List<Message>) {
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LazyColumn(state = listState) { /* ... */ }
ScrollToTopButton(
onClick = {
coroutineScope.launch {
listState.animateScrollToItem(index = 0) // 부드럽게 상단으로
}
}
)
}
| API | 설명 |
|---|---|
firstVisibleItemIndex | 현재 화면에 보이는 첫 번째 아이템의 인덱스 |
firstVisibleItemScrollOffset | 첫 번째 아이템의 스크롤 오프셋 |
scrollToItem(index) | 즉시 해당 위치로 이동 |
animateScrollToItem(index) | 애니메이션과 함께 부드럽게 이동 (smooth scroll) |
scrollToItem()과 animateScrollToItem() 모두 suspend 함수이므로 반드시 코루틴 내에서 호출해야 함rememberCoroutineScope() : 해당 컴포저블이 살아있는 동안 사용하는 코루틴derivedStateOf 최적화firstVisibleItemIndex는 스크롤하면 계속 바뀌는 값인데, 컴포즈에서 State값이 바뀌면 그걸 읽는 컴포저블은 리컴포지션되어 다시 그러짐
즉 인덱스가 바뀔 때마다 매번 화면을 다시 그림, 결과가 똑같으면 매번 다시 그릴 필요가 없음
derivedStateOf: 결과값을 감지해서, 변경이 된다고 하면 그때 리컴포지션이 일어남
즉 스크롤할때마다 리컴포지션이 발생하지 않고, 값이 실제로 바뀔때만 된다~
스크롤 이벤트는 매우 빈번하게 발생하므로, firstVisibleItemIndex를 읽을 때는 derivedStateOf로 감싸 결과값이 실제로 변경될 때만 Recomposition이 발생하도록 최적화해야 함
rememberLazyListState() — LazyListState를 컴포지션에 기억
rememberCoroutineScope() — 컴포저블 수명에 묶인 코루틴 스코프
derivedStateOf — 다른 State에서 파생된 State, 변화 감지 최적화
Arrangement — 아이템 배치 전략 (spacedBy, Center, SpaceBetween 등)
PaddingValues — contentPadding에 사용하는 패딩 값 래퍼
Route에서 ViewModel 연결 및 상태 관리, Screen은 UI만 담당@Serializable data object/class 기반 타입 안전 네비게이션navigation<MainGraph>로 중첩 그래프 구성AsyncImage로 네트워크 이미지 로딩좋아요 뷰모델을 만들어서 여러 화면에서 사용하려고 했는데, 이러면 Main에 등록해야 하는데, 공식문서를 찾아보니까 좋은 방식이 아니었다.
MainScreen → AppNavHost → 각 Route로 계속 파라미터 전달해야 함Navigation Graph Scope 방식으로 변경, hiltViewModel로 같은 NavGraph 안에서 동일한 인스턴스를 공유하도록 해서 좋아요 상태가 공유되도록 설정했다.
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<MainGraph>()
}
val favoriteViewModel: FavoriteViewModel = hiltViewModel(parentEntry)
this?.hasRoute<PurchaseDetail>() == true -> BottomNavItem.PURCHASE this?.hasRoute<PurchaseDetail>() == true -> BottomNavItem.PURCHASE
Box 안에서 이미지를 사용하는 방식으로 구현했는데,
AsyncImage에서 직접 사이즈 지정했는데 적용이 안되었다. .
Box에서 사이즈 고정, 이미지에서 max로 채우도록 변경했다.
Box(
modifier = Modifier
.size(314.dp)
.background(Color(0xFFF5F5F5))
.aspectRatio(1f)
) {
AsyncImage(
model = item.productImage,
contentDescription = item.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
탭을 왼쪽 정렬하려고 weight(1f)를 제거했더니 선택된 탭의 언더라인이 화면 전체 너비를 채워버렸다 ...
weight(1f) 제거 후에 Column에 너비 제약이 없어지면서 내부 fillMaxWidth()가 부모의 max constraint을 그대로 사용해버렸다.
Row: fillMaxWidth() + weight(1f) 제거 → 탭이 왼쪽부터 붙음, padding(start = 9.dp) 은 유지 Column: Modifier.width(IntrinsicSize.Max)로 Column을 텍스트 너비로 고정해 해결,