본문 바로가기

Android/Jetpack Compose

[Compose] TabRow와 ScrollableTabRow 사이에서 고민될 때, SubcomposeLayout으로 반응형 탭을 만들어보자

728x90

필자가 회사에서 업무를 진행하다 보면, 화면 상단에 탭을 배치하는 일이 자주 있다. Jetpack Compose에서는 보통 TabRowScrollableTabRow 둘 중 하나를 선택해서 사용하게 되는데, 어느 한쪽으로 정해버리면 항상 무언가가 아쉬웠던 것 같다.

짧은 레이블에는 TabRow가 좋고, 긴 레이블에는 ScrollableTabRow가 좋은데, 다국어 지원이나 동적으로 받아오는 카테고리 이름의 경우에는 어떤 것을 사용해야 할지 런타임에서나 알 수 있는 경우가 있다.

최근 Jetpack Compose 관련 블로그를 보다가, Joe Birch의 글에서 SubcomposeLayout을 활용한 반응형 TabRow 구현 방식을 알게 되었다. 이번 글에서는 SubcomposeLayout을 사용해 콘텐츠 길이에 따라 TabRowScrollableTabRow 중 적절한 것을 자동으로 선택하는 ResponsiveTabRow를 구현해 보고자 한다.


TabRow vs ScrollableTabRow

본격적으로 들어가기 전에 두 컴포넌트의 차이점을 먼저 정리해보자.

TabRow (고정 너비)

  • 각 탭이 동일한 너비를 가짐
  • 전체 너비를 탭 수로 균등 분할
  • 탭 수가 적고 레이블이 짧을 때 적합
  • 모든 탭이 화면에 표시됨

ScrollableTabRow (스크롤 가능)

  • 각 탭이 콘텐츠에 맞는 너비를 가짐
  • 수평 스크롤 가능
  • 탭이 많거나 레이블이 길 때 적합
  • 기본적으로 시작 정렬(start alignment)

문제는, 정적인 텍스트라면 어떤 것을 쓸지 미리 정할 수 있지만, 동적으로 길이가 결정되는 경우에는 단정 짓기가 애매하다는 점이다.


그래서 어떤 문제가 생기는가

필자가 직접 실험해본 결과, 두 컴포넌트는 잘못 사용했을 때 다음과 같은 문제가 발생한다.

1. 고정 너비 문제
TabRow 사용 시 텍스트가 잘리거나 여러 줄로 표시될 수 있다. 예를 들어 "개인정보 보호 설정" 같은 긴 텍스트를 3개만 넣어도 화면에서 잘려버린다.

2. 불필요한 스크롤
ScrollableTabRow는 공간이 충분해도 항상 스크롤이 가능하다. 짧은 텍스트 3개를 넣었는데도 화면 가운데가 비어 있고 좌측 정렬되는 모습은 영 이상해 보인다.

3. 다국어·동적 콘텐츠
서버에서 받아오는 카테고리 이름이나 다국어 리소스는 런타임에야 길이가 결정된다. 영어로는 짧지만 독일어나 한국어로는 길어지는 경우도 흔하다.

4. 접근성
사용자가 시스템에서 큰 텍스트 크기를 설정한 경우, 동일한 레이블이라도 차지하는 너비가 달라진다.

결국 어떤 컴포넌트를 쓸지 컴파일 타임에 결정짓기는 어렵고, 런타임에 콘텐츠를 측정해서 결정해야 한다는 결론에 도달하게 된다.


SubcomposeLayout이란

SubcomposeLayout은 Compose에서 콘텐츠를 단계적으로 측정하고 배치할 수 있는 강력한 도구이다. 일반적인 Layout과 달리, 자식 콘텐츠를 여러 단계로 나누어 측정한 뒤 그 결과를 바탕으로 다음 단계의 레이아웃을 결정할 수 있다.

대략적인 동작 흐름은 다음과 같다.

