ViewModel에 너무 많은 책임을 지우지 말 것
여기 간단한 ``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하는 방법이다.
그러나 나는 ``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를 어디에 정의하는 것이 제일 좋은 지 자주 고민하는데, 좋은 사례가 되었다고 생각한다.