From d5cc5df704eff2440357b678f843ba4dfb3557db Mon Sep 17 00:00:00 2001 From: klaus Date: Tue, 30 Jun 2026 02:43:54 +0900 Subject: [PATCH] =?UTF-8?q?refactor(creator-ranking):=20=EC=83=81=EC=9C=84?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C=20=EB=B7=B0=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorRankingTopCardMargin.kt | 28 ++++ .../CreatorRankingTopCardView.kt | 150 ++++++++++++++++++ .../layout/view_creator_ranking_top_card.xml | 96 +++++++++++ .../CreatorRankingTopCardMarginTest.kt | 43 +++++ 4 files changed, 317 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardMargin.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardView.kt create mode 100644 app/src/main/res/layout/view_creator_ranking_top_card.xml create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardMarginTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardMargin.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardMargin.kt new file mode 100644 index 00000000..35c06ab5 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardMargin.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.v2.widget.creatorranking + +data class CreatorRankingTopCardMargin( + val nameHorizontalDp: Int, + val nameBottomDp: Int, + val rankGroupStartDp: Int +) { + companion object { + fun fromRank(rank: Int): CreatorRankingTopCardMargin = when (rank) { + 1 -> CreatorRankingTopCardMargin( + nameHorizontalDp = 20, + nameBottomDp = 24, + rankGroupStartDp = 10 + ) + in 2..7 -> CreatorRankingTopCardMargin( + nameHorizontalDp = 10, + nameBottomDp = 10, + rankGroupStartDp = 8 + ) + in 8..10 -> CreatorRankingTopCardMargin( + nameHorizontalDp = 10, + nameBottomDp = 10, + rankGroupStartDp = 6 + ) + else -> error("Unsupported top-card rank: $rank") + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardView.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardView.kt new file mode 100644 index 00000000..1aaf26a3 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardView.kt @@ -0,0 +1,150 @@ +package kr.co.vividnext.sodalive.v2.widget.creatorranking + +import android.content.Context +import android.graphics.Outline +import android.graphics.RenderEffect +import android.graphics.Shader +import android.os.Build +import android.util.AttributeSet +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import kr.co.vividnext.sodalive.R +import kotlin.math.roundToInt + +class CreatorRankingTopCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private var image: ImageView? = null + private var dimGradient: View? = null + private var rankText: TextView? = null + private var rankGroup: View? = null + private var deltaGroup: View? = null + private var deltaAmountText: TextView? = null + private var deltaIcon: ImageView? = null + private var nameText: TextView? = null + private var currentItem: CreatorRankingItem? = null + private var clickListener: ((CreatorRankingItem) -> Unit)? = null + + override fun onFinishInflate() { + super.onFinishInflate() + image = findViewById(R.id.iv_creator_ranking_image) + dimGradient = findViewById(R.id.v_creator_ranking_dim_gradient) + rankGroup = findViewById(R.id.ll_creator_ranking_rank_group) + rankText = findViewById(R.id.tv_creator_ranking_rank) + deltaGroup = findViewById(R.id.ll_creator_ranking_delta) + deltaAmountText = findViewById(R.id.tv_creator_ranking_delta_amount) + deltaIcon = findViewById(R.id.iv_creator_ranking_delta_icon) + nameText = findViewById(R.id.tv_creator_ranking_name) + clipToOutline = true + outlineProvider = roundOutlineProvider() + imageView().outlineProvider = roundOutlineProvider() + imageView().clipToOutline = true + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = MeasureSpec.getSize(widthMeasureSpec) + val squareHeightSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY) + super.onMeasure(widthMeasureSpec, squareHeightSpec) + } + + fun bind(item: CreatorRankingItem) { + currentItem = item + val style = CreatorRankingTextStyle.fromRank(item.rank) + applyMargins(CreatorRankingTopCardMargin.fromRank(item.rank)) + requireNotNull(rankText).apply { + text = item.rank.toString() + setTextSize(TypedValue.COMPLEX_UNIT_SP, style.rankTextSizeSp.toFloat()) + applyCreatorRankingRankGradient() + } + bindDelta(item, style) + requireNotNull(nameText).apply { + text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, style.nameTextSizeSp.toFloat()) + visibility = if (text.isNullOrBlank()) INVISIBLE else VISIBLE + } + dimGradient?.visibility = VISIBLE + applyAccessState(item) + } + + fun imageView(): ImageView = requireNotNull(image) + + fun setOnCreatorClick(listener: ((CreatorRankingItem) -> Unit)?) { + clickListener = listener + currentItem?.let(::applyAccessState) + } + + private fun applyMargins(margin: CreatorRankingTopCardMargin) { + requireNotNull(rankGroup).layoutParams = + (requireNotNull(rankGroup).layoutParams as MarginLayoutParams).apply { + marginStart = margin.rankGroupStartDp.dpToPx() + } + requireNotNull(nameText).layoutParams = + (requireNotNull(nameText).layoutParams as MarginLayoutParams).apply { + marginStart = margin.nameHorizontalDp.dpToPx() + marginEnd = margin.nameHorizontalDp.dpToPx() + bottomMargin = margin.nameBottomDp.dpToPx() + } + } + + private fun bindDelta( + item: CreatorRankingItem, + style: CreatorRankingTextStyle + ) { + requireNotNull(deltaGroup).visibility = if (item.showRankChange) VISIBLE else GONE + if (!item.showRankChange) return + + val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount) + applyDeltaContainer(presentation) + requireNotNull(deltaIcon).apply { + setImageResource(presentation.iconRes) + layoutParams = (layoutParams as MarginLayoutParams).apply { + width = presentation.iconWidthDp.dpToPx() + height = presentation.iconHeightDp.dpToPx() + marginStart = presentation.iconMarginStartDp.dpToPx() + } + } + requireNotNull(deltaAmountText).apply { + text = presentation.amountText.orEmpty() + setTextSize(TypedValue.COMPLEX_UNIT_SP, style.deltaTextSizeSp.toFloat()) + visibility = if (presentation.showAmount) VISIBLE else GONE + } + } + + private fun applyDeltaContainer(presentation: CreatorRankingDeltaPresentation) { + requireNotNull(deltaGroup).apply { + setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0) + val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0 + setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom) + } + } + + private fun applyAccessState(item: CreatorRankingItem) { + applyBlur(item.isInaccessible) + isClickable = item.isTouchable && clickListener != null + setOnClickListener(if (isClickable) OnClickListener { clickListener?.invoke(item) } else null) + } + + private fun applyBlur(enabled: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + imageView().setRenderEffect( + if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null + ) + } + } + + private fun roundOutlineProvider() = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat()) + } + } + + private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt() +} diff --git a/app/src/main/res/layout/view_creator_ranking_top_card.xml b/app/src/main/res/layout/view_creator_ranking_top_card.xml new file mode 100644 index 00000000..e8ceb66a --- /dev/null +++ b/app/src/main/res/layout/view_creator_ranking_top_card.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardMarginTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardMarginTest.kt new file mode 100644 index 00000000..c8996423 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardMarginTest.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.v2.widget.creatorranking + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CreatorRankingTopCardMarginTest { + + @Test + fun `rank 1은 큰 닉네임 margin과 rank group start margin을 사용한다`() { + val margin = CreatorRankingTopCardMargin.fromRank(1) + + assertEquals(20, margin.nameHorizontalDp) + assertEquals(24, margin.nameBottomDp) + assertEquals(10, margin.rankGroupStartDp) + } + + @Test + fun `rank 2부터 7은 중간 rank group start margin을 사용한다`() { + (2..7).forEach { rank -> + val margin = CreatorRankingTopCardMargin.fromRank(rank) + + assertEquals(10, margin.nameHorizontalDp) + assertEquals(10, margin.nameBottomDp) + assertEquals(8, margin.rankGroupStartDp) + } + } + + @Test + fun `rank 8부터 10은 작은 rank group start margin을 사용한다`() { + (8..10).forEach { rank -> + val margin = CreatorRankingTopCardMargin.fromRank(rank) + + assertEquals(10, margin.nameHorizontalDp) + assertEquals(10, margin.nameBottomDp) + assertEquals(6, margin.rankGroupStartDp) + } + } + + @Test(expected = IllegalStateException::class) + fun `top-card 범위 밖 rank는 지원하지 않는다`() { + CreatorRankingTopCardMargin.fromRank(11) + } +}