[Background]
서버에서 엄청난 양의 데이터를 내려줘야 할 때 함께 번에 내려주지 않고 Paging을 이용해 요청할 때마다 일정량의 데이터를 내려줄 수 있다.
Android에서는 Paging3 라이브러리를 사용해서 정말로 쉽게(?) 도입할 수 있는데
제가 느끼기에는 Paging Item을 직접적으로 Composable에 노출을 시켜줘야 하고 viewModel에서 이 Paging 데이터를 활용하기 불편하다는 점도 있어서 State 관리나 확장성에도 솔직히 구리다는 느낌을 받았습니다
그래서 한번 직접 도입을 해보겠습니다.
[dependency]
Compose Complier 1.5.4
Retrofit 2.9.0
gson 2.9.0
[적용하기]
데이터를 가져올 Api는 StackOverFlow Api를 선택했습니다. Clean Architecture랑 Multi Module을 적용했지만 이 부분의 도입은 크게 중요한 게 없어서 스킵하겠습니다.
StackOverFlow에 데이터를 요청할 때 Page의 사이즈와 현재 Page 번호를 넘겨줍니다.
interface StackOverFlowApi {
@GET("/questions?order=desc&sort=activity&site=stackoverflow")
suspend fun lastActiveQuestions(
@Query("pagesize") pageSize: Int?,
@Query("page") page: Int,
@Query("key") api: String
): Response<QuestionListDTO>
}
이걸 viewModel을 호출하게 되는데 viewModel에서 page를 관리하고 넘겨주게 됩니다.
viewModel 안를 살펴보면
private var page by mutableStateOf(1)
private var canPaginate by mutableStateOf(false)
var homeState by mutableStateOf(HomeState())
private set
page는 현재 page 번호
canPaginate는 마지막에 내려온 리스트가 요청한 페이지수와 사이즈가 같은지 확인하고 다르면 더 이상 내려줄 Page가 없다 판단하여 false로 바뀝니다.
homeState는 Home Screen에서 사용하고 있는 모든 state를 관리해 주는 data class입니다. 오로지 viewmodel안에서만 수정이 가능합니다.
data class HomeState(
val isLoading: Boolean = false,
val isError: String? = null,
val errorMessage: String = "",
val questions: List<QuestionVO> = emptyList(),
val pageSize: Int = 30
)
그다음은 Page를 시작하게 될 event 함수, composable에서 onEvent를 호출해 OnPagingStart이라는 event를 호출하면 Paging이 진행됩니다.
fun onEvent(event: HomeEvent) {
when (event) {
is HomeEvent.OnPagingStart -> {
getQuestion()
}
}
}
private fun getQuestion() {
if (page == 1 || (page != 1 && canPaginate) && !homeState.isLoading) {
homeState = homeState.copy(isLoading = true)
viewModelScope.launch(Dispatchers.IO) {
repository.getQuestion(
pageSize = homeState.pageSize,
page = page,
)
.catch {
homeState = homeState.copy(
isLoading = false,
isError = it.message
)
}
.collect {
canPaginate = it.questions.size == homeState.pageSize
homeState = if (page == 1) {
homeState.copy(
isLoading = false,
questions = it.questions,
isError = null
)
} else {
homeState.copy(
isLoading = false,
questions = homeState.questions + it.questions,
isError = null
)
}
if (canPaginate) {
page++
}
}
}
}
}
첨에 조건을 확인해서 첫 페이지면 그냥 넘어가고 다음부터는 loading인지 그리고 Paging을 할 데이터가 남았는지 확인한다.
그리고 성공적으로 Api 호출을 하면 다음 페이지를 State에 저장해 준다.
이제 Composable을 보자면
맨 처음에는 Event를 발생시키는 부분
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val lazyListState = rememberLazyListState()
LaunchedEffect(key1 = state.questions.size) {
if (Constants.cardHeight.dp * state.questions.size < screenHeight) {
onEvent(HomeEvent.OnPagingStart)
}
}
화면 높이를 보는 이유는 PageSize가 화면보다 작을 때 화면 스크롤이 안돼서 스크롤이 가능한지 안 한 지 확인하기 위해 LaunchedEffect로 감싸주었다.
화면보다 작으면 이렇게 돼서 다음 Page를 받아올 수가 없다.
언제 다음 페이지를 요청할지는 현재 사용자의 화면에 보이는 마지막 위치를 바탕으로 결정하게 된다
val shouldStartPaginate = remember {
derivedStateOf {
(lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (lazyListState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(key1 = shouldStartPaginate.value) {
if (shouldStartPaginate.value && !state.isLoading) {
onEvent(HomeEvent.OnPagingStart)
}
}
derivedStateOf를 쓰는 이유는 lazyListState에 있는 visibleItemInfo의 상태가 스크롤할 때마다 몇 번씩이나 바뀌게 돼서
shouldStartPaginate의 상태를 너무 불필요하게 바꿔서 recomposition이 너무 자주 일어나게 되는 것을 막아준다.
그리고 shouldStartPaginate가 바꾸면 로딩 중이지 않을 때 다음 페이지를 가져오게 된다.
마지막은 가져온 데이터를 lazyColumn에 보여준다.
key를 index로 한 이유는 StackOverFlow에서 가끔 중복되는 질문을 내려줘서 Unique key를 안 쓴다고 튕길 때가 있습니다.
LazyColumn(
state = lazyListState,
) {
items(state.questions.size, key = {
it
}) { index ->
QuestionTile(question = state.questions[index])
}
}
마지막으로 결과를 보면 30개만 가져오고 마지막에 데이터를 가져오는 시간 때문에 한번 걸렸지만 바로 다음 페이지 데이터를 보여주는 것을 볼 수 있습니다.
[마무리]
전체 코드는 여기서 확인 가능합니다.
https://github.com/flash159483/mutli-module-compose
'android > compose' 카테고리의 다른 글
[Android/Compose] 최소 터치 타겟 사이즈에 대해서 알아보기 (0) | 2024.01.14 |
---|---|
(android/compose) Server driven UI + Rich Text로 배포 없이 간편하게 UI를 변화시키다 (1) | 2023.11.30 |