diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt index 06396e5e..00181137 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt @@ -185,6 +185,9 @@ class HomeMainFragment : BaseFragment( private fun setUpCreatorRankingAdapter() { binding.rvHomeCreatorRankings.apply { layoutManager = CreatorRankingAdapter.createGridLayoutManager(requireContext()) + if (itemDecorationCount == 0) { + addItemDecoration(CreatorRankingAdapter.createItemDecoration(requireContext())) + } adapter = creatorRankingAdapter } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapter.kt index a545d28c..7efbbf60 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapter.kt @@ -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() - 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 + } } } diff --git a/app/src/main/res/layout/fragment_v2_main_home.xml b/app/src/main/res/layout/fragment_v2_main_home.xml index 326ce589..815d07f4 100644 --- a/app/src/main/res/layout/fragment_v2_main_home.xml +++ b/app/src/main/res/layout/fragment_v2_main_home.xml @@ -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" /> diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt index d7b63ee4..5143e54c 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt @@ -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(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")) } diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapterLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapterLayoutTest.kt index 3399d32a..c3ba489f 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapterLayoutTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapterLayoutTest.kt @@ -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(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(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(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(R.layout.view_creator_ranking_horizontal_card) - - view.setCardSize(CreatorRankingCardSize(widthPx = 374, heightPx = 100)) - - val rankGroup = view.findViewById(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(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(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(R.layout.view_creator_ranking_large_card) - - view.bind(sampleItem(showRankChange = false)) - - assertEquals(View.GONE, view.findViewById(R.id.ll_creator_ranking_delta).visibility) - } - - @Test - fun `large card는 순위 변동 표시이면 delta를 보여준다`() { - val view = inflateView(R.layout.view_creator_ranking_large_card) - - view.bind(sampleItem(showRankChange = true)) - - assertEquals(View.VISIBLE, view.findViewById(R.id.ll_creator_ranking_delta).visibility) - } - - @Test - fun `compact card는 순위 변동 숨김이면 delta를 숨긴다`() { - val view = inflateView(R.layout.view_creator_ranking_compact_card) - - view.bind(sampleItem(showRankChange = false)) - - assertEquals(View.GONE, view.findViewById(R.id.ll_creator_ranking_delta).visibility) - } - - @Test - fun `compact card는 순위 변동 표시이면 delta를 보여준다`() { - val view = inflateView(R.layout.view_creator_ranking_compact_card) - - view.bind(sampleItem(showRankChange = true)) - - assertEquals(View.VISIBLE, view.findViewById(R.id.ll_creator_ranking_delta).visibility) - } - - @Test - fun `horizontal card는 순위 변동 숨김이면 delta를 숨긴다`() { - val view = inflateView(R.layout.view_creator_ranking_horizontal_card) - - view.bind(sampleItem(rank = 11, showRankChange = false)) - - assertEquals(View.GONE, view.findViewById(R.id.ll_creator_ranking_delta).visibility) - } - - @Test - fun `horizontal card는 순위 변동 표시이면 delta를 보여준다`() { - val view = inflateView(R.layout.view_creator_ranking_horizontal_card) - - view.bind(sampleItem(rank = 11, showRankChange = true)) - - assertEquals(View.VISIBLE, view.findViewById(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 inflateView(layoutResId: Int): T { - val context = ApplicationProvider.getApplicationContext() - 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 - ) }