# Capsule Tab Bar Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Figma `20:3590` 기준으로 메뉴가 많으면 가로 스크롤되는 재사용 가능한 Capsule Tab Bar Component를 추가한다. **Architecture:** 기존 `TextTabBarView`는 text 타입 탭 전용으로 유지하고, capsule 타입은 `CapsuleTabBarView`로 분리한다. XML 레이아웃은 `HorizontalScrollView`와 내부 `LinearLayout`을 제공하고, Kotlin custom view가 메뉴 TextView를 동적으로 생성해 selected 상태를 단일 관리한다. **Tech Stack:** Android XML Views, Kotlin custom View, ViewBinding/resource merge, JUnit4 local unit test. --- ## 작업 목표 - Figma `20:3590` capsule 탭바 디자인을 Android XML/Kotlin 재사용 컴포넌트로 구현한다. - 메뉴 개수가 많으면 가로 스크롤되어야 한다. - 기존 `TextTabBarView`와 기존 화면은 변경하지 않는다. ## 파일 구조 - Create: `app/src/main/res/layout/view_capsule_tab_bar.xml` - `CapsuleTabBarView` 루트, 내부 `HorizontalScrollView`, 내부 `LinearLayout` 컨테이너를 정의한다. - Create: `app/src/main/res/drawable/bg_capsule_tab_selected.xml` - selected capsule 배경을 정의한다. - Create: `app/src/main/res/drawable/bg_capsule_tab_normal.xml` - normal capsule 배경과 border를 정의한다. - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionState.kt` - 메뉴 목록과 selected index를 검증/보정하는 순수 Kotlin 상태 객체를 정의한다. - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt` - 메뉴 TextView를 동적으로 생성하고, 선택 상태와 콜백을 관리한다. - Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionStateTest.kt` - 1개 이상 메뉴, out-of-range 보정, 단일 selected 상태를 검증한다. - Modify: `docs/plan-task/20260519_캡슐탭바컴포넌트.md` - 구현 중 체크박스와 검증 기록을 누적한다. ## 구현 계획 ### Task 1: Capsule tab 선택 상태 TDD **Files:** - Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionStateTest.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionState.kt` - [x] **Step 1: RED - 선택 상태 테스트 추가** ```kotlin package kr.co.vividnext.sodalive.v2.widget import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test class CapsuleTabSelectionStateTest { @Test fun `create keeps one selected tab with one or more menus`() { val state = CapsuleTabSelectionState.create(listOf("추천", "랭킹", "팔로잉"), selectedIndex = 2) assertEquals(listOf("추천", "랭킹", "팔로잉"), state.menus) assertEquals(2, state.selectedIndex) assertFalse(state.isSelected(0)) assertFalse(state.isSelected(1)) assertTrue(state.isSelected(2)) } @Test fun `create rejects empty menus`() { assertThrows(IllegalArgumentException::class.java) { CapsuleTabSelectionState.create(emptyList()) } } @Test fun `create normalizes out of range selected index to first menu`() { val state = CapsuleTabSelectionState.create(listOf("추천", "랭킹"), selectedIndex = 5) assertEquals(0, state.selectedIndex) assertTrue(state.isSelected(0)) assertFalse(state.isSelected(1)) } @Test fun `select changes selected tab`() { val state = CapsuleTabSelectionState.create(listOf("추천", "랭킹", "팔로잉")) .select(1) assertEquals(1, state.selectedIndex) assertFalse(state.isSelected(0)) assertTrue(state.isSelected(1)) assertFalse(state.isSelected(2)) } } ``` - [x] **Step 2: RED 실행** Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"` Expected: `Unresolved reference 'CapsuleTabSelectionState'`로 실패한다. - [x] **Step 3: GREEN - 최소 상태 객체 추가** ```kotlin package kr.co.vividnext.sodalive.v2.widget class CapsuleTabSelectionState private constructor( val menus: List, val selectedIndex: Int ) { fun isSelected(index: Int): Boolean = selectedIndex == index fun select(index: Int): CapsuleTabSelectionState = create(menus, index) companion object { fun create( menus: List, selectedIndex: Int = 0 ): CapsuleTabSelectionState { require(menus.isNotEmpty()) { "Capsule tab bar requires at least one menu." } val normalizedSelectedIndex = if (selectedIndex in menus.indices) selectedIndex else 0 return CapsuleTabSelectionState( menus = menus.toList(), selectedIndex = normalizedSelectedIndex ) } } } ``` - [x] **Step 4: GREEN 실행** Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"` Expected: `BUILD SUCCESSFUL` ### Task 2: Capsule tab XML 리소스 추가 **Files:** - Create: `app/src/main/res/drawable/bg_capsule_tab_selected.xml` - Create: `app/src/main/res/drawable/bg_capsule_tab_normal.xml` - Create: `app/src/main/res/layout/view_capsule_tab_bar.xml` - [x] **Step 1: selected capsule drawable 추가** `app/src/main/res/drawable/bg_capsule_tab_selected.xml` ```xml ``` - [x] **Step 2: normal capsule drawable 추가** `app/src/main/res/drawable/bg_capsule_tab_normal.xml` ```xml ``` - [x] **Step 3: capsule tab bar layout 추가** `app/src/main/res/layout/view_capsule_tab_bar.xml` ```xml ``` ### Task 3: CapsuleTabBarView 구현 **Files:** - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt` - [x] **Step 1: custom view 추가** ```kotlin package kr.co.vividnext.sodalive.v2.widget import android.content.Context import android.util.AttributeSet import android.view.ViewGroup import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView import androidx.core.content.ContextCompat import kr.co.vividnext.sodalive.R class CapsuleTabBarView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr) { private var container: LinearLayout? = null private var selectionState: CapsuleTabSelectionState? = null private var onTabSelected: ((index: Int) -> Unit)? = null override fun onFinishInflate() { super.onFinishInflate() container = findViewById(R.id.ll_capsule_tab_container) } fun setMenus( menus: List, selectedIndex: Int = 0 ) { applyState(CapsuleTabSelectionState.create(menus, selectedIndex), notifySelection = false) } fun selectTab(index: Int) { val currentState = selectionState ?: return if (index !in currentState.menus.indices) return if (index == currentState.selectedIndex) return applyState(currentState.select(index), notifySelection = true) } fun setOnTabSelectedListener(listener: ((index: Int) -> Unit)?) { onTabSelected = listener } private fun applyState( state: CapsuleTabSelectionState, notifySelection: Boolean ) { selectionState = state val tabContainer = requireNotNull(container) { "Capsule tab container is not inflated." } tabContainer.removeAllViews() state.menus.forEachIndexed { index, label -> tabContainer.addView(createTabView(label, state.isSelected(index), index)) } if (notifySelection) { onTabSelected?.invoke(state.selectedIndex) } } private fun createTabView( label: String, selected: Boolean, index: Int ): TextView { return TextView(context).apply { text = label isSelected = selected setTextAppearance(R.style.Typography_Body5) setTextColor(ContextCompat.getColor(context, R.color.white)) background = ContextCompat.getDrawable( context, if (selected) R.drawable.bg_capsule_tab_selected else R.drawable.bg_capsule_tab_normal ) gravity = android.view.Gravity.CENTER minHeight = resources.getDimensionPixelSize(R.dimen.spacing_32) setPadding( resources.getDimensionPixelSize(R.dimen.spacing_12), resources.getDimensionPixelSize(R.dimen.spacing_8), resources.getDimensionPixelSize(R.dimen.spacing_12), resources.getDimensionPixelSize(R.dimen.spacing_8) ) setOnClickListener { selectTab(index) } layoutParams = LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, resources.getDimensionPixelSize(R.dimen.spacing_32) ).apply { if (index > 0) { marginStart = resources.getDimensionPixelSize(R.dimen.spacing_8) } } } } } ``` - [x] **Step 2: 리소스 참조 확인** Run: `rg -n "CapsuleTabBarView|ll_capsule_tab_container|bg_capsule_tab|Typography_Body5|spacing_8|spacing_12|spacing_32" app/src/main/res app/src/main/java/kr/co/vividnext/sodalive/v2/widget` Expected: 새 layout/drawable/Kotlin 파일의 참조가 모두 조회된다. ### Task 4: 검증 및 문서 기록 **Files:** - Modify: `docs/plan-task/20260519_캡슐탭바컴포넌트.md` - [x] **Step 1: LSP 진단 실행** Run: `lsp_diagnostics` on modified Kotlin/XML files Expected: 새 오류가 없다. Kotlin/XML LSP가 환경에 없으면 검증 기록에 남긴다. - [x] **Step 2: 단위 테스트 실행** Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"` Expected: `BUILD SUCCESSFUL` - [x] **Step 3: 리소스 빌드 실행** Run: `./gradlew :app:assembleDebug` Expected: `BUILD SUCCESSFUL` - [x] **Step 4: 스타일 검사 실행** Run: `./gradlew :app:ktlintCheck` Expected: `BUILD SUCCESSFUL` - [x] **Step 5: 검증 기록 누적** 문서 하단 `검증 기록`에 실행한 명령, 결과, 빌드 성공 여부를 한국어로 기록한다. ## 체크리스트 - [x] AC1: `view_capsule_tab_bar.xml`은 height `52dp`, background black을 가진다. - QA: XML 속성 확인 - [x] AC2: `view_capsule_tab_bar.xml`은 `HorizontalScrollView`를 포함해 메뉴 초과 시 가로 스크롤 가능하다. - QA: XML 구조와 `assembleDebug` 확인 - [x] AC3: selected 탭은 `soda_400` 배경과 white 텍스트를 사용한다. - QA: `bg_capsule_tab_selected.xml`, `CapsuleTabBarView.kt` 확인 - [x] AC4: normal 탭은 black 배경, `gray_700` border, white 텍스트를 사용한다. - QA: `bg_capsule_tab_normal.xml`, `CapsuleTabBarView.kt` 확인 - [x] AC5: 메뉴 1개 이상을 허용하고 항상 하나만 selected 상태다. - QA: `CapsuleTabSelectionStateTest` - [x] AC6: 기존 `TextTabBarView`와 기존 화면은 변경하지 않는다. - QA: 변경 파일 목록 확인 - [x] AC7: 리소스 병합, 단위 테스트, ktlint 검증이 성공한다. - QA: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"`, `./gradlew :app:assembleDebug`, `./gradlew :app:ktlintCheck` ## 검증 기록 - 2026-05-19 - 무엇/왜/어떻게: Figma `20:3590` 디자인을 확인하고, 기존 Text Tab과 분리된 Capsule Tab Bar PRD 및 구현 계획/TASK 문서를 작성했다. - 실행 명령/도구: - `Figma_get_design_context(20:3590)` - `Figma_get_screenshot(20:3590)` - `read(app/src/main/res/layout/view_text_tab_bar.xml)` - `read(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TextTabBarView.kt)` - `read(app/src/main/res/values/colors.xml)` - `read(app/src/main/res/values/dimens.xml)` - `read(app/src/main/res/values/typography.xml)` - 결과: - PRD 문서는 `docs/prd/20260519_캡슐탭바컴포넌트_prd.md`에 작성했다. - 계획/TASK 문서는 `docs/plan-task/20260519_캡슐탭바컴포넌트.md`에 작성했다. - 구현은 아직 수행하지 않았다. - 2026-05-19 - 무엇/왜/어떻게: 탭바 컴포넌트 네이밍을 `{Type}TabBarView` 방식으로 통일하기로 결정해 문서의 기존 text 탭 컴포넌트 참조를 `TextTabBarView` 계열로 갱신했다. - 실행 명령/도구: - `rg -n "CapsuleTabBarView|TabTextBarView|TabText|TextTab" app/src docs` - `apply_patch(docs/prd/20260519_캡슐탭바컴포넌트_prd.md)` - `apply_patch(docs/plan-task/20260519_캡슐탭바컴포넌트.md)` - 결과: - Capsule 컴포넌트는 `CapsuleTabBarView` 이름을 유지한다. - 기존 text 탭 컴포넌트 참조는 `TextTabBarView`, `TextTabSelectionState` 기준으로 정리했다. - 2026-05-19 - 무엇/왜/어떻게: 계획 문서에 따라 `CapsuleTabSelectionState`를 TDD로 추가하고, `HorizontalScrollView` 기반 재사용 `CapsuleTabBarView`와 capsule 배경 리소스를 구현했다. - 실행 명령/도구: - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"` (RED) - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"` (GREEN) - `rg -n "CapsuleTabBarView|ll_capsule_tab_container|bg_capsule_tab|Typography_Body5|spacing_8|spacing_12|spacing_32" app/src/main/res app/src/main/java/kr/co/vividnext/sodalive/v2/widget` - `lsp_diagnostics` on modified Kotlin/XML files - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"` - `./gradlew :app:assembleDebug` - `./gradlew :app:ktlintCheck` - 결과: - RED 테스트는 `Unresolved reference 'CapsuleTabSelectionState'`로 실패해 신규 상태 객체 부재를 확인했다. - GREEN 테스트는 `BUILD SUCCESSFUL`로 통과했다. - 리소스 참조 확인에서 `CapsuleTabBarView`, `ll_capsule_tab_container`, `bg_capsule_tab_*`, `Typography_Body5`, `spacing_8`, `spacing_12`, `spacing_32` 참조를 확인했다. - Kotlin/XML LSP는 현재 환경에 서버가 없어 `No LSP server configured for extension: .kt/.xml`로 실행 불가했다. - 단위 테스트, `assembleDebug`, `ktlintCheck`는 모두 `BUILD SUCCESSFUL`로 종료했다.