지난 포스팅들에 이어, 갤러리 앱을 만드는 과정을 계속 진행해보자.
이미지 리스트의 masonry layout 구현을 목표로 하였고, Jetpack Compose의 LazyStaggeredGrid와 Coil 라이브러리를 사용하여 이를 나타내볼 수 있었다.

Home 화면의 레이아웃은 대강 짜여졌고, 이제 추가적 기능을 구현해볼 차례이다.
화면의 이미지 리스트 중 특정 이미지를 클릭했을때 해당 이미지가 Dialog로 보여지고, 동시에 arrow button을 사용해 리스트의 전 후 이미지들 또한 탐색할 수 있으면 좋을 것 같다.
이를 어떻게 구현해 볼 수 있는지 알아보자.
ImageDialog Popup
기존의 Home 화면 구성 파일에 이미지 dialog 구현을 위한 컴포저블 함수를 생성해보자.
Jetpack Compose를 활용해 Composable 함수를 구현하는데 있어 어쩌면 가장 중요시 될 수 있는 포인트는 여러 문서에서도 언급되어 오듯이 "매개변수"를 통한 데이터 전달이다.
새로운 하위 컴포저블 함수를 생성할 때 상위 컴포저블 내부에 선언된 값중 "어떠한" 값을 받아 "어떻게" 활용할지를 고민할 필요가 있다. 이 과정이 결국 매개변수를 구성하기 때문이다.
일단 초기 기능 구현으로 아래와 같은 데이터들이 ImageDialogPopup의 매개변수를 구성할 수 있을 것 같다.
- dialog 이미지 리스트
- 초기 이미지 인덱스 값
- 이미지 슬라이더 창 닫기 (Unit 타입)
@Composable
fun ImageDialogPopup(
images: List<HomeListItem>,
initialImageIndex: Int,
onClose: () -> Unit,
imageSize: Dp = 500.dp, // 원하는 이미지 크기
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier
) {}
호출부에서 매개변수로 설정한 인자 값들을 받아오기에 앞서 먼저 컴포저블 함수를 구현해보자.
Dialog 컴포저블 함수 내부에 Image 컴포저블과 전 후 이미지에 접근하기 위한 Arrow Button을 지닌 Icon 컴포저블이 필요할 것이다.
요구되는 기능은 무엇이 있을까?
- 이미지를 클릭하면 당연히 클릭한 이미지가 첫번째로 슬라이더 화면에 노출되어야 한다.
- Arrow Icon을 통해 전 후 이미지에 접근할 때마다 리스트의 인덱스에 알맞는 이미지가 노출되어야 한다.
- Arrow Icon은 리스트의 가장 첫 이미지와 마지막 이미지에 도달 했을 때 제거되도록 한다.
- Dialog 이미지 외부 영역을 클릭하였을 시 이미지 창을 닫는다.
두 번째 기능을 구현하기 위해 현재 이미지의 리스트 내 인덱스 값을 "기억"할 필요가 있고 결국 이는 상태로써 관리해야 한다는 것을 의미한다. 컴포즈 함수의 주기 동안 이를 기억하며, configuration changes에 대처하기 위해 rememberSaveable 람다식을 사용하도록 한다.
또한 해당 람다식 내부 상태의 초기값으로 상위 컴포저블 내에서 선언한 initialImageIndex 값을 받게끔 한다. 해당 값은 간단히 말해 클릭한 시점의 이미지 인덱스 값을 의미한다.
var currentIndex by rememberSaveable {
mutableIntStateOf(initialImageIndex)
}
현재는 하위 컴포저블 내부에서만 쓰이게 될 상태이므로 굳이 뷰모델까지 가져가진 않게끔 하였다.
아래는 ImageDialogPopup의 전체 구현코드이다. Dialog 영역과 그 외부 영역의 구분을 위해 Box 컴포저블로 Dialog를 감싸는 작업을 수행하였다.
@Composable
fun ImageDialogPopup(
images: List<HomeListItem>,
initialImageIndex: Int,
onClose: () -> Unit,
imageSize: Dp = 500.dp, // 원하는 이미지 크기
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier
) {
var currentIndex by rememberSaveable {
mutableIntStateOf(initialImageIndex)
}
Box(
modifier = modifier
.fillMaxSize()
.clickable { onClose() }
) {
Dialog(onDismissRequest = onClose) {
Box(
modifier = modifier
.padding(16.dp)
.clip(shape = RoundedCornerShape(10.dp))
) {
Image(
painter = rememberAsyncImagePainter(images[currentIndex].imageUri),
contentDescription = null,
modifier = modifier
.fillMaxWidth()
.height(imageSize)
.clickable { /* Do nothing when the image is clicked */ },
contentScale = ContentScale.Crop,
)
// Left arrow
if (currentIndex > 0) {
IconButton(
onClick = { currentIndex = (currentIndex - 1).coerceIn(0, images.size - 1) },
modifier = modifier
.align(Alignment.CenterStart)
) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Previous Image",
modifier = modifier
.size(48.dp),
tint = Color.LightGray
)
}
}
// Right arrow
if (currentIndex < images.size - 1) {
IconButton(
onClick = { currentIndex = (currentIndex + 1).coerceIn(0, images.size - 1) },
modifier = modifier
.align(Alignment.CenterEnd)
) {
Icon(
imageVector = Icons.Default.ArrowForward,
contentDescription = "Next Image",
modifier = modifier
.size(48.dp),
tint = Color.LightGray,
)
}
}
}
}
}
}

