Jetpack Compose에서 Paging LoadStateFooter를 구현 하는 방법

이지훈·2023년 10월 20일
2
post-thumbnail
post-custom-banner

사진: UnsplashMike van den Bos

서두

https://developer.android.com/topic/libraries/architecture/paging/v3-overview
https://developer.android.com/topic/libraries/architecture/paging/load-state

Paging 관련 공식문서에 Jetpack Compose를 사용 할 때 LoadStateFooter를 구현하는 방법에 대한 언급이 존재하지 않아, 이를 직접 구현하는데 상당히 어려움을 겪었기 때문에, 내 글이 조금이나마 도움이 되었으면 해서 글을 작성한다. "이런 식으로 구현 할 수도 있겠구나" 하고 글을 읽었으면 좋겠다.

내가 구현하고자 하는 LoadStateFooter 는 다음과 같다.

Loading 만 표시되는 것이 아니라, 적절한 에러 메세지와 다시 시도 버튼이 존재하여 문제가 생겼을 경우, 다시 api를 호출 할 수 있는 기능을 포함한다.

검색을 어떻게 구현 하였는지는 아래 글들을 통해 확인할 수 있다.
Compose 에서 TextField를 이용하여 자동 검색 기능 구현 하기 (기존 xml 에서의 방식과 비교) - 1
Compose 에서 TextField를 이용하여 자동 검색 기능 구현 하기 (기존 xml 에서의 방식과 비교) - 2

Xml에선 이를 어떻게 구현하는지는 공식문서에서 워낙 설명이 잘 되어있기 때문에 생략하고,,,
https://developer.android.com/topic/libraries/architecture/paging/load-state

(글 최하단에 xml 코드를 포함하는 깃허브 링크를 달아두었다.)

위의 요구사항을 만족하기 위해 내가 구현 했던 방식은 다음과 같다.

@Composable
fun KakaoMediaSearchApp(
    viewModel: SearchViewModel = hiltViewModel()
) {
    val searchQuery by viewModel.searchQuery.collectAsState()
    val debouncedSearchQuery by viewModel.debouncedSearchQuery.collectAsState(null)
    
    val blogItems = viewModel.searchBlogs.collectAsLazyPagingItems()
    val videoItems = viewModel.searchVideos.collectAsLazyPagingItems()
    val imageItems = viewModel.searchImages.collectAsLazyPagingItems()
    
    val navController = rememberNavController()
    val currentBackStack by navController.currentBackStackEntryAsState()
    val currentDestination = currentBackStack?.destination
    val currentScreen = searchTabRowScreens.find { it.route == currentDestination?.route } ?: Video

    Surface(color = Color.White) {
        Column {
            OutlinedTextField(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(8.dp),
                value = searchQuery,
                singleLine = true,
                onValueChange = { query ->
                    viewModel.updateSearchQuery(query)
                },
                leadingIcon = {
                    Icon(
                        Icons.Filled.Search,
                        contentDescription = "Search Icon"
                    )
                },
                keyboardOptions = KeyboardOptions(
                    imeAction = ImeAction.Done
                ),
                placeholder = {
                    Text(
                        text = stringResource(R.string.search),
                        fontWeight = FontWeight.Thin
                    )
                },
            )
            SearchTabRow(
                allScreens = searchTabRowScreens,
                onTabSelected = { newScreen ->
                    navController.navigateSingleTopTo(newScreen.route)
                },
                currentScreen = currentScreen
            )
            SearchNavHost(
                navController = navController,
                searchQuery = searchQuery,
                debouncedSearchQuery = debouncedSearchQuery ?: "",
                blogs = blogItems,
                videos = videoItems,
                images = imageItems,
                modifier = Modifier
                    .weight(1f)
                    .padding(top = 8.dp)
            )
        }
    }
}

private val searchTabRowScreens = listOf(Blog, Video, Image)
@Composable
fun SearchNavHost(
    navController: NavHostController,
    searchQuery: String,
    debouncedSearchQuery: String,
    blogs: LazyPagingItems<BlogItem>,
    videos: LazyPagingItems<VideoItem>,
    images: LazyPagingItems<ImageItem>,
    modifier: Modifier = Modifier
) {
    NavHost(
        navController = navController,
        startDestination = Video.route,
        modifier = modifier,
    ) {
        composable(route = Blog.route) {
            BlogScreen(
                blogs = blogs,
                searchQuery = searchQuery,
                debouncedSearchQuery = debouncedSearchQuery,
                onClickBlogDetail = { urlType ->
                    val encodedUrl = URLEncoder.encode(urlType, StandardCharsets.UTF_8.toString())
                    navController.navigateToDetail(encodedUrl)
                }
            )
        }
        composable(route = Video.route) {
            VideoScreen(
                videos = videos,
                searchQuery = searchQuery,
                debouncedSearchQuery = debouncedSearchQuery,
                onClickVideoDetail = { urlType ->
                    val encodedUrl = URLEncoder.encode(urlType, StandardCharsets.UTF_8.toString())
                    navController.navigateToDetail(encodedUrl)
                }
            )
        }
        composable(route = Image.route) {
            ImageScreen(
                images = images,
                searchQuery = searchQuery,
                debouncedSearchQuery = debouncedSearchQuery,
                onClickImageDetail = { urlType ->
                    val encodedUrl = URLEncoder.encode(urlType, StandardCharsets.UTF_8.toString())
                    navController.navigateToDetail(encodedUrl)
                }
            )
        }
        composable(
            route = SearchDetail.routeWithArgs,
            arguments = SearchDetail.arguments
        ) {
            SearchDetailScreen()
        }
    }
}

