refactor(creator-ranking): 어댑터 뷰 타입을 정리한다

This commit is contained in:
2026-06-30 02:44:19 +09:00
parent 86be8e4af1
commit 7c7aa0f442
5 changed files with 104 additions and 303 deletions

View File

@@ -185,6 +185,9 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
private fun setUpCreatorRankingAdapter() {
binding.rvHomeCreatorRankings.apply {
layoutManager = CreatorRankingAdapter.createGridLayoutManager(requireContext())
if (itemDecorationCount == 0) {
addItemDecoration(CreatorRankingAdapter.createItemDecoration(requireContext()))
}
adapter = creatorRankingAdapter
}
}

View File

@@ -1,7 +1,9 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import android.content.Context
import android.graphics.Rect
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.GridLayoutManager
@@ -16,26 +18,22 @@ class CreatorRankingAdapter(
private val items = mutableListOf<CreatorRankingItem>()
override fun getItemViewType(position: Int): Int = when (CreatorRankingPlacement.fromRank(items[position].rank).variant) {
CreatorRankingCardVariant.Large -> VIEW_TYPE_LARGE
CreatorRankingCardVariant.Compact -> VIEW_TYPE_COMPACT
CreatorRankingCardVariant.Horizontal -> VIEW_TYPE_HORIZONTAL
override fun getItemViewType(position: Int): Int {
val placement = CreatorRankingPlacement.fromRank(items[position].rank)
return when (placement.viewType) {
CreatorRankingViewType.TopTen -> VIEW_TYPE_TOP_TEN
CreatorRankingViewType.Lower -> VIEW_TYPE_LOWER
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_LARGE -> LargeViewHolder(
inflater.inflate(R.layout.view_creator_ranking_large_card, parent, false) as CreatorRankingLargeCardView,
parent
VIEW_TYPE_TOP_TEN -> TopTenViewHolder(
inflater.inflate(R.layout.view_creator_ranking_top_card, parent, false) as CreatorRankingTopCardView
)
VIEW_TYPE_COMPACT -> CompactViewHolder(
inflater.inflate(R.layout.view_creator_ranking_compact_card, parent, false) as CreatorRankingCompactCardView,
parent
)
VIEW_TYPE_HORIZONTAL -> HorizontalViewHolder(
inflater.inflate(R.layout.view_creator_ranking_horizontal_card, parent, false) as CreatorRankingHorizontalCardView,
parent
VIEW_TYPE_LOWER -> LowerViewHolder(
inflater.inflate(R.layout.view_creator_ranking_lower_row, parent, false) as CreatorRankingLowerRowView
)
else -> error("Unknown viewType: $viewType")
}
@@ -44,9 +42,8 @@ class CreatorRankingAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = items[position]
when (holder) {
is LargeViewHolder -> holder.bind(item)
is CompactViewHolder -> holder.bind(item)
is HorizontalViewHolder -> holder.bind(item)
is TopTenViewHolder -> holder.bind(item)
is LowerViewHolder -> holder.bind(item)
}
}
@@ -58,66 +55,37 @@ class CreatorRankingAdapter(
notifyDataSetChanged()
}
private inner class LargeViewHolder(
private val view: CreatorRankingLargeCardView,
private val parent: ViewGroup
private inner class TopTenViewHolder(
private val view: CreatorRankingTopCardView
) : RecyclerView.ViewHolder(view) {
fun bind(item: CreatorRankingItem) {
bindCommon(view, item, parent)
bindCommon(view, item)
view.bind(item)
view.setOnCreatorClick(onClickItem)
}
}
private inner class CompactViewHolder(
private val view: CreatorRankingCompactCardView,
private val parent: ViewGroup
private inner class LowerViewHolder(
private val view: CreatorRankingLowerRowView
) : RecyclerView.ViewHolder(view) {
fun bind(item: CreatorRankingItem) {
bindCommon(view, item, parent)
view.bind(item)
view.setOnCreatorClick(onClickItem)
}
}
private inner class HorizontalViewHolder(
private val view: CreatorRankingHorizontalCardView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: CreatorRankingItem) {
bindCommon(view, item, parent)
bindCommon(view, item)
view.bind(item)
view.setOnCreatorClick(onClickItem)
}
}
private fun bindCommon(
view: CreatorRankingLargeCardView,
item: CreatorRankingItem,
parent: ViewGroup
view: CreatorRankingTopCardView,
item: CreatorRankingItem
) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadCreatorImage(item)
}
private fun bindCommon(
view: CreatorRankingCompactCardView,
item: CreatorRankingItem,
parent: ViewGroup
view: CreatorRankingLowerRowView,
item: CreatorRankingItem
) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadCreatorImage(item)
}
private fun bindCommon(
view: CreatorRankingHorizontalCardView,
item: CreatorRankingItem,
parent: ViewGroup
) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadCreatorImage(item)
}
@@ -130,21 +98,6 @@ class CreatorRankingAdapter(
}
}
private fun calculateSize(
item: CreatorRankingItem,
parent: ViewGroup
): CreatorRankingCardSize {
val parentWidth = parent.width.takeIf { it > 0 } ?: parent.resources.displayMetrics.widthPixels
return CreatorRankingLayoutCalculator.calculate(
parentWidthPx = parentWidth,
parentHorizontalPaddingPx = parent.paddingLeft + parent.paddingRight,
horizontalGapPx = HORIZONTAL_GAP_DP.dpToPx(parent),
placement = CreatorRankingPlacement.fromRank(item.rank)
)
}
private fun Int.dpToPx(parent: ViewGroup): Int = (this * parent.resources.displayMetrics.density).roundToInt()
companion object {
const val GRID_SPAN_COUNT = 6
@@ -152,6 +105,10 @@ class CreatorRankingAdapter(
spanSizeLookup = createSpanSizeLookup()
}
fun createItemDecoration(context: Context): RecyclerView.ItemDecoration {
return CreatorRankingItemDecoration(HORIZONTAL_GAP_DP.dpToPx(context))
}
fun createSpanSizeLookup(): GridLayoutManager.SpanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int = when (CreatorRankingPlacement.fromRank(position + 1).itemsPerRow) {
1 -> GRID_SPAN_COUNT
@@ -161,9 +118,50 @@ class CreatorRankingAdapter(
}
}
private const val VIEW_TYPE_LARGE = 1
private const val VIEW_TYPE_COMPACT = 2
private const val VIEW_TYPE_HORIZONTAL = 3
private const val HORIZONTAL_GAP_DP = 4
private const val VIEW_TYPE_TOP_TEN = 1
private const val VIEW_TYPE_LOWER = 2
private const val HORIZONTAL_GAP_DP = 8
private fun Int.dpToPx(context: Context): Int = (this * context.resources.displayMetrics.density).roundToInt()
}
}
internal class CreatorRankingItemDecoration(
private val spacingPx: Int
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val position = parent.getChildAdapterPosition(view)
val itemCount = parent.adapter?.itemCount ?: return
if (position == RecyclerView.NO_POSITION) return
outRect.set(offsetsForPosition(position, itemCount))
}
fun offsetsForPosition(
adapterPosition: Int,
itemCount: Int
): Rect {
val placement = CreatorRankingPlacement.fromRank(adapterPosition + 1)
val indexInRow = adapterPosition.indexInRankingRow(placement.itemsPerRow)
return Rect().apply {
left = indexInRow * spacingPx / placement.itemsPerRow
right = spacingPx - (indexInRow + 1) * spacingPx / placement.itemsPerRow
if (adapterPosition < itemCount - 1) bottom = spacingPx
}
}
private fun Int.indexInRankingRow(itemsPerRow: Int): Int {
return when (this) {
0 -> 0
in 1..6 -> (this - 1) % itemsPerRow
in 7..9 -> (this - 7) % itemsPerRow
else -> 0
}
}
}

