본문 바로가기
[클라이언트]/[Android Kotlin]

Paging3 - insertSeparators 활용하여 갤러리 구현하기

by Hevton 2025. 2. 20.
반응형

 

사진 갤러리에서 날짜 데이터를 추가해야 할 일이 생겼습니다.

 

Paging3는 PagingData<T>로 데이터가 감싸져 있어서, 내부 데이터를 다룰 수가 없는 불편함이 있는 만큼

insertHeaderItem(), insertFooterItem(), insertSeparator() 같은 내부 함수들을 지원합니다.

 

 

이 중 insertSeparator()를 활용하면, 우리가 갤러리 앱에서 볼 수 있는 UI를 쉽게 구현할 수 있습니다.

@CheckResult
@JvmSynthetic
public fun <T : R, R : Any> PagingData<T>.insertSeparators(
    terminalSeparatorType: TerminalSeparatorType = FULLY_COMPLETE,
    generator: suspend (T?, T?) -> R?,
): PagingData<R> {
    // This function must be an extension method, as it indirectly imposes a constraint on
    // the type of T (because T extends R). Ideally it would be declared not be an
    // extension, to make this method discoverable for Java callers, but we need to support
    // the common UI model pattern for separators:
    //     class UiModel
    //     class ItemModel: UiModel
    //     class SeparatorModel: UiModel
    return PagingData(
        flow = flow.insertEventSeparators(terminalSeparatorType, generator),
        uiReceiver = uiReceiver,
        hintReceiver = hintReceiver
    )
}

 

terminalSeparatorType에는 3가지 옵션이 있는데, 디폴트 파라미터로 기본값 FULLY_COMPLETE 가 설정되어 있습니다.

FULLY_COMPLETE (기본값) 모든 데이터를 로드한 후에만 Separator 추가 완전히 로드된 후 날짜별 헤더를 추가할 때
SOURCE_COMPLETE 현재 불러온 데이터까지만 고려해서 Separator 추가 스크롤 중에도 헤더를 추가하고 싶을 때
ALWAYS 데이터가 없어도 Separator를 추가 시도 빈 리스트 처리에 활용 가능

 

 

저는 특별한 옵션이 필요한 케이스가 아니라서 기본값을 사용하게 되었습니다.

 

우선 sealed class를 사용해서 멀티 뷰 타입을 정의해야겠고

sealed class GalleryItem {
    data class DateHeader(val date: String) : GalleryItem()
    data class ImageItem(val chat: ChatWithIcon) : GalleryItem()
}

 

그 다음 뷰모델에서 Paging을 사용하는 UseCase를 통해 insertSeparator()를 활용해주었습니다.

@HiltViewModel
class GalleryViewModel @Inject constructor(
    private val getGalleryUseCase: GetGalleryUseCase
): ViewModel() {

    fun getImage(chatGroup: String) : Flow<PagingData<GalleryItem>> {
        return getGalleryUseCase(chatGroup).map { pagingData ->
            pagingData.map { chat ->
                GalleryItem.ImageItem(chat)
            }.insertSeparators { before, after ->
                val beforeDate = chat.date.toLocalDate()
                val afterDate = cat.date.toLocalDate()

                if (before == null && afterDate != null) {
                    // 첫 번째 데이터에는 헤더 추가
                    return@insertSeparators GalleryItem.DateHeader(afterDate.toGalleryHeader())
                }
                if (beforeDate != null && afterDate != null && beforeDate != afterDate) {
                    // 날짜가 다르면 헤더 추가
                    return@insertSeparators GalleryItem.DateHeader(afterDate.toGalleryHeader())
                }
                // 헤더가 필요하지 않으면 null
                null
            }
        }.cachedIn(viewModelScope)
    }
}

 

if문이 두 번 필요했는데

첫번째는, 맨 앞 데이터에는 날짜 헤더 데이터를 무조건 추가해야했기 때문이고

두번째는, 데이터 사이의 날짜가 다르면 날짜 헤더 데이터를 삽입하기 위한 로직입니다.

 

 

그리고 갤러리를 N X 3의 형태로 보여주되, 날짜 헤더는 하나의 row를 전부 차지해야 하기 때문에 아래와 같은 로직도 필요합니다.

@Composable
fun GalleryScreen(viewModel: GalleryViewModel, chatGroup: String) {
    val galleryItems = viewModel.getImage(chatGroup).collectAsLazyPagingItems()

    LazyVerticalGrid(
        columns = GridCells.Fixed(3),
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(8.dp)
    ) {
        items(
            count = galleryItems.itemCount,
            key = { index -> galleryItems.peek(index)?.hashCode() ?: index }
        ) { index ->
            val item = galleryItems[index]

            when (item) {
                is GalleryItem.DateHeaderItem -> {
                    DateHeader(item.date, Modifier.span(3))
                }
                is GalleryItem.ImageItem -> {
                    ImageItem(item.uri, Modifier.span(1))
                }
            }
        }

        when (galleryItems.loadState.append) {
            is LoadState.Loading -> {
                item(span = { GridItemSpan(3) }) {
                    LoadingIndicator()
                }
            }
            is LoadState.Error -> {
                item(span = { GridItemSpan(3) }) {
                    ErrorMessage((galleryItems.loadState.append as LoadState.Error).error)
                }
            }
        }
    }
}

 

 

 

결과적으로 다음과 같은 실행 화면을 기대할 수 있습니다.

 

 

Paging3를 사용하면서 얻는 장점과, 단점이 있는데요

다음 글에서 정리해보도록 하겠습니다.

반응형