Files
sodalive-android/docs/plan-task/20260519_캡슐탭바컴포넌트.md

16 KiB

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

  • Step 1: RED - 선택 상태 테스트 추가

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))
    }
}
  • Step 2: RED 실행

Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"

Expected: Unresolved reference 'CapsuleTabSelectionState'로 실패한다.

  • Step 3: GREEN - 최소 상태 객체 추가
package kr.co.vividnext.sodalive.v2.widget

class CapsuleTabSelectionState private constructor(
    val menus: List<String>,
    val selectedIndex: Int
) {
    fun isSelected(index: Int): Boolean = selectedIndex == index

    fun select(index: Int): CapsuleTabSelectionState = create(menus, index)

    companion object {
        fun create(
            menus: List<String>,
            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
            )
        }
    }
}
  • 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

  • Step 1: selected capsule drawable 추가

app/src/main/res/drawable/bg_capsule_tab_selected.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/soda_400" />
    <corners android:radius="100dp" />
</shape>
  • Step 2: normal capsule drawable 추가

app/src/main/res/drawable/bg_capsule_tab_normal.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/black" />
    <stroke
        android:width="1dp"
        android:color="@color/gray_700" />
    <corners android:radius="100dp" />
</shape>
  • Step 3: capsule tab bar layout 추가

app/src/main/res/layout/view_capsule_tab_bar.xml

<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.CapsuleTabBarView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="52dp"
    android:background="@color/black">

    <HorizontalScrollView
        android:id="@+id/hsv_capsule_tab_bar"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fillViewport="false"
        android:overScrollMode="never"
        android:scrollbars="none">

        <LinearLayout
            android:id="@+id/ll_capsule_tab_container"
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:gravity="center_vertical"
            android:orientation="horizontal"
            android:paddingHorizontal="@dimen/spacing_20" />
    </HorizontalScrollView>
</kr.co.vividnext.sodalive.v2.widget.CapsuleTabBarView>

Task 3: CapsuleTabBarView 구현

Files:

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt

  • Step 1: custom view 추가

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<String>,
        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)
                }
            }
        }
    }
}
  • 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

  • Step 1: LSP 진단 실행

Run: lsp_diagnostics on modified Kotlin/XML files

Expected: 새 오류가 없다. Kotlin/XML LSP가 환경에 없으면 검증 기록에 남긴다.

  • Step 2: 단위 테스트 실행

Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"

Expected: BUILD SUCCESSFUL

  • Step 3: 리소스 빌드 실행

Run: ./gradlew :app:assembleDebug

Expected: BUILD SUCCESSFUL

  • Step 4: 스타일 검사 실행

Run: ./gradlew :app:ktlintCheck

Expected: BUILD SUCCESSFUL

  • Step 5: 검증 기록 누적

문서 하단 검증 기록에 실행한 명령, 결과, 빌드 성공 여부를 한국어로 기록한다.

체크리스트

  • AC1: view_capsule_tab_bar.xml은 height 52dp, background black을 가진다.
    • QA: XML 속성 확인
  • AC2: view_capsule_tab_bar.xmlHorizontalScrollView를 포함해 메뉴 초과 시 가로 스크롤 가능하다.
    • QA: XML 구조와 assembleDebug 확인
  • AC3: selected 탭은 soda_400 배경과 white 텍스트를 사용한다.
    • QA: bg_capsule_tab_selected.xml, CapsuleTabBarView.kt 확인
  • AC4: normal 탭은 black 배경, gray_700 border, white 텍스트를 사용한다.
    • QA: bg_capsule_tab_normal.xml, CapsuleTabBarView.kt 확인
  • AC5: 메뉴 1개 이상을 허용하고 항상 하나만 selected 상태다.
    • QA: CapsuleTabSelectionStateTest
  • AC6: 기존 TextTabBarView와 기존 화면은 변경하지 않는다.
    • QA: 변경 파일 목록 확인
  • 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로 종료했다.