HomeScreenContent 수정및 추가 구현
ImageDialogPopup을 생성하는 것 보다 더 중요한 작업은 해당 함수를 호출하게 되는 HomeScreenContent 컴포저블 내부에서 일어난다.
imageSlider를 보여줄지 말지의 여부, imageSlider의 인덱스 값등 상태로 관리해야 할 데이터들이 존재하며 이들을 수행해주는 함수또한 정의되어야 한다. 그럼 기존의 ViewModel에 해당 상태및 구현 메서드들을 정의해보자.
(기존 ViewModel및 HomeScreen에 대한 코드는 아래의 포스팅을 참조해주시면 감사하겠습니다)
[Jetpack Compose] Preview와 ViewModel의 충돌
https://nemoo-dev.tistory.com/entry/Jetpack-Compose-masonry-Layout%EC%9D%84-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EC%9E%90feat-LazyStaggeredGrid [Jetpack Compose] masonry Layout을 만들어보자(feat. LazyStaggeredGrid) 간간히 위와 같은 레이아
nemoo-dev.tistory.com
HomeViewModel
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()
private val _imageSliderShown = MutableStateFlow<Boolean>(false)
val imageSliderShown: StateFlow<Boolean> = _imageSliderShown.asStateFlow()
private val _imageSliderIndex = MutableStateFlow<Int?>(null)
val imageSliderIndex: StateFlow<Int?> = _imageSliderIndex.asStateFlow()
fun addNewImages(newImages: List<HomeListItem>) {
_existingImages.value += newImages
}
fun showDialog() {
_dialogShown.value = true
}
fun dismissDialog() {
_dialogShown.value = false
}
// 이미지를 보여줌과 동시에 인덱스를 기억하도록 한다.
fun showImageSlider(imageIndex: Int) {
_imageSliderShown.value = true
_imageSliderIndex.value = imageIndex
}
fun dismissImageSlider() {
_imageSliderShown.value = false
}
}
showImageSlider() 함수 내부에선 imageSlider를 보여주는 처리 뿐 아니라 매개변수로 이미지의 인덱스 값을 받아 기존 홈 화면에 존재하는 리스트의 이미지 인덱스값과 이미지 슬라이더의 이미지 인덱스 값을 일치시키는 행위를 포함하도록 한다.
HomeScreen
@Composable
fun HomeScreen(
homeViewModel: HomeViewModel = viewModel(),
) {
val existingImages by homeViewModel.existingImages.collectAsState()
val dialogShown by homeViewModel.dialogShown.collectAsState()
val imageSliderShown by homeViewModel.imageSliderShown.collectAsState()
val imageSliderIndex by homeViewModel.imageSliderIndex.collectAsState()
// 상태로써 홈 화면의 블러 처리 여부 관리
val isHomeScreenBlurred = imageSliderShown
val addNewImages: (List<HomeListItem>) -> Unit = { newImages ->
homeViewModel.addNewImages(newImages)
}
val showDialog: () -> Unit = {
homeViewModel.showDialog()
}
val dismissDialog: () -> Unit = {
homeViewModel.dismissDialog()
}
val showImageSlider: (Int) -> Unit = { imageIndex ->
homeViewModel.showImageSlider(imageIndex)
}
val dismissImageSlider: () -> Unit = {
homeViewModel.dismissImageSlider()
}
HomeScreenContent(
existingImages = existingImages,
dialogShown = dialogShown,
imageSliderIndex = imageSliderIndex,
imageSliderShown = imageSliderShown,
isHomeScreenBlurred = isHomeScreenBlurred,
addNewImages = addNewImages,
showDialog = showDialog,
dismissDialog = dismissDialog,
showImageSlider = showImageSlider,
dismissImageSlider = dismissImageSlider,
)
}
imageSliderShown의 상태와 동일한 값으로 isHomeScreenBlurred 값을 지정한다. 이는 이미지 슬라이더가 화면상에 띄워졌을 시, 조금 더 가시성이 좋은 UI를 위해 나머지 배경 영역에 대한 블러 처리를 위함이다.
이렇게 ViewModel에 접근할 수 있는 유일한 컴포저블 함수를 구성한 뒤 내부에서 호출한 HomeScreenContent에 접근해 Image Dialog Slider 구현을 가능케 해준다.
HomeScreenContent
// ~ 생략
// Blur radius 값 설정
val blurredAlpha = if (isHomeScreenBlurred) 0.3f else 1f // 블러 적용 여부에 따라 투명도 조절
Column(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
alpha = blurredAlpha // 블러 적용
},
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (existingImages.isEmpty()) {
Text(
text = "사진을 채워주세요",
textAlign = TextAlign.Center,
fontSize = 20.sp
)
} else {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
state = scrollState,
contentPadding = PaddingValues(20.dp, 20.dp, 20.dp, 100.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalItemSpacing = 20.dp,
) {
// 기존의 items가 아닌 itemsIndexed를 사용해 index에 접근
itemsIndexed(existingImages) {index, listItem ->
GalleryImage(
item = listItem,
modifier = Modifier.clickable {
// 이미지 클릭시 이미지 슬라이더 띄우기
showImageSlider(index)
}
)
}
}
}
// dialogshown이 true시, 앞서 생성한 DialogPopup 호출
// 더하여 imageSliderIndex 상태가 null이 아닐 경우(즉, 이미지가 존재하여 리스트 내부에서 특정 인덱스를 가질 경우)
if (imageSliderShown) {
imageSliderIndex?.let { index ->
ImageDialogPopup(
images = existingImages,
initialImageIndex = index,
onClose = dismissImageSlider
)
}
}
}
우린 GalleryImage 컴포저블 함수내에서 클릭을 수행하였을 시 showImageSlider를 호출하도록 하였고, 해당 showImageSlider 함수는 ViewModel 내에서 아래와 같이 정의하였다.
fun showImageSlider(imageIndex: Int) {
_imageSliderShown.value = true
_imageSliderIndex.value = imageIndex
}
즉, 클릭하였을 당시의 이미지 인덱스를 itemsIndexed 함수로부터 받아올 수 있게 되는데, 해당 인덱스가 결국 DialogPopup에서 사용하게 될 초기값인 initialImageIndex가 되는 것이다.
⨳ 참고 itemsIndexed
해당 함수는 LazyVerticalStaggeredGrid 컴포저블 내부에서 불러올 수 있었으며 당연히 해당 스코프 인터페이스의 확장함수로써 선언되었다.
inline fun <T> LazyStaggeredGridScope.itemsIndexed(
items: List<T>,
noinline key: ((index: Int, item: T) -> Any)? = null,
crossinline contentType: (index: Int, item: T) -> Any? = { _, _ -> null },
noinline span: ((index: Int, item: T) -> StaggeredGridItemSpan)? = null,
crossinline itemContent: @Composable LazyStaggeredGridItemScope.(index: Int, item: T) -> Unit
) {
items(
count = items.size,
key = key?.let {
{ index -> key(index, items[index]) }
},
contentType = { index -> contentType(index, items[index]) },
span = span?.let {
{ index -> span(index, items[index]) }
},
itemContent = { index -> itemContent(index, items[index]) }
)
}
자, 이렇게 우린 하위 컴포넌트에서 기능 구현을 위해 정의한 매개변수들을 토대로 ViewModel과 상위 컴포넌트를 추가 구성할 수 있었으며 아래와 같은 결과를 가능케 하였다.

slider에 사용되는 이미지의 currentIndex 값이 현재 rememberSaveable을 통해 상태관리가 되고 있는 만큼, 기존에 보여졌던 이미지에 대해선 매끄러운 이동이 가능하지만 처음 리스트에 등록된 이미지에 대해선 끊김이 발생하는 것을 확인할 수 있다. 추후 로컬 저장소등을 활용하여 조금 더 매끄러운 UI를 구현하는 시간을 가져볼 필요가 있을 것 같다.
이렇게 간단히 Dialog를 활용해 이미지 슬라이더를 구현할 수 있었고 계속해서 갤러리 앱의 추가 업데이트를 진행해보고자 한다.
'Jetpack Compose' 카테고리의 다른 글
[Jetpack Compose] Bottom Navbar를 통해 배우는 Compose Navigation (1) | 2024.02.16 |
---|---|
[Jetpack Compose] Preview와 ViewModel의 충돌 (0) | 2024.02.08 |
[Jetpack Compose] masonry Layout을 만들어보자(feat. LazyStaggeredGrid) (0) | 2024.02.07 |