XML

[Android] TabLayout 형태의 스크롤 가능한 Sticky Header 구현하기

Marchbreeze 2025. 5. 15. 04:23
2023년 11월 20일에 작성되었던 글입니다.

구현 영상

 

구현해야 하는 뷰는 다음과 같습니다.

  1. 무신사의 레이아웃과 같이, 최상단에는 상품 소개뷰가 존재합니다.
  2. 아래로 스크롤하며 상세 설명이 나오면, 5개의 탭(정보, 사이즈, 추천, 리뷰, 문의) 순서대로 리스트가 나옵니다.
  3. 각 탭에 해당하는 내용이 리스트에 있으면, 해당 탭이 활성화된 TabLayout이 상단에 Sticky Header로 붙습니다.
  4. 스크롤되어 탭이 넘어가면 활성화된 탭도 변경되어 표시되어야 합니다.

 

다음과 같은 아이디어를 통해 구현했습니다.

  1. CollapsingToolbarLayout으로, 최상단의 상품 소개뷰는 처음 진입 시 펼쳐져 있지만, 스크롤 이후 접혀있도록 설정했습니다.
  2. TabLayout의 경우, CollapsingToolbarLayout과 동일한 AppBarLayout에 두어 소개뷰가 접히면 최상단에 고정되도록 설정했습니다.
  3. AppBarLayout 아래에 NestedScrollView를 배치하여 스크롤이 가능한 뷰를 설정했습니다.
  4. 스크롤을 감지하여, 해당 포지션까지 스크롤된 경우 활성화된 탭을 변경하며, 탭 클릭시 해당 레이아웃의 위치로 스크롤되도록 설정했습니다.

 

 


1. 전체적인 XML 레이아웃 구성

<CoordinatorLayout>

    <!-- AppBar 영역: 스크롤에 반응하는 툴바 -->
    <AppBarLayout>
        <CollapsingToolbarLayout
          app:layout_scrollFlags="scroll|enterAlwaysCollapsed">
                <!--상품소개뷰-->
        </CollapsingToolbarLayout>

        <!-- 탭 바 -->
        <TabLayout>
    </AppBarLayout>

    <!-- 스크롤 가능한 본문 영역 -->
    <NestedScrollView
      app:layout_behavior="@string/appbar_scrolling_view_behavior">
        <LinearLayout>
                <!--탭1-->
                <!--탭2-->
                <!--탭3-->
                <!--탭4-->
                <!--탭5-->
        </LinearLayout>
    </NestedScrollView>
    
</CoordinatorLayout>

 

CoordinatorLayout

  • 여러 자식 뷰 간에 상호작용(앱바 스크롤, 툴바 고정 등)을 조정하는 최상위 레이아웃입니다.
  • AppBarLayout과 함께 사용하면, 상단에 고정된 앱바를 만들거나, 스크롤에 따라 앱바의 크기, 투명도 등을 동적으로 변경할 수 있습니다.
  • NestedScrollView와 함께 사용하면, 스크롤 이벤트를 자동 처리하며 중첩된 스크롤뷰를 효과적으로 지원합니다.
  • 각각의 자식 뷰에 CoordinatorLayout.Behavior를 지정하여 특정 동작을 정의할 수 있습니다.

 

AppBarLayout

  • 앱바(툴바 등) 구성 요소를 감싸며, 스크롤에 따라 확장·축소 등의 동작을 지원합니다.  

 

CollapsingToolbarLayout 

  • AppBarLayout 내부에서 툴바 크기를 스크롤에 따라 조절할 수 있는 컴포넌트입니다.
  • layout_scrollFlags : AppBarLayout 내부의 자식 뷰들이 어떻게 스크롤에 반응할지를 결정하는 데 사용되는 속성입니다.
layout_scrollFlags 위로 스크롤 아래로 스크롤
enterAlways Toolbar만 남기고 다 올림 최상단 도착 시 CollapsingToolbarLayout 전체가 내려오기 시작
enterAlwaysCollapsed Toolbar까지 다 올림 스크롤 즉시 CollapsingToolbarLayout 전체가 내려오기 시작
exitUntilCollapsed Toolbar까지 다 올림 최상단 도착 시 CollapsingToolbarLayout 전체가 내려오기 시작

 

해당 속성을 고려하여, app:layout_scrollFlags="scroll|enterAlwaysCollapsed"로 설정했습니다.

 

layout_behavior

  • NestedScrollView에 layout_behavior를 지정하면, CoordinatorLayout을 통해 AppBarLayout과 연동된 스크롤 동작이 자동으로 적용됩니다. 

 

 


2. 스크롤 위치 지정

