
간간히 위와 같은 레이아웃의 형태를 많이 접할 수 있다.
Android에선 "Lazy Staggered Grid", 혹은 "Asymmetic Grid" 등의 키워드로 접할 수 있는 위와 같은 레이아웃의 정식 명칭은 "Masonry Layout" 이라 한다.
단조로운 그리드 배치가 아닌 정형화 된 틀을 깨고 랜덤한 이미지의 배치 혹은 높이, 너비의 조절로써 위와 같은 미적으로 재미있는 레이아웃을 만들 수 있게 된다.
간단한 갤러리 앱을 구현하는 나의 첫 Jetpack Compose 미니 프로젝트에도 masonry layout을 적용해보고자 하였다.
그냥 샘플 이미지를 res/drawable 디렉토리 아래에 여러개 넣어준 뒤 해당 이미지 리스트를 바로 그리드화(lazy-staggered-grid) 시키는 작업은 쉽게 진행이 되었다.
하지만 이번 글에선 직접 갤러리 앱에 접근해 동적으로 이미지를 화면에 뿌리면서 masonry layout을 구현하는 과정을 담아보고자 한다. Jetpack Compose를 사용하는 만큼 지금부터 진행되는 내용에 있어서 "masonry layout"이 아닌 "LazyStaggered Grid"로 일컫도록 하겠다.
베이스 구축 (Image Loader && LazyStaggeredGrid)
먼저 가장 핵심이 되는 기능을 구현해보자.
- 갤러리 앱 접근을 통해 불러올 사진 추출
- 불러온 이미지들을 통한 동적 Lazy Staggered Grid 형성
위 두 과정이 구현해야 할 전부이기도 하다. 디테일한 조정에 들어가기 전 빠르게 베이스를 구축해보자.
갤러리 앱 접근을 통한 이미지 로더 기능은 "Jetpack Compose Coil"을 사용하기로 하였다. 다른 이미지 로딩 라이브러리들과 달리 심플하게 여러 기능들을 처리할 수 있다는 장점이 있다.
// build.gradle.kts(:app)
dependencies {
implementation("io.coil-kt:coil-compose:2.5.0")
}
Ui State of List Item
LazyStaggeredGrid를 구현하는데 있어, "상태"로써 기억해야하는 값은 무엇이 있을까? 물론 기능 요구 사항에 따라 다르겠지만 현재 프로젝트에선 Horizontal이 아닌 Vertical 만의 Staggered Grid를 구현하기로 하였고, 이에 따라 아래와 같은 두 요소를 ui state에 담기로 하였다.
// HomeListItem.kt
data class HomeListItem(
val height: Dp,
val imageUri: Uri?,
)
GallaryImage Compose
전체 스크린에 대한 컴포저블 함수를 알아보기 전 먼저 단일 이미지 (아이템) 요소에 대한 컴포저블 함수를 구현해보자.
@Composable
fun GalleryImage(
item: HomeListItem,
modifier: Modifier = Modifier
) {
// imagePainter를 생성하기 위해 아래의 함수 사용
val painter = rememberAsyncImagePainter(model = item.imageUri)
val description = null
Surface(
modifier = modifier
.shadow(elevation = 15.dp, shape = RoundedCornerShape(10))
.clip(RoundedCornerShape(10)),
) {
Image(
// 앞서 생성한 painter를 Image composable에서 그려줄 수 있다.
painter = painter,
// ContentScale.Crop을 통해 사용 가능한 공간에 맞게 이미지를 가운데를 중심으로 자른다.
contentScale = ContentScale.Crop,
contentDescription = description,
modifier = Modifier
.fillMaxSize()
.height(item.height)
.clip(RoundedCornerShape(10))
)
}
}
중요한 포인트는 첫 번째로 rememberAsyncImagePainter() 함수를 통해 image를 불러오는 것, 두 번째로는 랜덤한 값을 지닐 이미지 height 값으로 인한 영역 차지 이슈를 해결하기 위해 contentScale을 Crop으로 지정하는 것 정도로 짚을 수 있을 것 같다.
HomeScreen Compose (LazyStaggeredGrid의 사용)
위에서 구현한 GalleryImgae 컴포저블을 호출함과 동시에, (여기선 설명하지 않겠지만) NavController를 통해 외부에서 호출 될 최상단의 컴포저블인 HomeScreen Composable을 구현해보자.
여기서 대부분의 UI state 적용이 이루어 질 것이다.
@Composable
fun HomeScreen(modifier: Modifier = Modifier, maxSelectionCount: Int = 1) {
// 화면의 리스트 영역에 존재하는 이미지들 (기존에 불러온 이미지)
var existingImages by rememberSaveable { mutableStateOf<List<HomeListItem>>(emptyList()) }
// 새로 등록될 이미지
var newImages by remember { mutableStateOf<List<HomeListItem>>(emptyList()) }
val buttonText = if (maxSelectionCount > 1) {
"Select up to $maxSelectionCount photos"
} else {
"Select a photo"
}
// 단일 이미지(1개)만 불러올 경우
val singlePhotoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
onResult = { uri ->
newImages = listOf(
ListItem(
height = Random.nextInt(100, 300).dp,
imageUri = uri
)
)
existingImages = existingImages + newImages
}
)
// 여러 이미지들을(최대 제한 횟수 적용) 불러올 경우
val multiplePhotoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(
maxItems = if (maxSelectionCount > 1) maxSelectionCount else 2
),
onResult = { uris ->
newImages = uris.map { uri ->
HomeListItem(
height = Random.nextInt(100, 300).dp,
imageUri = uri
)
}
existingImages = existingImages + newImages
}
)
// 외부에서 설정될 maxSelectionCount 값 조건에 따른 photo launcher 함수 선언
fun launchPhotoPicker() {
if (maxSelectionCount > 1) {
multiplePhotoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
} else {
singlePhotoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
}
Column(modifier.fillMaxSize()) {
Button(onClick = {
// photo launcher 함수 실행
launchPhotoPicker()
}) {
Text(buttonText)
}
// 💢 LazyVerticalStaggeredGrid 💢
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(20.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalItemSpacing = 20.dp
) {
items(existingImages) { listItem ->
GalleryImage(item = listItem)
}
}
}
}
몇 가지 포인트만 짚어보자. 위의 코드를 베이스로 앞으로 추가및 수정이 이루어지게 된다.
현재, 따로 ViewModel을 두지 않은 상황이다. 이에 따라 컴포저블 내부에서 기존 존재하는 이미지(exisitingImages), 추가하게 될 이미지(newImages)들에 대한 상태를 지정해준다.
실제 이미지에 반영될 existingImages에 대한 상태는 configuration change 상황에도 값을 유지하고자 rememberSaveable를 사용한다. (추후 ViewModel 적용 시 이러한 별도의 처리는 필요 없다)
그리고 이 글의 제목에서도 언급이되었고, 구현의 핵심이 될 "LazyVerticalStaggeredGrid" Composable이 등장하게 된다.
Jetpack Compose 등장 시점에서 부터 공식 레퍼런스에 꾸준히 언급되었던 해당 컴포저블 함수는 1.3.0 version 이후 정식으로 추가되었다. 물론, LazyVerticalStaggeredGrid가 나왔듯이 LazyHorizontalStaggeredGrid 또한 추가되었다.
코드에서 볼 수 있듯이, 그리드 셀을 구성할 컬럼의 카운트 수를 지정 및 각 아이템에 대한 여백 (패딩) 부여와 같은 여러 기능들을 편하게 제공함으로써 masonry layout을 쉽게 구현할 수 있게 도와준다.
지금까지의 과정을 수행하면 아래와 같은 구현이 가능케 된다.
(maxSelectionCount = 3)

