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
)
}
}
}