feat(widget): 공통 탭바와 타이틀바 컴포넌트를 추가한다

This commit is contained in:
2026-05-19 20:29:42 +09:00
parent 3121d9dca9
commit 4457941193
21 changed files with 1360 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
package kr.co.vividnext.sodalive.v2.widget
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
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 = 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)
}
}
}
}
}

View File

@@ -0,0 +1,25 @@
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
)
}
}
}

View File

@@ -0,0 +1,70 @@
package kr.co.vividnext.sodalive.v2.widget
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import kr.co.vividnext.sodalive.R
class TextTabBarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private val tabViews: List<TextView>
get() = listOf(
findViewById(R.id.tv_text_tab_first),
findViewById(R.id.tv_text_tab_second),
findViewById(R.id.tv_text_tab_third)
)
private var selectionState: TextTabSelectionState? = null
private var onTabSelected: ((index: Int) -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
tabViews.forEachIndexed { index, textView ->
textView.setOnClickListener { selectTab(index) }
}
}
fun setMenus(
menus: List<String>,
selectedIndex: Int = 0
) {
applyState(TextTabSelectionState.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: TextTabSelectionState,
notifySelection: Boolean
) {
selectionState = state
tabViews.forEachIndexed { index, textView ->
val hasMenu = index in state.menus.indices
textView.visibility = if (hasMenu) View.VISIBLE else View.GONE
textView.isSelected = hasMenu && state.isSelected(index)
if (hasMenu) {
textView.text = state.menus[index]
}
}
if (notifySelection) {
onTabSelected?.invoke(state.selectedIndex)
}
}
}

View File

@@ -0,0 +1,27 @@
package kr.co.vividnext.sodalive.v2.widget
class TextTabSelectionState private constructor(
val menus: List<String>,
val selectedIndex: Int
) {
fun isSelected(index: Int): Boolean = selectedIndex == index
fun select(index: Int): TextTabSelectionState = create(menus, index)
companion object {
private const val MAX_MENU_COUNT = 3
fun create(
menus: List<String>,
selectedIndex: Int = 0
): TextTabSelectionState {
require(menus.size in 1..MAX_MENU_COUNT) { "Text tab bar requires one to three menus." }
val normalizedSelectedIndex = if (selectedIndex in menus.indices) selectedIndex else 0
return TextTabSelectionState(
menus = menus.toList(),
selectedIndex = normalizedSelectedIndex
)
}
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/white" android:state_selected="true" />
<item android:color="@color/gray_600" />
</selector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,8 @@
<?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>

View File

@@ -0,0 +1,5 @@
<?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>

View File

@@ -0,0 +1,23 @@
<?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>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.TextTabBarView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="52dp"
android:background="@color/black"
android:gravity="start|center_vertical"
android:paddingHorizontal="@dimen/spacing_20"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_text_tab_first"
style="@style/Typography.Heading3"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="@dimen/spacing_20"
android:gravity="center_vertical"
android:textColor="@color/color_text_tab_bar"
tools:text="추천" />
<TextView
android:id="@+id/tv_text_tab_second"
style="@style/Typography.Heading3"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="@dimen/spacing_20"
android:gravity="center_vertical"
android:textColor="@color/color_text_tab_bar"
android:visibility="gone"
tools:text="랭킹"
tools:visibility="visible" />
<TextView
android:id="@+id/tv_text_tab_third"
style="@style/Typography.Heading3"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:textColor="@color/color_text_tab_bar"
android:visibility="gone"
tools:text="팔로잉"
tools:visibility="visible" />
</kr.co.vividnext.sodalive.v2.widget.TextTabBarView>

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="60dp"
android:background="@color/black"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_20">
<TextView
android:id="@+id/tv_title_bar_title"
style="@style/Typography.Heading2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/white"
tools:text="타이틀" />
<View
android:id="@+id/view_title_bar_spacer"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<ImageView
android:id="@+id/iv_title_bar_menu"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null" />
</LinearLayout>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="60dp"
android:background="@color/black"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_20">
<ImageView
android:id="@+id/iv_title_bar_logo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
android:src="@drawable/img_text_logo_v2" />
<View
android:id="@+id/view_title_bar_spacer"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<ImageView
android:id="@+id/iv_title_bar_menu"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null" />
</LinearLayout>

View File

@@ -0,0 +1,48 @@
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))
}
}

View File

@@ -0,0 +1,51 @@
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 TextTabSelectionStateTest {
@Test
fun `create keeps one selected tab within one to three menus`() {
val state = TextTabSelectionState.create(listOf("", "랭킹", "마이"), selectedIndex = 1)
assertEquals(listOf("", "랭킹", "마이"), state.menus)
assertEquals(1, state.selectedIndex)
assertFalse(state.isSelected(0))
assertTrue(state.isSelected(1))
assertFalse(state.isSelected(2))
}
@Test
fun `create rejects empty or more than three menus`() {
assertThrows(IllegalArgumentException::class.java) {
TextTabSelectionState.create(emptyList())
}
assertThrows(IllegalArgumentException::class.java) {
TextTabSelectionState.create(listOf("1", "2", "3", "4"))
}
}
@Test
fun `create normalizes out of range selected index to first menu`() {
val state = TextTabSelectionState.create(listOf("", "랭킹"), selectedIndex = 3)
assertEquals(0, state.selectedIndex)
assertTrue(state.isSelected(0))
assertFalse(state.isSelected(1))
}
@Test
fun `select changes selected tab and keeps only one selected`() {
val state = TextTabSelectionState.create(listOf("", "랭킹", "마이"))
.select(2)
assertEquals(2, state.selectedIndex)
assertFalse(state.isSelected(0))
assertFalse(state.isSelected(1))
assertTrue(state.isSelected(2))
}
}