ViewModel Class 도입과 기능 개선
coil 라이브러리로부터 사용된 rememberAsyncImagePainter(), 지정된 activity의 결과를 요청하게끔 하는(외부의 화면으로 이동하게 끔 해주는 launcher) rememberLauncherForActivityResult()는 컴포저블 내부에 존재해야하는 api 함수이다.
위의 경우가 아닌 우리가 직접 remember 혹은 rememberSaveable을 통해 지정해준 상태들은 별도의 ViewModel에 선언하도록 해보자. 물론, 모든 상태를 무조건적을 ViewModel에 위치시킬 필요는 없다. 뷰에서 옵저빙하면서 관리되어야할 상태이냐에 따라 컴포즈 함수 내부에서 관리하여도 무방하다.
현재 코드에서 상태로써 관리되고 있는 부분은 이미지 리스트가 전부이다. 하지만 해당 이미지 로딩은 UI 구성에 밀접한 관련이 있고, configuration change에도 유연하게 대응하고자 ViewModel로 이동해주는 선택을 하였다. (사실 이정도의 단순한 규모에선 큰 차이가 없다, 레이어 분리에 의의를 두자)
HomeViewModel (초기)
class HomeViewModel : ViewModel() {
private val _existingImages = MutableStateFlow<List<HomeListItem>>(emptyList())
val existingImages: StateFlow<List<HomeListItem>> = _existingImages.asStateFlow()
fun addNewImages(newImages: List<HomeListItem>) {
_existingImages.value += newImages
}
}
ViewModel을 둠으로써 새로 추가될 이미지에 대해 유연한 선언을 할 수 있게 되었다. 컴포즈 함수 내부에선 ViewModel의 addNewImages() 함수로 접근해 이미지 추가 기능을 구현할 수 있게 된다.
HomeScreen (초기)
dependencies {
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
}
(위 라이브러리 설치 필요)
@Composable
fun HomeScreen(
homeViewModel: HomeViewModel = viewModel(),
maxSelectionCount: Int = 1
) {
// ViewModel에서 정의한 existingImages 상태를 불러옴 (중요)
val existingImages by homeViewModel.existingImages.collectAsState()
val singlePhotoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
onResult = { uri ->
// ViewModel에서 선언한 addNewImages를 호출
homeViewModel.addNewImages(
listOf(
HomeListItem(
height = Random.nextInt(100, 300).dp,
imageUri = uri
)
)
)
}
)
val multiplePhotoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(
maxItems = if (maxSelectionCount > 1) maxSelectionCount else 2
),
onResult = { uris ->
// ViewModel에서 선언한 addNewImages를 호출
homeViewModel.addNewImages(
uris.map { uri ->
HomeListItem(
height = Random.nextInt(100, 300).dp,
imageUri = uri
)
}
)
}
)
// ~~ 생략
}
ViewModel에서 선언한 상태를 아래와 같이 by 위임자를 통해 불러온다. 이때 `collectAsState()`를 사용하게 되는데, 이는 MutableStateFlow로 선언된 Flow 형태의 데이터 소스를 컴포즈 내부에서 관찰하여 적절한 recomposition을 이루게 해준다. 만약 Flow 수준이 아닌 일반 MutableState을 가질 경우 `mutableStateOf()`로 불러오는 것과 비슷한 작업이다. LiveData의 상태를 ViewModel에서 선언해준 뒤 컴포즈 내부에서 `observeAsState()`로 불러오는 것 또한 동일한 흐름이다.
Coroutine Flow, LiveData 와 같은 내용은 추후 포스팅으로 다뤄보도록 하겠다.
val existingImages by homeViewModel.existingImages.collectAsState()
Alert Dialog를 통한 UI/UX 수정 (선택)
소제목 명을 "Alert Dialog를 통한 UI/UX 개선"이라 명명하려 했지만 지금 추가하게 될 기능은 사실 오히려 UX 관점에서 더 불편한 상황이 만들어 질 수도 있기 때문에 오로지 기능 구현 의의에 목적을 둔다.
배경은 아래와 같다.
"select up to $maxSelectionCount photos" 는 버튼 텍스트 명으로써 썩 내키지 않는다. 다중 이미지 불러오기든 단일 이미지 불러오기든 상관없이, 깔끔하게 "이미지 불러오기"란 텍스트가 좋을 것 같다.
즉, 최대 불러올 수 있는 사진의 수는 dialog 창으로 유저에게 알려주도록 구현한다.
// HomeScreen
val buttonText = "이미지 불러오기"
// dialog를 보여줄지 여부를 상태로써 관리
val dialogShown = remember { mutableStateOf(false) }
val dialogMessage = "최대 $maxSelectionCount 개의 이미지를 불러올 수 있습니다."
// dialog를 띄운 여부를 상태로써 관리
val isFirstTime = remember { mutableStateOf(false) }
// 해당 컴포저블이 처음 실행될때 "한번"만 dialog를 띄우도록 설정
LaunchedEffect(Unit) {
isFirstTime.value = true
}
// ~~ 생략
// show dialog
if (dialogShown.value) {
AlertDialog(
onDismissRequest = { dialogShown.value = false },
title = { Text(text = "알림")},
text = { Text(dialogMessage) },
confirmButton = {
Button(onClick = {
dialogShown.value = false
isFirstTime.value = false
launchPhotoPicker()
}) {
Text(text = "확인")
}
}
)
}
Column(Modifier.fillMaxSize()) {
Button(
onClick = {
// 컴포저블 수명 주기동안 첫 버튼 클릭시에만 dialog 창 호출
if (isFirstTime.value) {
dialogShown.value = true
} else {
dialogShown.value = false
launchPhotoPicker()
}
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.LightGray,
contentColor = Color.Black,
),
modifier = Modifier
.padding(8.dp)
) {
Text(buttonText)
}
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(20.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalItemSpacing = 20.dp
) {
items(existingImages) { listItem ->
GalleryImage(item = listItem)
}
}
}
dialog 창을 직접적으로 띄우는 것과 관련된 dialogShown 상태뿐만 아니라 빈번한 dialog 창 호출을 막기 위해 isFirstTime이란 상태또한 관리하도록 한다. 이는 HomeScreen 컴포저블의 주기동안 dialog 창이 한 번만 호출되게 하기 위해 필요한 값이다. (물론 이렇게 처리해도 빈번한건 사실이다, 추후 더 나은 방법을 고려해봐야겠다)
ViewModel 추가 및 컴포저블 함수 내부 변경
dialogShown의 경우, 화면 회전 등과 같은 configuration change에 영향을 받기 때문에 rememberSaveable과 같이 상태 유지 함수로써 처리해야한다. 물론 그렇게 처리해도 무방하지만, ViewModel로 해당 상태를 옮겨 책임을 분리하게끔 하였다.
isFirstTime과 같은 상태는 온전히 HomeScreen 내부의 dialogShown 상태를 위한 상태이므로(UI 구성에 영향 X) 해당 컴포저블 내부에서 그대로 관리하도록 한다.
class HomeViewModel : ViewModel() {
private val _existingImages = MutableStateFlow<List<HomeListItem>>(emptyList())
val existingImages: StateFlow<List<HomeListItem>> = _existingImages.asStateFlow()
private val _dialogShown = MutableStateFlow<Boolean>(false)
val dialogShown: StateFlow<Boolean> = _dialogShown.asStateFlow()
fun addNewImages(newImages: List<HomeListItem>) {
_existingImages.value += newImages
}
fun showDialog() {
_dialogShown.value = true
}
fun dismissDialog() {
_dialogShown.value = false
}
}
HomeScreen
val dialogShown by homeViewModel.dialogShown.collectAsState()