View File

@@ -360,5 +360,5 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_tab_bar_home"
tools:listitem="@layout/view_creator_ranking_horizontal_card" />
tools:listitem="@layout/view_creator_ranking_lower_row" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -585,24 +585,14 @@ class HomeMainFragmentLayoutTest {
}
@Test
fun `home ranking layout contains ranking list below text tab bar`() {
val root = inflateView(R.layout.fragment_v2_main_home)
val rankingList = root.findViewById<RecyclerView>(R.id.rv_home_creator_rankings)
val layoutParams = rankingList.layoutParams as ConstraintLayout.LayoutParams
fun `home ranking layout declares ranking list without capsule tab source`() {
val layoutSource = homeMainLayoutSource()
assertNotNull(rankingList)
assertSame(root, rankingList.parent)
assertEquals(0, layoutParams.width)
assertEquals(0, layoutParams.height)
assertEquals(R.id.text_tab_bar_home, layoutParams.topToBottom)
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, layoutParams.startToStart)
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, layoutParams.endToEnd)
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, layoutParams.bottomToBottom)
assertEquals(View.GONE, rankingList.visibility)
assertEquals(false, rankingList.clipToPadding)
assertEquals(14.dpToPx(), rankingList.paddingStart)
assertEquals(14.dpToPx(), rankingList.paddingTop)
assertEquals(28.dpToPx(), rankingList.paddingBottom)
assertTrue(layoutSource.contains("@+id/rv_home_creator_rankings"))
assertTrue(layoutSource.contains("@id/text_tab_bar_home"))
assertFalse(layoutSource.contains("view_capsule_tab_bar"))
assertFalse(layoutSource.contains("hsv_capsule_tab_bar"))
assertFalse(layoutSource.contains("ll_capsule_tab_container"))
}
@Test
@@ -1199,6 +1189,7 @@ class HomeMainFragmentLayoutTest {
assertTrue(source.contains("CreatorRankingAdapter { openCreatorRankingProfile(it) }"))
assertTrue(source.contains("binding.rvHomeCreatorRankings.apply"))
assertTrue(source.contains("layoutManager = CreatorRankingAdapter.createGridLayoutManager(requireContext())"))
assertTrue(source.contains("addItemDecoration(CreatorRankingAdapter.createItemDecoration(requireContext()))"))
assertTrue(source.contains("adapter = creatorRankingAdapter"))
}

