#안드로이드 #jetpack-compose

ScrollableTabRow와 HorizontalPager을 이용한 탭별 페이지 조회하기

1jeongg 1jeongg Follow 2024년 02월 04일 · 9 mins read
Share this

개발을 하던 중 ScrollableTabRow를 활용해야 했다.

개발 후 사용하다보니 이 탭바가 최상단에 있다 보니 사용자의 Thumb Zone (손가락이 닿기 편한 거리)에서 벗어나 불편했다.

이러한 문제를 해결하기 위해 HorizontalPager를 도입해서 좌우로 swipe하면 페이지가 전환되도록 하는 스크린을 만들어 보았다.

개발

01. 데이터 매핑

앞서 설명한 뷰는 공지 리스트를 보여주는 홈화면과 스크랩 화면, 이렇게 두 군데에서 필요했다. 두 화면별로 받아오는 데이터가 조금씩 달랐기 때문에 presentation layer에서 이를 mapper를 통해 동일한 데이터 형태로 바꾼뒤 하나의 CustomTabLayer에서 사용하고자 하였다.

ViewModel에서 contents가 공지 아이템 리스트을 가지고 있도록 했다. 이때 페이지네이션을 사용했기 때문에 데이터 타입은 Flow<PagingData<NoticeItemDTO>> 로 설정했다. NoticeItemDTO 에선 공지사항에 대한 실제 데이터를 가지고 있다.

01-1. ViewModel

@HiltViewModel
class ScrapViewModel @Inject constructor(
    private val getScrapListUseCase: GetScrapList,
): ViewModel(){

    private val _contents: Flow<PagingData<NoticeItemDTO>> = flow{}
    var contents = _contents

    init {
        getScrapPage(null)
    }

    fun getScrapPage(subscribeId: Long?){
        contents = NoticeItemMapper().scrapToNoticeItem(
            scrapPagingData = getScrapListUseCase(subscribeId).cachedIn(viewModelScope),
            scrapEvent = { isScraped, scrapDTO -> scrapEvent(isScraped, scrapDTO) }
        )
    }
}

01-2. NoticeItem 데이터

data class NoticeItemDTO(
    val contentId: Long = 0,
    val title: String = "",
    val date: String = "",
    val link: String = "",
    val isScraped: Boolean = false,
    val onScrapClick: (MutableState<Boolean>) -> Unit = {}
)

01-3. 매퍼

fun scrapToNoticeItem(
    scrapPagingData: Flow<PagingData<ScrapDTO>>,
    scrapEvent: (MutableState<Boolean>, ScrapDTO) -> Unit,
): Flow<PagingData<NoticeItemDTO>> {
    return scrapPagingData.map { scrapDTO ->
        scrapDTO.map { notice ->
            NoticeItemDTO(
                contentId = notice.contentId,
                title = notice.contentTitle,
                date = notice.pubDate.date.toString(),
                link = notice.link,
                isScraped = notice.isScrap,
                onScrapClick = { scrapEvent(it, notice) }
            )
        }
    }
}

02. Custom Tab Layer 생성

구독 탭 리스트와 공지사항 리스트를 처리할 Custom Tab Layer (PTabLayer) 를 만든다.

PTabLayer에서 관리할 현재 선택된 탭의 index를 rememberSaveable을 통해 관리하고 viewModel로부터 공지 리스트 데이터를 LazyPagingItem으로 변환하여 parameter로 넘겨준다.

02-1. 스크린에서 Custom Tab Layer, PTabLayer 호출

val selectedTabIndex = rememberSaveable { mutableIntStateOf(0) }
val contents = viewModel.contents.collectAsLazyPagingItems()

PTabLayer(
    tabs = subscribeList,
    selectedTabIndex = selectedTabIndex.intValue,
    contents = contents,
    onTabClick = { tabIndex ->
        if (selectedTabIndex.intValue != tabIndex) {
            selectedTabIndex.intValue = tabIndex
            viewModel.getScrapPage(subscribeList[tabIndex].subscribeId)
        }
    }
)

