사진: Unsplash의 Mike 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
참고 자료)