private fun NavHostController.navigateToDetail(urlType: String) {
    this.navigateSingleTopTo("${SearchDetail.route}/$urlType")
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun VideoScreen(
    videos: LazyPagingItems<VideoItem>,
    searchQuery: String,
    debouncedSearchQuery: String,
    onClickVideoDetail: (String) -> Unit,
) {
    val listState = rememberLazyListState()
    val controller = LocalSoftwareKeyboardController.current

    val isInitial = searchQuery.isEmpty()
    val isEmpty = videos.itemCount == 0
    val isLoading = videos.loadState.refresh is LoadState.Loading
    val isError = videos.loadState.refresh is LoadState.Error
    
    LaunchedEffect(key1 = debouncedSearchQuery) {
        listState.animateScrollToItem(0)
        controller?.hide()
    }

    when {
        isInitial && isEmpty -> {
            InitialScreen()
        }

        isLoading -> {
            LoadingScreen()
        }

        isError -> {
            NetworkErrorScreen(
                errorMessage = stringResource(id = R.string.video_error_message),
                onClickRetryButton = { videos.retry() },
            )
        }

        else -> {
            LazyColumn(state = listState) {
                items(
                    count = videos.itemCount,
                    key = videos.itemKey(key = { video -> video.url }),
                    contentType = videos.itemContentType()
                ) { index ->
                    val item = videos[index]
                    item?.let {
                        VideoCard(videoItem = it, onClick = onClickVideoDetail)
                    }

                    Divider(color = Gray300)
                }

                item {
                    LoadStateFooter(
                        loadState = videos.loadState.append,
                        onRetryClick = { videos.retry() },
                    )
                }
            }
        }
    }
}

LazyColumn 내부의 items 블럭 밑에 LoadStateFooter Composable을 달아주었다.

LoadStateFooter 의 구현은 다음과 같다.

@Composable
fun LoadStateFooter(
    modifier: Modifier = Modifier,
    loadState: LoadState,
    onRetryClick: () -> Unit,
) {
    Box(
        modifier = modifier
            .fillMaxWidth()
            .padding(8.dp),
    ) {
        when (loadState) {
            is LoadState.Loading -> LoadingScreen()

            is LoadState.Error -> LoadErrorScreen(onRetryClick = onRetryClick)

            else -> EndOfResultScreen()
        }
    }
}
@Composable
fun LoadErrorScreen(
  modifier: Modifier = Modifier,
  onRetryClick: () -> Unit,
) {
  Box(
    modifier = modifier
      .fillMaxWidth()
      .padding(8.dp),
    contentAlignment = Alignment.Center,
  ) {
    Row(
      modifier = modifier.fillMaxWidth(),
      horizontalArrangement = Arrangement.SpaceBetween,
      verticalAlignment = Alignment.CenterVertically,
    ) {
      Text(
        text = stringResource(id = R.string.error_message),
        color = Gray900,
        fontSize = 16.sp,
        modifier = Modifier.align(Alignment.CenterVertically),
      )
      Button(
        onClick = onRetryClick,
      ) {
        Text(text = stringResource(id = R.string.retry))
      }
    }
  }
}

기존의 Android View System(이하 View) 을 통해 LoadStateFooter 를 구현할 때와 가장 큰 차이점은 LoadState의 상태를 View에선 pagingDataAdapter가 가지고 있기 때문에

    private fun initObserver() {
        repeatOnStarted {
            launch {
                videoSearchAdapter.loadStateFlow.collectLatest { loadStates ->
                    val loadState = loadStates.source

                    val isListEmpty = videoSearchAdapter.itemCount < 1 &&
                            loadState.refresh is LoadState.NotLoading &&
                            loadState.append.endOfPaginationReached

                    val isError = loadState.refresh is LoadState.Error

                    binding.apply {
                        pbVideoSearch.isVisible = loadState.refresh is LoadState.Loading
                        tvVideoSearchNoResult.isVisible = isListEmpty
                        rvVideoSearch.isVisible = !isListEmpty

                        tvVideoSearchError.isVisible = isError
                        btnVideoSearchRetry.isVisible = isError
                    }
                }
            }

            launch {
                viewModel.refreshClickEvent.collect {
                    videoSearchAdapter.retry()
                }
            }
        }
    }

다음과 같이 adapter의 loadStateFlow 를 구독하여 상태에 대한 이벤트를 처리하였는데
(retry 함수 역시 adapter 에서 지원)

Compose 에서는 adapter 패턴을 사용하지 않기 때문에 LazyPagingItems 가 상태를 가지고 있다.
따라서 상태에 관한 정보를 item 에서 추출한 뒤에 하위 컴포저블에게 전달해준다. 하위 컴포저블은 상태를 전달만 받기 때문에 stateless 한 형태로 구성될 수 있다.

// 각각의 상태(검색 이전 초기화면, 검색 결과 없음, 로딩, 에러)
val isInitial = searchQuery.isEmpty()
val isEmpty = videos.itemCount == 0
val isLoading = videos.loadState.refresh is LoadState.Loading
val isError = videos.loadState.refresh is LoadState.Error

코드의 가독성을 위해 컴포저블들을 최대한 분리하여 작성하였으며,
해당 깃허브 레포에서 전체 코드를 확인 할 수 있다.

전체 코드)
https://github.com/easyhooon/KakaoMediaSearchApp2

참고 자료)

https://stackoverflow.com/questions/71674153/how-to-handle-result-of-paging-data-in-compose-and-implement-header-and-footer-l

profile
실력은 고통의 총합이다. Android Developer
post-custom-banner

0개의 댓글