02-2. PTabLayer 함수

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PTabLayer(
    tabs: List<SubscribeGetResponseDTO>,
    selectedTabIndex: Int,
    onTabClick: (Int) -> Unit,
    contents: LazyPagingItems<NoticeItemDTO>,
) {
    val scope = rememberCoroutineScope()
    val pagerState = rememberPagerState { tabs.size }
    LaunchedEffect(pagerState.currentPage, pagerState.isScrollInProgress) {
        if (!pagerState.isScrollInProgress) {
            onTabClick(pagerState.currentPage)
        }
    }
    Column {
        TopSubscribeList(
            selectedTabIndex = selectedTabIndex,
            tabs = tabs,
            onTabClick= { index ->
                onTabClick(index)
                scope.launch { pagerState.scrollToPage(index) }
            }
        )
        HorizontalPager(
            state = pagerState,
            pageSpacing = 15.dp,
            modifier = Modifier.fillMaxSize(),
            verticalAlignment = Alignment.Top
        ) { page ->
            HorizontalPagerContent(page, selectedTabIndex, contents)
        }
    }
}

03. ScrollableTabRow 사용 (TopSubscribeList)

먼저 최상단의 구독 리스트 부분을 만들어줘야 한다. 이 부분은 따로 ScrollableTabRow 를 활용하여 만들어주면 된다.

탭을 클릭하면 selectedTabIndex 바뀌고 pagerState가 해당 index로 이동한다. 또한 해당 index의 content를 다시 불러온다.

03-1 ScrollableTabRow

ScrollableTabRow(
    selectedTabIndex = selectedTabIndex,
    contentColor = Color.White,
    edgePadding = 0.dp,
    containerColor = MaterialTheme.colorScheme.background,
    indicator = { TabLayerIndicator(it, selectedTabIndex) },
    divider = { PDivider() },
    modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth()
) {
    tabs.forEachIndexed { index, value ->
        Tab(
            selected = selectedTabIndex == index,
            onClick = { onTabClick(index) },
            text = { TabText(value.title) },
            interactionSource = NoRippleInteractionSource,
            selectedContentColor = main_yellow,
            unselectedContentColor = MaterialTheme.colorScheme.primary
        )
    }
}

04. HorizontalPager 사용

우리의 하이라이트 HorizontalPager이다.

보통 HorizontalPager는 이미지 등을 보여줄 때 주로 사용하곤 했는데 contents 자체를 보여주는 건 처음이라 조금 헤맸다.

먼저 pagerState는 현재 페이지에 대한 정보를 담고있는 변수이다.

  • currentPage: 지금 몇 번째 페이지인지 (절반 이상 swipe되면 바뀜)
  • isScrollInProgress: 지금 사용자가 스크롤 중인지
  • targetPage: 어디 페이지로 이동하는 중인지 (스크롤 중일 때)
  • scrollToPage(index): index 번째 페이지로 이동

04-1. HorizontalPager 사용

val pagerState = rememberPagerState { tabs.size }
HorizontalPager(
    state = pagerState,
    pageSpacing = 15.dp,
    modifier = Modifier.fillMaxSize(),
    verticalAlignment = Alignment.Top
) { page ->
    HorizontalPagerContent(page, selectedTabIndex, contents)
}

04-2. LazyColumn내에서 구독 아이템 보여주기 현재 코드에선 페이지네이션을 사용하기 때문에 다음과 같이 작성하였다. 현재 페이지의 content일 경우에만 내용을 보여주도록 하기 위해 if문을 사용하였다.

@Composable
private fun HorizontalPagerContent(
    page: Int,
    selectedTabIndex: Int,
    contents: LazyPagingItems<NoticeItemDTO>
) {
    LazyColumn {
        if (page == selectedTabIndex) {
            items(
                count = contents.itemCount,
                key = contents.itemKey { it.contentId }
            ) { index ->
                NoticeItem(noticeItemDTO = contents[index] ?: NoticeItemDTO())
            }
        }
    }
}

