XML
[Android] TabLayout 형태의 스크롤 가능한 Sticky Header 구현하기
Marchbreeze
2025. 5. 15. 04:23
2023년 11월 20일에 작성되었던 글입니다.
구현 영상
구현해야 하는 뷰는 다음과 같습니다.
- 무신사의 레이아웃과 같이, 최상단에는 상품 소개뷰가 존재합니다.
- 아래로 스크롤하며 상세 설명이 나오면, 5개의 탭(정보, 사이즈, 추천, 리뷰, 문의) 순서대로 리스트가 나옵니다.
- 각 탭에 해당하는 내용이 리스트에 있으면, 해당 탭이 활성화된 TabLayout이 상단에 Sticky Header로 붙습니다.
- 스크롤되어 탭이 넘어가면 활성화된 탭도 변경되어 표시되어야 합니다.
다음과 같은 아이디어를 통해 구현했습니다.
- CollapsingToolbarLayout으로, 최상단의 상품 소개뷰는 처음 진입 시 펼쳐져 있지만, 스크롤 이후 접혀있도록 설정했습니다.
- TabLayout의 경우, CollapsingToolbarLayout과 동일한 AppBarLayout에 두어 소개뷰가 접히면 최상단에 고정되도록 설정했습니다.
- AppBarLayout 아래에 NestedScrollView를 배치하여 스크롤이 가능한 뷰를 설정했습니다.
- 스크롤을 감지하여, 해당 포지션까지 스크롤된 경우 활성화된 탭을 변경하며, 탭 클릭시 해당 레이아웃의 위치로 스크롤되도록 설정했습니다.
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)
}
}
- OnScrollChangeListener로 scrollY를 감지했습니다.
- 각 섹션의 Y 위치와 비교해 setScrollPosition로 탭을 동기화했습니다.
이처럼 CoordinatorLayout + CollapsingToolbarLayout + NestedScrollView 조합에 TabLayout을 연동하여 탭 클릭 시 해당 섹션으로 부드럽게 이동하고, 스크롤 시 자동으로 탭이 전환되는 UX를 완성했습니다.
참고 자료 :
[안드로이드/따라만들기] 카카오뱅크 앱 애니메이션, MotionLayout 으로 따라만들기!
[안드로이드 잡학] Android ScrollView, ScrollTo 정복하기 + Custom Smooth Scroll