feat(widget): 공통 탭바와 타이틀바 컴포넌트를 추가한다
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
5
app/src/main/res/color/color_text_tab_bar.xml
Normal file
5
app/src/main/res/color/color_text_tab_bar.xml
Normal 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>
|
||||
BIN
app/src/main/res/drawable-mdpi/ic_bar_bell.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_bar_bell.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 401 B |
BIN
app/src/main/res/drawable-mdpi/ic_bar_cash.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_bar_cash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 693 B |
BIN
app/src/main/res/drawable-mdpi/ic_bar_search.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_bar_search.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 369 B |
BIN
app/src/main/res/drawable-mdpi/img_text_logo_v2.png
Normal file
BIN
app/src/main/res/drawable-mdpi/img_text_logo_v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
8
app/src/main/res/drawable/bg_capsule_tab_normal.xml
Normal file
8
app/src/main/res/drawable/bg_capsule_tab_normal.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/bg_capsule_tab_selected.xml
Normal file
5
app/src/main/res/drawable/bg_capsule_tab_selected.xml
Normal 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>
|
||||
23
app/src/main/res/layout/view_capsule_tab_bar.xml
Normal file
23
app/src/main/res/layout/view_capsule_tab_bar.xml
Normal 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>
|
||||
43
app/src/main/res/layout/view_text_tab_bar.xml
Normal file
43
app/src/main/res/layout/view_text_tab_bar.xml
Normal 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>
|
||||
32
app/src/main/res/layout/view_title_bar_default.xml
Normal file
32
app/src/main/res/layout/view_title_bar_default.xml
Normal 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>
|
||||
28
app/src/main/res/layout/view_title_bar_home.xml
Normal file
28
app/src/main/res/layout/view_title_bar_home.xml
Normal 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>
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user