feat(widget): 시리즈 콘텐츠 카드 컴포넌트를 추가한다

This commit is contained in:
2026-05-20 13:47:20 +09:00
parent 36ffbc6cdb
commit 960e78afac
10 changed files with 862 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
package kr.co.vividnext.sodalive.v2.widget
import androidx.annotation.StyleRes
import kr.co.vividnext.sodalive.R
sealed class SeriesContentCardSize(
val cardWidthDp: Int,
val thumbnailWidthDp: Int,
val thumbnailHeightDp: Int,
val labelWidthDp: Int,
val thumbnailLabelGapDp: Int,
@get:StyleRes val titleStyleRes: Int,
@get:StyleRes val creatorStyleRes: Int
) {
data object Large : SeriesContentCardSize(
cardWidthDp = 163,
thumbnailWidthDp = 163,
thumbnailHeightDp = 230,
labelWidthDp = 151,
thumbnailLabelGapDp = 8,
titleStyleRes = R.style.Typography_Heading4,
creatorStyleRes = R.style.Typography_Body5
)
data object Small : SeriesContentCardSize(
cardWidthDp = 122,
thumbnailWidthDp = 122,
thumbnailHeightDp = 172,
labelWidthDp = 114,
thumbnailLabelGapDp = 8,
titleStyleRes = R.style.Typography_Body1,
creatorStyleRes = R.style.Typography_Caption2
)
}

View File

@@ -0,0 +1,115 @@
package kr.co.vividnext.sodalive.v2.widget
import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import kr.co.vividnext.sodalive.R
import kotlin.math.roundToInt
class SeriesContentCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private var thumbnailContainer: FrameLayout? = null
private var thumbnail: ImageView? = null
private var originalTag: View? = null
private var labelContainer: LinearLayout? = null
private var titleText: TextView? = null
private var creatorText: TextView? = null
init {
orientation = VERTICAL
}
override fun onFinishInflate() {
super.onFinishInflate()
thumbnailContainer = findViewById(R.id.fl_series_thumbnail_container)
thumbnail = findViewById(R.id.iv_series_content_thumbnail)
originalTag = findViewById(R.id.include_series_original_tag)
labelContainer = findViewById(R.id.ll_series_content_label)
titleText = findViewById(R.id.tv_series_content_title)
creatorText = findViewById(R.id.tv_series_content_creator)
setThumbnailOutline()
setSize(SeriesContentCardSize.Large)
}
fun setSize(size: SeriesContentCardSize) {
updateRootWidth(size.cardWidthDp.dpToPx())
requireNotNull(thumbnailContainer).layoutParams = LayoutParams(
size.thumbnailWidthDp.dpToPx(),
size.thumbnailHeightDp.dpToPx()
)
requireNotNull(labelContainer).layoutParams = LayoutParams(
size.labelWidthDp.dpToPx(),
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
topMargin = size.thumbnailLabelGapDp.dpToPx()
}
requireNotNull(titleText).setTextAppearance(size.titleStyleRes)
requireNotNull(creatorText).apply {
setTextAppearance(size.creatorStyleRes)
layoutParams = LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
topMargin = TITLE_CREATOR_GAP_DP.dpToPx()
}
}
}
fun setContent(
title: String,
creatorName: String
) {
requireNotNull(titleText).text = title
requireNotNull(creatorText).text = creatorName
}
fun setOriginalVisible(isVisible: Boolean) {
requireNotNull(originalTag).visibility = if (isVisible) VISIBLE else GONE
}
fun thumbnailView(): ImageView = requireNotNull(thumbnail)
private fun setThumbnailOutline() {
requireNotNull(thumbnailContainer).apply {
clipToOutline = true
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
}
}
}
}
private fun updateRootWidth(width: Int) {
val currentLayoutParams = layoutParams
layoutParams = if (currentLayoutParams == null) {
ViewGroup.LayoutParams(width, ViewGroup.LayoutParams.WRAP_CONTENT)
} else {
currentLayoutParams.apply {
this.width = width
this.height = ViewGroup.LayoutParams.WRAP_CONTENT
}
}
}
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
private companion object {
const val TITLE_CREATOR_GAP_DP = 2
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 B

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/gray_900" />
<corners android:radius="@dimen/radius_14" />
</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/gray_900" />
<corners android:bottomRightRadius="@dimen/radius_8" />
</shape>

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.SeriesContentCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<FrameLayout
android:id="@+id/fl_series_thumbnail_container"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/bg_series_content_thumbnail"
android:clipToOutline="true">
<ImageView
android:id="@+id/iv_series_content_thumbnail"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<include
android:id="@+id/include_series_original_tag"
layout="@layout/view_series_original_tag"
android:layout_width="101dp"
android:layout_height="@dimen/spacing_24"
android:layout_gravity="top|start" />
</FrameLayout>
<LinearLayout
android:id="@+id/ll_series_content_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/tv_series_content_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/white"
tools:text="콘텐츠 제목" />
<TextView
android:id="@+id/tv_series_content_creator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/gray_500"
tools:text="크리에이터 이름" />
</LinearLayout>
</kr.co.vividnext.sodalive.v2.widget.SeriesContentCardView>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/fl_series_original_tag"
android:layout_width="101dp"
android:layout_height="@dimen/spacing_24"
android:background="@drawable/bg_series_original_tag">
<ImageView
android:id="@+id/iv_series_original_icon"
android:layout_width="@dimen/spacing_14"
android:layout_height="@dimen/spacing_14"
android:layout_gravity="start|center_vertical"
android:layout_marginStart="@dimen/spacing_8"
android:contentDescription="@null"
android:src="@drawable/ic_series_original" />
<TextView
android:id="@+id/tv_series_original_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="26dp"
android:layout_marginTop="2dp"
android:fontFamily="@font/phosphate_solid"
android:text="ORIGINAL"
android:textColor="@color/white"
android:textSize="16sp"
tools:ignore="HardcodedText" />
</FrameLayout>

View File

@@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.v2.widget
import kr.co.vividnext.sodalive.R
import org.junit.Assert.assertEquals
import org.junit.Test
class SeriesContentCardSizeTest {
@Test
fun `large size matches figma contract`() {
assertEquals(163, SeriesContentCardSize.Large.cardWidthDp)
assertEquals(163, SeriesContentCardSize.Large.thumbnailWidthDp)
assertEquals(230, SeriesContentCardSize.Large.thumbnailHeightDp)
assertEquals(151, SeriesContentCardSize.Large.labelWidthDp)
assertEquals(8, SeriesContentCardSize.Large.thumbnailLabelGapDp)
assertEquals(R.style.Typography_Heading4, SeriesContentCardSize.Large.titleStyleRes)
assertEquals(R.style.Typography_Body5, SeriesContentCardSize.Large.creatorStyleRes)
}
@Test
fun `small size matches figma contract`() {
assertEquals(122, SeriesContentCardSize.Small.cardWidthDp)
assertEquals(122, SeriesContentCardSize.Small.thumbnailWidthDp)
assertEquals(172, SeriesContentCardSize.Small.thumbnailHeightDp)
assertEquals(114, SeriesContentCardSize.Small.labelWidthDp)
assertEquals(8, SeriesContentCardSize.Small.thumbnailLabelGapDp)
assertEquals(R.style.Typography_Body1, SeriesContentCardSize.Small.titleStyleRes)
assertEquals(R.style.Typography_Caption2, SeriesContentCardSize.Small.creatorStyleRes)
}
}