Primary/Compose

ViewModel에 너무 많은 책임을 지우지 말 것

해스끼 2024. 2. 4. 22:20

여기 간단한 ``TabRow``가 하나 있다.

그런데 하단 탭 영역을 스와이프하여 넘기면 선택된 탭의 텍스트 색이 바뀌지 않고, 와중에 tab indicator만 움직인다.

???

원인

일단 전체 코드를 보자.

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SubscriptionTabs(
    selectedTab: EditSubscriptionTab,
    onTabClick: (EditSubscriptionTab) -> Unit,
    categories: List<NormalSubscriptionUiModel>,
    departments: List<DepartmentSubscriptionUiModel>,
    onCategoryClick: (Int) -> Unit,
    onDepartmentClick: (String) -> Unit,
    onAddDepartmentButtonClick: () -> Unit,
    onSubscriptionComplete: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val coroutineScope = rememberCoroutineScope()
    val pagerState = rememberPagerState(
        initialPage = selectedTab.ordinal,
        pageCount = { EditSubscriptionTab.values().size }
    )

    val currentPage = pagerState.currentPage
    Column(modifier = modifier) {
        TabRow(
            selectedTabIndex = currentPage,
            backgroundColor = MaterialTheme.colors.surface,
            contentColor = MaterialTheme.colors.primary,
            indicator = { tabPositions ->
                TabRowDefaults.Indicator(
                    Modifier
                        .tabIndicatorOffset(tabPositions[currentPage])
                        .padding(horizontal = 29.dp)
                )
            }
        ) {
            EditSubscriptionTab.values().forEach { tab ->
                SubscriptionTab(
                    tab = tab,
                    isSelected = tab == selectedTab,
                    onClick = {
                        coroutineScope.launch {
                            pagerState.animateScrollToPage(it.ordinal)
                        }
                        onTabClick(it)
                    },
                )
            }
        }
        // ...
    }
}

``TabRow``는 선택된 탭 등의 정보를 기억하기 위해 ``PagerState``를 활용한다. 위 코드에서도 ``rememberPagerState()`` API를 호출하여 ``PagerState``를 얻고 있다.

val pagerState = rememberPagerState(
    initialPage = selectedTab.ordinal,
    pageCount = { EditSubscriptionTab.values().size }
)

탭이 스와이프되면 ``pagerState.currentPage`` 값이 바뀐다. Composable 내부에서 이 값을 적절히 참조하면 된다.

 

그런데 위 코드에서 ``SubscriptionTab``을 호출하는 부분을 보면, ``isSelected``를 계산하는 과정에서 ``pagerState``를 전혀 참조하고 있지 않다. 

isSelected = tab == selectedTab

``tab``은 forEach에서 고정된 값이고, 스와이프하여 탭을 넘기면 ``onTabClick``이 실행되지 않으므로 ``selectedTab`` 역시  값이 그대로이다. 따라서 탭을 클릭하지 않는 이상 ``isSelected``의 값은 바뀌지 않는다.

해결 방법

``selectedTab``의 값을 바꾸는 방법과, ``pagerState`` 내부적으로 처리하는 방법이 있다. 

 

첫 번째 방법은 여기를 참고하면 된다. 콜백을 실행하여 ``ViewModel``의 ``selectedTab`` 값을 바꾸고, ``SubscriptionsTab``을 recomposition하는 방법이다.

 

Jetpack Compose HorizontalPager Page Change Callback

❤️ Is this article helpful? Buy me a coffee ☕ or support my work via PayPal to keep this space 🖖 and ad-free. Do send some 💖 to @d_luaz or share this article. ✨ By Desmond Lua A dream boy who enjoys making apps, travelling and making youtube

code.luasoftware.com

그러나 나는 ``ViewModel``에서 선택된 탭 정보를 굳이 들고 있을 필요가 없었다. 탭에 따라 로직이 달라지는 경우도 없고, 선택된 탭과 무관하게 모든 탭에 필요한 정보를 항상 로드하고 있기 때문이다.

 

따라서 ``pagerState``를 통해 선택된 탭의 정보를 얻어도 전혀 문제가 없다. 이제 ``pagerState.currentPage``를 참조하여 ``isSelected``를 계산하면 된다.

EditSubscriptionTab.values().forEachIndexed { index, tab ->
    SubscriptionTab(
        tab = tab,
        // 여기
        isSelected = index == pagerState.currentPage,
        onClick = {
            coroutineScope.launch {
                pagerState.animateScrollToPage(index)
            }
        },
    )
}

전체 코드를 보면, ``TabRow``의 indicator 매개변수를 계산하는 과정에서는 이미 ``pagerState.currentPage``를 참조하고 있다. 그래서 초록색 막대의 위치만 정상적으로 바뀌었던 것.

마치며

이 예시에서 알 수 있듯이, UI의 모든 state를 ``ViewModel``이 들고 있을 필요는 없다. ``ViewModel``은 UI가 보여줘야 할 데이터를 제공하는 데 집중해야 하며, 특별한 이유가 없다면 UI 내부의 state까지 ``ViewModel``이 들고 있을 필요는 없다.

 

요즘 state를 어디에 정의하는 것이 제일 좋은 지 자주 고민하는데, 좋은 사례가 되었다고 생각한다.