SubcomposeLayout(modifier = modifier) { constraints ->
      // 1단계: 측정 (MEASURE_PHASE)
      val measurements = subcompose("MEASURE_PHASE") {
          // 측정할 콘텐츠
      }.map { it.measure(...) }

      // 2단계: 측정 결과로 실제 UI 구성 (LAYOUT_PHASE)
      val content = subcompose("LAYOUT_PHASE") {
          // 측정 결과를 바탕으로 실제 UI 구성
      }.map { it.measure(constraints) }

      // 3단계: 배치
      layout(width, height) {
          content.forEach { it.placeRelative(0, 0) }
      }
  }
  

위와 같이 1단계에서 콘텐츠를 먼저 측정한 뒤, 그 결과를 바탕으로 2단계에서 실제 UI를 구성할 수 있다. 이를 활용하면 "콘텐츠가 화면에 다 들어가는지 먼저 재본 다음, 적절한 컴포넌트를 선택"하는 것이 가능해진다.


ResponsiveTabRow 구현하기

이제 본격적으로 반응형 TabRow를 구현해보자. 큰 흐름은 다음과 같다.

  1. 각 탭의 콘텐츠를 무제한 너비로 측정해서 선호 너비(preferred width)를 구한다
  2. 화면 너비를 탭 수로 나눈 값과 비교한다
  3. 하나라도 그 값을 초과하면 ScrollableTabRow, 아니면 TabRow로 렌더링한다

1단계 - 각 탭의 선호 너비 측정

val tabPreferredWidths = mutableListOf<Int>()
  subcompose("MEASURE_INDIVIDUAL_TABS") {
      tabTitles.forEachIndexed { index, title ->
          Box(
              modifier = Modifier.padding(horizontal = tabContentHorizontalPadding),
              contentAlignment = Alignment.Center
          ) {
              TabContent(
                  title = title,
                  count = tabCounts?.getOrNull(index),
                  style = tabTextStyle
              )
          }
      }
  }.forEach { measurable ->
      tabPreferredWidths.add(
          measurable.measure(
              // 무제한 너비 제약을 줘서 콘텐츠가 원하는 만큼 자라게 함
              Constraints(minWidth = 0, maxWidth = Constraints.Infinity)
          ).width
      )
  }
  

위 코드의 핵심은 Constraints(minWidth = 0, maxWidth = Constraints.Infinity)이다. 이렇게 무제한 너비 제약을 줘야 콘텐츠가 자기 자신이 원하는 최대 너비를 알려준다.

2단계 - 사용할 TabRow 타입 결정

val widthPerTabIfFixed = availableWidthPx / numberOfTabs
  val useScrollable = tabPreferredWidths.any { preferredWidth ->
      preferredWidth > widthPerTabIfFixed
  }
  

고정 너비를 사용했을 때 각 탭이 가질 수 있는 너비를 계산하고, 실제로 측정된 선호 너비 중 하나라도 그 값을 초과하면 ScrollableTabRow를 쓰는 것으로 결정한다.

3단계 - 실제 컴포넌트 렌더링

val layoutContent = @Composable {
      if (useScrollable) {
          ScrollableTabRow(
              selectedTabIndex = selectedTabIndex,
              backgroundColor = containerColor,
              contentColor = contentColor,
              edgePadding = 0.dp,
              indicator = indicator,
              divider = divider,
          ) {
              tabTitles.forEachIndexed { index, title ->
                  Tab(
                      selected = selectedTabIndex == index,
                      onClick = { onTabClick(index) },
                      text = {
                          TabContent(
                              title = title,
                              count = tabCounts?.getOrNull(index),
                              style = tabTextStyle
                          )
                      }
                  )
              }
          }
      } else {
          TabRow(
              selectedTabIndex = selectedTabIndex,
              backgroundColor = containerColor,
              contentColor = contentColor,
              indicator = indicator,
              divider = divider
          ) {
              tabTitles.forEachIndexed { index, title ->
                  Tab(
                      selected = selectedTabIndex == index,
                      onClick = { onTabClick(index) },
                      text = {
                          TabContent(
                              title = title,
                              count = tabCounts?.getOrNull(index),
                              style = tabTextStyle
                          )
                      }
                  )
              }
          }
      }
  }

  val placeables = subcompose("LAYOUT_ACTUAL_ROW", layoutContent)
      .map { it.measure(constraints) }
  