Scroll 상태 반영을 통한 최신 이미지 호출
베이스 코드 구현에 따른 실제 앱 테스트 과정을 자세히 보면, 이미지가 추가될 때마다 추가된 이미지의 영역이 보이는 것이 아닌, 위치해있던 화면 상태가 그대로 보여지게 된다. 즉, 최상단에서 계속해서 이미지를 추가하였다면, 유저는 최근 추가한 이미지를 보기 위해 최하단으로 스크롤을 일일히 내려줘야 할 것이다.
또한 다른 화면에서 HomeScreen으로 이동하였을 때에도 가장 최근에 등록한 이미지가 보여지면 좋을 것이다.
이를 처리하기 위한 다양한 방법이 있겠지만, 이번 기능 구현에선 자동 스크롤 상태를 통해 리스트의 마지막을 항상 보여주게끔 처리하였다.
LazyStaggeredGrid를 사용한만큼, 해당 스크롤의 상태는 아래의 함수로써 받아올 수 있다.
val scrollState = rememberLazyStaggeredGridState()
또한, 컴포저블 실행동안 위의 스크롤 상태 값을 토대로 원하는 위치로의 스크롤을 구현하기 위해 아래와 같이 처리해 준다.
LaunchedEffect(scrollState.canScrollForward) {
// Scroll to the bottom when new images are added
scrollState.scrollToItem(existingImages.size - 1)
}
// ... 생략
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
state = scrollState, // --> scrollState 적용
contentPadding = PaddingValues(20.dp, 20.dp, 20.dp, 100.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalItemSpacing = 20.dp,
) {
items(existingImages) { listItem ->
GalleryImage(item = listItem)
}
}
스크롤을 어떻게 적용해야하나... 고민도 하고 여러 이슈들을 찾아보았지만 입문단계에서 한번에 서칭하기란 쉽지 않았다. 하지만 정답은 의외로 가까이에 존재하였고 위의 LazyVerticalStaggeredGrid 컴포저블 함수의 매개변수로써 state를 가지고 있음을 발견하였다.
단순하다면 단순한 위의 과정에서 젯팩 컴포즈가 얼마나 잘 만들어진 툴킷인가 실감하게 되었다.
끝으로 이러한 과정을 통해 masonry (LazyStaggeredGrid) 레이아웃의 이미지 로더 기능을 구현할 수 있었고, 테스트 결과는 아래와 같이 보여진다.

'Jetpack Compose' 카테고리의 다른 글
[Jetpack Compose] Bottom Navbar를 통해 배우는 Compose Navigation (1) | 2024.02.16 |
---|---|
[Jetpack Compose] Image Dialog로 슬라이더를 구현해보자. (0) | 2024.02.14 |
[Jetpack Compose] Preview와 ViewModel의 충돌 (0) | 2024.02.08 |