05. LaunchedEffect 사용

사용자가 scroll 하면, 즉 pagerState의 내용이 바뀌면 해당 index를 바꿔주는 것이다. LaunchedEffect를 사용하여

문제 발생

swipe 중 이전/다음 페이지에서 잘못된 페이지가 뜸

사용자가 다음 탭으로 swipe를 할 때 현재 탭의 내용이 보이는 문제가 발생했다. 이를 해결하기 위해서 무한 구글링 결과 다음 링크 를 통해 해결할 수 있었다.

문제가 발생한 이유는 너무 당연하게도 다음 페이지의 content를 가져오도록 로직이 구성되어있기 때문이었다. 무슨 당연한 소리인가.. 싶긴 하지만 현재 나는 현재 선택된 index의 content를 가지고 있기에 당연히 모든 페이지가 해당 content를 보여주는 것이었다.

그러면 생각해 볼 수 있는 경우의 수는

  1. 전체 Tab의 데이터를 로드해서 List<PagingItem<NoticeItemDTO>>를 만들어서 contents[index] 를 가져온다. 이 방법은 사용자가 쓰지도 않는 Tab의 데이터까지 가져오기때문에 쓸데 없는 성능 저하가 발생하기 때문에 Pass~
  2. 그러면 필요한 탭별로 가져오면 되겠다 싶어 Map<PagingItem<NoticeItemDTO>> 를 사용해서 해당 페이지가 이미 로드 되어있으면 그냥 쓰고 없으면 추가해준다. 이 방법은 어떤 블로그를 보고 생각했던 건데 구현하다가 접었다. 가장 먼저 문제가 될 법한 부분은 탭이 많다면 오히려 성능 저하가 발생할 수 있다는 점이었다. 또한 로직을 짜고 봤는데 코드가 굉장히.. 더러워졌다. 별로라서 pass~
  3. (채택) 그냥 현재 page의 content만 보여주고 swipe되는 동안은 다른 페이지는 비어있도록 진행 이때도 생각해야 할 게 좀 있었다. 지금 페이지의 인덱스를 int형 변수 selectedTabIndex , pagerState의 현재 인덱스 pagerState.currentPage, HorizontalPager내 page 이렇게 3개에서 관리하고 있는데 페이지를 이동할 때 이 3개가 모두 달랐기 때문이다.

    로그를 찍어본 결과 다음과 같이 나왔다.

    pagerState.currentPage는 swipe하다가 절반쯤 이동하면 바뀐다.

    page는 이전페이지, 현재페이지, 다음 페이지 이렇게 왔다 갔다 한다.

    selectedTabIndex 는 페이지가 완전히 전환되면 바뀐다.

    정답은 page == selectedTabIndex 가 일치하는 동안이다. page가 바뀌긴 하지만 근본적으로는 현재 HorizontalPager의 page index를 나타내기 때문에 그렇게 동작하는 것이었다.

ScrollableTabRow 의 탭을 클릭하면 아무 content가 뜨지 않음

이렇게 구현하고 나니 탭을 클릭했을 때 아무 내용이 뜨지 않았다. 또다시 로그를 찍어보니 pagerState.currentPage가 이동하지 않는 것을 알 수 있었다.

scope.launch { pagerState.scrollToPage(index) } 을 추가해주니 문제 없이 동작하였다.

추가 Tip - 로딩중 처리

로딩 중일 때 CircularProgressIndicator를 띄워주면 된다. 상황은 다음과 같이 3가지 고려해볼 수 있다.

  • contents.loadState.refresh is LoadState.Loading 전체 페이지가 로딩 중일 때
  • contents.loadState.append is LoadState.Loading 페이지의 Top 부분이 로딩 중일 때
  • contents.loadState.prepend is LoadState.Loading 페이지의 Footer 부분이 로딩 중일 때

끝! 다들 즐코딩하세요~



1jeongg
Written by 1jeongg Follow

I'm studying Android development by Kotlin and Spring by Java