측정 단계와 실제 레이아웃 단계의 subcompose 키를 다르게 줘야 한다. 위 코드에서는 측정용으로는 "MEASURE_INDIVIDUAL_TABS", 실제 렌더링용으로는 "LAYOUT_ACTUAL_ROW" 라는 키를 사용했다.


실제 사용해보기

짧은 레이블 - TabRow가 자동 선택됨

var selectedTab by remember { mutableIntStateOf(0) }
  val tabs = listOf("Posts", "Following", "Followers")

  ResponsiveTabRow(
      selectedTabIndex = selectedTab,
      tabTitles = tabs,
      onTabClick = { selectedTab = it }
  )
  

위 코드는 짧은 레이블 3개라서 화면에 충분히 들어간다. 이 경우 내부적으로 TabRow가 선택되고, 각 탭이 균등한 너비를 가진다.

긴 레이블 - ScrollableTabRow가 자동 선택됨

val tabs = listOf("일반 설정", "알림 설정", "개인정보 보호 및 보안", "계정 관리")

  ResponsiveTabRow(
      selectedTabIndex = selectedTab,
      tabTitles = tabs,
      onTabClick = { selectedTab = it }
  )
  

레이블이 길어서 균등 너비로는 잘리는 상황이다. 이 경우 ScrollableTabRow로 자동 전환되어 좌우 스크롤이 가능해진다.

배지와 함께 사용

val tabs = listOf("받은편지함", "보낸편지함", "임시보관함")
  val counts = listOf(12, null, 3)

  ResponsiveTabRow(
      selectedTabIndex = selectedTab,
      tabTitles = tabs,

배지와 함께 사용

val tabs = listOf("받은편지함", "보낸편지함", "임시보관함")
  val counts = listOf(12, null, 3)

  ResponsiveTabRow(
      selectedTabIndex = selectedTab,
      tabTitles = tabs,
      tabCounts = counts,
      onTabClick = { selectedTab = it }
  )
  

tabCounts 파라미터로 각 탭에 배지를 표시할 수 있도록 확장하였다. null인 경우에는 배지를 표시하지 않고, 99를 초과하면 "99+"로 표시된다.


구현하면서 알게 된 점

처음에는 단순히 텍스트 길이만 비교하면 되지 않을까 생각했다. 하지만 실제로는 폰트 크기, 패딩, 배지 같은 요소가 모두 영향을 주기 때문에 단순한 글자 수 비교로는 정확한 너비를 알 수 없다는 것을 알게 되었다.

그래서 SubcomposeLayout으로 실제 콘텐츠를 측정하는 방식이 가장 정확한 것 같다. 시스템 폰트 크기 설정을 바꿔도, 다국어 리소스로 바꿔도, 모두 자동으로 대응된다.

다만 SubcomposeLayout은 측정 단계가 추가되는 만큼 일반 Layout보다는 비용이 든다. 일반적인 사용 케이스(3~7개 탭)에서는 별로 문제가 되지 않지만, 탭이 매우 많은 경우에는 다른 방식을 고민해보는 게 좋을 것 같다.

그리고 tabTitles 같은 리스트는 remember로 안정적으로 유지해야 한다. 매번 새 리스트가 만들어지면 재측정이 발생해서 성능에 영향을 줄 수 있다.


마무리

이번 글에서는 SubcomposeLayout을 활용해 콘텐츠 길이에 따라 자동으로 적절한 TabRow를 선택하는 ResponsiveTabRow에 대하여 작성해 보았다.

필자처럼 다국어 지원이나 동적 카테고리를 다뤄야 하는 경우, 매번 어떤 TabRow를 쓸지 고민하는 대신 이런 식으로 한 번 만들어두면 두고두고 편하게 쓸 수 있을 것 같다는 생각이 든다. SubcomposeLayout 자체도 응용 범위가 넓어서, 다음에는 비슷한 패턴으로 반응형 그리드나 적응형 다이얼로그 같은 컴포넌트도 한번 만들어볼 예정이다.

필자도 아직 SubcomposeLayout에 대해서는 공부 중이기 때문에 작성한 개념이나 설명에 오류가 있을 가능성이 있다는 점을 감안하고 읽어주었으면 좋겠다.

해당 게시글에 사용한 예제는 다음 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample

 

GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.

This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample

github.com

728x90