View File

@@ -1,32 +1,17 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import android.app.Application
import android.content.Context
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType.Increase
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class CreatorRankingAdapterLayoutTest {
@Test
fun `span count supports full two and three column ranking rows`() {
fun `grid span count는 1 2 3 item row를 모두 표현한다`() {
assertEquals(6, CreatorRankingAdapter.GRID_SPAN_COUNT)
}
@Test
fun `span lookup maps rank groups to expected row widths`() {
fun `span lookup rank 구간별 items per row를 span 크기로 변환한다`() {
val spanLookup = CreatorRankingAdapter.createSpanSizeLookup()
assertEquals(6, spanLookup.getSpanSize(0))
@@ -42,200 +27,24 @@ class CreatorRankingAdapterLayoutTest {
}
@Test
fun `large card 순위 숫자는 Figma 고정 박스 안에서 중앙 정렬된다`() {
val view = inflateView<CreatorRankingLargeCardView>(R.layout.view_creator_ranking_large_card)
fun `item decoration은 rank 구간별 row index로 gap을 분배한다`() {
val spacing = 8
val decoration = CreatorRankingItemDecoration(spacing)
view.setCardSize(CreatorRankingCardSize(widthPx = 374, heightPx = 374))
val first = decoration.offsetsForPosition(adapterPosition = 0, itemCount = 12)
val second = decoration.offsetsForPosition(adapterPosition = 1, itemCount = 12)
val third = decoration.offsetsForPosition(adapterPosition = 2, itemCount = 12)
val eighth = decoration.offsetsForPosition(adapterPosition = 7, itemCount = 12)
val ninth = decoration.offsetsForPosition(adapterPosition = 8, itemCount = 12)
val tenth = decoration.offsetsForPosition(adapterPosition = 9, itemCount = 12)
val eleventh = decoration.offsetsForPosition(adapterPosition = 10, itemCount = 12)
assertRankTextBox(
view = view.findViewById(R.id.tv_creator_ranking_rank),
expectedWidth = 86,
expectedHeight = 116,
expectedLeft = 0,
expectedTop = 0,
expectedBottomPadding = 10
)
assertEquals(spacing, first.bottom)
assertEquals(spacing, second.right + third.left)
assertEquals(spacing, second.bottom)
assertEquals(spacing, eighth.right + ninth.left)
assertEquals(spacing, ninth.right + tenth.left)
assertEquals(spacing, tenth.bottom)
assertEquals(spacing, eleventh.bottom)
}
@Test
fun `compact medium card 순위 숫자는 Figma 고정 박스 안에서 중앙 정렬된다`() {
val view = inflateView<CreatorRankingCompactCardView>(R.layout.view_creator_ranking_compact_card)
view.setCardSize(CreatorRankingCardSize(widthPx = 185, heightPx = 185))
assertRankTextBox(
view = view.findViewById(R.id.tv_creator_ranking_rank),
expectedWidth = 56,
expectedHeight = 70,
expectedLeft = 0,
expectedTop = 0,
expectedBottomPadding = 6
)
}
@Test
fun `compact small card 순위 숫자는 Figma 고정 박스 안에서 중앙 정렬된다`() {
val view = inflateView<CreatorRankingCompactCardView>(R.layout.view_creator_ranking_compact_card)
view.setCardSize(CreatorRankingCardSize(widthPx = 122, heightPx = 122))
assertRankTextBox(
view = view.findViewById(R.id.tv_creator_ranking_rank),
expectedWidth = 52,
expectedHeight = 50,
expectedLeft = 0,
expectedTop = 0,
expectedBottomPadding = 5
)
}
@Test
fun `horizontal card 순위 그룹은 Figma 고정 박스 위치를 유지한다`() {
val view = inflateView<CreatorRankingHorizontalCardView>(R.layout.view_creator_ranking_horizontal_card)
view.setCardSize(CreatorRankingCardSize(widthPx = 374, heightPx = 100))
val rankGroup = view.findViewById<View>(R.id.ll_creator_ranking_rank_group)
val params = rankGroup.layoutParams as ViewGroup.MarginLayoutParams
assertEquals(49, params.width)
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, params.height)
assertEquals(14, params.leftMargin)
assertEquals(12, params.topMargin)
assertRankTextBox(
view = view.findViewById(R.id.tv_creator_ranking_rank),
expectedWidth = 48,
expectedHeight = 52,
expectedLeft = 0,
expectedTop = 0,
expectedBottomPadding = 4
)
}
@Test
fun `large card 순위 변화 표시는 Figma 위치를 유지한다`() {
val view = inflateView<CreatorRankingLargeCardView>(R.layout.view_creator_ranking_large_card)
view.setCardSize(CreatorRankingCardSize(widthPx = 374, heightPx = 374))
assertViewPosition(
view = view.findViewById(R.id.ll_creator_ranking_delta),
expectedLeft = 20,
expectedTop = 116
)
}
@Test
fun `compact small card 순위 변화 표시는 Figma 위치를 유지한다`() {
val view = inflateView<CreatorRankingCompactCardView>(R.layout.view_creator_ranking_compact_card)
view.setCardSize(CreatorRankingCardSize(widthPx = 122, heightPx = 122))
assertViewPosition(
view = view.findViewById(R.id.ll_creator_ranking_delta),
expectedLeft = 10,
expectedTop = 50
)
}
@Test
fun `large card는 순위 변동 숨김이면 delta를 숨긴다`() {
val view = inflateView<CreatorRankingLargeCardView>(R.layout.view_creator_ranking_large_card)
view.bind(sampleItem(showRankChange = false))
assertEquals(View.GONE, view.findViewById<View>(R.id.ll_creator_ranking_delta).visibility)
}
@Test
fun `large card는 순위 변동 표시이면 delta를 보여준다`() {
val view = inflateView<CreatorRankingLargeCardView>(R.layout.view_creator_ranking_large_card)
view.bind(sampleItem(showRankChange = true))
assertEquals(View.VISIBLE, view.findViewById<View>(R.id.ll_creator_ranking_delta).visibility)
}
@Test
fun `compact card는 순위 변동 숨김이면 delta를 숨긴다`() {
val view = inflateView<CreatorRankingCompactCardView>(R.layout.view_creator_ranking_compact_card)
view.bind(sampleItem(showRankChange = false))
assertEquals(View.GONE, view.findViewById<View>(R.id.ll_creator_ranking_delta).visibility)
}
@Test
fun `compact card는 순위 변동 표시이면 delta를 보여준다`() {
val view = inflateView<CreatorRankingCompactCardView>(R.layout.view_creator_ranking_compact_card)
view.bind(sampleItem(showRankChange = true))
assertEquals(View.VISIBLE, view.findViewById<View>(R.id.ll_creator_ranking_delta).visibility)
}
@Test
fun `horizontal card는 순위 변동 숨김이면 delta를 숨긴다`() {
val view = inflateView<CreatorRankingHorizontalCardView>(R.layout.view_creator_ranking_horizontal_card)
view.bind(sampleItem(rank = 11, showRankChange = false))
assertEquals(View.GONE, view.findViewById<View>(R.id.ll_creator_ranking_delta).visibility)
}
@Test
fun `horizontal card는 순위 변동 표시이면 delta를 보여준다`() {
val view = inflateView<CreatorRankingHorizontalCardView>(R.layout.view_creator_ranking_horizontal_card)
view.bind(sampleItem(rank = 11, showRankChange = true))
assertEquals(View.VISIBLE, view.findViewById<View>(R.id.ll_creator_ranking_delta).visibility)
}
private fun assertViewPosition(
view: View,
expectedLeft: Int,
expectedTop: Int
) {
val params = view.layoutParams as ViewGroup.MarginLayoutParams
assertEquals(expectedLeft, params.leftMargin)
assertEquals(expectedTop, params.topMargin)
}
private fun assertRankTextBox(
view: TextView,
expectedWidth: Int,
expectedHeight: Int,
expectedLeft: Int,
expectedTop: Int,
expectedBottomPadding: Int
) {
val params = view.layoutParams as ViewGroup.MarginLayoutParams
assertEquals(expectedWidth, params.width)
assertEquals(expectedHeight, params.height)
assertEquals(expectedLeft, params.leftMargin)
assertEquals(expectedTop, params.topMargin)
assertEquals(Gravity.CENTER, view.gravity)
assertEquals(false, view.includeFontPadding)
assertEquals(expectedBottomPadding, view.paddingBottom)
}
private inline fun <reified T : View> inflateView(layoutResId: Int): T {
val context = ApplicationProvider.getApplicationContext<Context>()
return LayoutInflater.from(context).inflate(layoutResId, null, false) as T
}
private fun sampleItem(
rank: Int = 1,
showRankChange: Boolean = true
) = CreatorRankingItem(
creatorId = 1L,
rank = rank,
rankChangeType = Increase,
rankChangeAmount = 4,
creatorName = "크리에이터 이름",
imageUrl = "https://example.com/image.png",
isBlocked = false,
showRankChange = showRankChange
)
}