// 스크롤 뷰 내의 해당 뷰의 위치값 조회
fun NestedScrollView.computeDistanceToView(view: View): Int {
    val parentRect = Rect().also { getChildAt(0).getGlobalVisibleRect(it) }
    val viewRect = Rect().also { view.getGlobalVisibleRect(it) }
    return abs(viewRect.top - parentRect.top - scrollY)
}

// 해당 뷰의 위치값에 해당하는 위치로 smoothScroll 진행
fun NestedScrollView.smoothScrollToView(
    view: View,
    marginTop: Int = 0,
    maxDuration: Long = 500L,
    onEnd: () -> Unit = {}
) {
    val targetY = computeDistanceToView(view) - marginTop
    val distance = abs(targetY - scrollY)
    val ratio = distance.toFloat() / (getChildAt(0).height - height)
    ObjectAnimator.ofInt(this, "scrollY", targetY).apply {
        duration = (maxDuration * ratio).toLong()
        interpolator = AccelerateDecelerateInterpolator()
        doOnEnd { onEnd() }
        start()
    }
}
  • computeDistanceToView : 스크롤뷰 내에서 특정 뷰까지의 Y 거리(픽셀)를 계산했습니다.
  • smoothScrollToView : ObjectAnimator를 사용해 scrollY 속성을 애니메이션하며 부드럽게 이동시켰습니다.

 

 


3. TabLayout 동적 추가

private val tabTextList = listOf("상품정보", "사이즈", "추천", "리뷰", "문의")

binding.tabHatDetail.apply {
    for (name in tabTextList) {
        newTab().setText(name).also { addTab(it) }
    }
}
  • newTab()setText()addTab() 순으로 다수의 탭을 동적으로 추가했습니다.

 

 


4. 탭 선택 시 스크롤 이동

binding.tabDetail.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
    override fun onTabSelected(tab: TabLayout.Tab) {
        when (tab.position) {
            0 -> binding.svDetail.smoothScrollToView(binding.layoutInfo)
            1 -> binding.svDetail.smoothScrollToView(binding.layoutSize)
            2 -> binding.svDetail.smoothScrollToView(binding.layoutRecommend)
            3 -> binding.svDetail.smoothScrollToView(binding.layoutReview)
            4 -> binding.svDetail.smoothScrollToView(binding.layoutAsk)
        }
        // 탭 클릭 후 AppBar를 완전히 축소해 제목 영역만 보이도록 함
        binding.appbarDetail.setExpanded(false)
    }
    override fun onTabUnselected(tab: TabLayout.Tab) = Unit
    override fun onTabReselected(tab: TabLayout.Tab) = Unit
})
  • OnTabSelectedListener: 탭 선택·해제·재선택 이벤트를 처리합니다.
  • 탭 클릭 시 smoothScrollToView를 호출해 해당 섹션으로 부드럽게 이동했고, setExpanded(false)로 소개뷰를 축소했습니다.

 

 


5. 스크롤 시 탭 위치 동기화

binding.svDetail.setOnScrollChangeListener { _, _, scrollY, _, _ ->
    val y = scrollY
    val distances = listOf(
        binding.layoutInfo,
        binding.layoutSize,
        binding.layoutRecommend,
        binding.layoutReview,
        binding.layoutAsk
    ).map { binding.svDetail.computeDistanceToView(it) }

    when {
        y < distances[1] -> binding.tabDetail.setScrollPosition(0, 0f, true)
        y in distances[1] until distances[2] -> binding.tabDetail.setScrollPosition(1, 0f, true)
        y in distances[2] until distances[3] -> binding.tabDetail.setScrollPosition(2, 0f, true)
        y in distances[3] until distances[4] -> binding.tabDetail.setScrollPosition(3, 0f, true)
        else -> binding.tabDetail.setScrollPosition(4, 0f, true)
    }
}
  • OnScrollChangeListenerscrollY를 감지했습니다.
  • 각 섹션의 Y 위치와 비교해 setScrollPosition로 탭을 동기화했습니다.

 

 


 

이처럼 CoordinatorLayout + CollapsingToolbarLayout + NestedScrollView 조합에 TabLayout을 연동하여 탭 클릭 시 해당 섹션으로 부드럽게 이동하고, 스크롤 시 자동으로 탭이 전환되는 UX를 완성했습니다.

 


 

참고 자료 :

[안드로이드/따라만들기] 카카오뱅크 앱 애니메이션, MotionLayout 으로 따라만들기!

[안드로이드 잡학] Android ScrollView, ScrollTo 정복하기 + Custom Smooth Scroll

[Android] Sticky Header RecyclerView 응용하기

[안드로이드 Toolbar] CollapsingToolbarLayout 스크롤시 툴바 가리기 속성