feat(home): 응원 크리에이터 카드 바인딩을 추가한다

This commit is contained in:
2026-06-04 20:18:06 +09:00
parent d5f46e6325
commit edf4a94494
4 changed files with 217 additions and 17 deletions

View File

@@ -30,7 +30,6 @@ import kr.co.vividnext.sodalive.v2.main.home.ui.HomeAiCharacterAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBannerBinder import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBannerBinder
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeCheerCreatorAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeCheerCreatorAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFirstAudioAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFirstAudioAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFollowAllButtonBinder
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeGenreCreatorAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeGenreCreatorAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeLiveAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeLiveAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeRecentActivityCreatorAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeRecentActivityCreatorAdapter
@@ -47,7 +46,7 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
private val firstAudioAdapter = HomeFirstAudioAdapter() private val firstAudioAdapter = HomeFirstAudioAdapter()
private val aiCharacterAdapter = HomeAiCharacterAdapter() private val aiCharacterAdapter = HomeAiCharacterAdapter()
private val genreCreatorAdapter = HomeGenreCreatorAdapter { creatorIds -> onGenreFollowAllClick(creatorIds) } private val genreCreatorAdapter = HomeGenreCreatorAdapter { creatorIds -> onGenreFollowAllClick(creatorIds) }
private val cheerCreatorAdapter = HomeCheerCreatorAdapter() private val cheerCreatorAdapter = HomeCheerCreatorAdapter { creatorIds -> onCheerFollowAllClick(creatorIds) }
private var bannerBinder: HomeBannerBinder? = null private var bannerBinder: HomeBannerBinder? = null
private var onGenreFollowAllClick: (List<Long>) -> Unit = {} private var onGenreFollowAllClick: (List<Long>) -> Unit = {}
private var onCheerFollowAllClick: (List<Long>) -> Unit = {} private var onCheerFollowAllClick: (List<Long>) -> Unit = {}
@@ -135,13 +134,7 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
private fun bindCheerCreatorSection(section: HomeRecommendationCheerCreatorSection) { private fun bindCheerCreatorSection(section: HomeRecommendationCheerCreatorSection) {
binding.llHomeCheerCreatorSection.visibility = section.items.toSectionVisibility() binding.llHomeCheerCreatorSection.visibility = section.items.toSectionVisibility()
cheerCreatorAdapter.submitItems(section.items) cheerCreatorAdapter.submitSection(section.items, section.isFollowCompleted)
HomeFollowAllButtonBinder.bind(
view = binding.viewHomeCheerFollowAll.root,
creatorIds = section.items.map { it.creatorId },
isFollowCompleted = section.isFollowCompleted,
onClick = onCheerFollowAllClick
)
} }
private fun bindPopularCommunitySection(section: HomeRecommendationPopularCommunityPostSection) { private fun bindPopularCommunitySection(section: HomeRecommendationPopularCommunityPostSection) {
@@ -247,7 +240,7 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
HomeRecommendationGenreCreatorGroupUiModel("판타지", sampleCreators(701L, count = 8)) HomeRecommendationGenreCreatorGroupUiModel("판타지", sampleCreators(701L, count = 8))
) )
), ),
cheerCreators = HomeRecommendationCheerCreatorSection(sampleCreators(701L)), cheerCreators = HomeRecommendationCheerCreatorSection(sampleCreators(701L, count = 8)),
popularCommunityPosts = HomeRecommendationPopularCommunityPostSection(emptyList()) popularCommunityPosts = HomeRecommendationPopularCommunityPostSection(emptyList())
) )

View File

@@ -1,3 +1,136 @@
package kr.co.vividnext.sodalive.v2.main.home.ui package kr.co.vividnext.sodalive.v2.main.home.ui
class HomeCheerCreatorAdapter : HomeCreatorProfileAdapter() import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.GridLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCreatorUiModel
class HomeCheerCreatorAdapter(
private val onFollowAllClick: (List<Long>) -> Unit
) : RecyclerView.Adapter<HomeCheerCreatorAdapter.CheerCreatorGroupViewHolder>() {
private var creators: List<HomeRecommendationCreatorUiModel> = emptyList()
private var isFollowCompleted: Boolean = false
fun submitSection(
creators: List<HomeRecommendationCreatorUiModel>,
isFollowCompleted: Boolean
) {
this.creators = creators
this.isFollowCompleted = isFollowCompleted
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CheerCreatorGroupViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_home_cheer_creator_group, parent, false)
view.layoutParams = cheerGroupLayoutParams(parent)
return CheerCreatorGroupViewHolder(view, parent, onFollowAllClick)
}
override fun onBindViewHolder(holder: CheerCreatorGroupViewHolder, position: Int) {
holder.bind(creators, isFollowCompleted)
}
override fun getItemCount(): Int = if (creators.isEmpty()) 0 else 1
class CheerCreatorGroupViewHolder(
itemView: View,
private val parent: ViewGroup,
private val onFollowAllClick: (List<Long>) -> Unit
) : RecyclerView.ViewHolder(itemView) {
private val creatorGrid = itemView.findViewById<GridLayout>(R.id.gl_home_cheer_creator_profiles)
private val followAllButton = itemView.findViewById<View>(R.id.view_home_cheer_group_follow_all)
fun bind(creators: List<HomeRecommendationCreatorUiModel>, isFollowCompleted: Boolean) {
val visibleCreators = creators.take(CREATOR_COUNT_PER_GROUP)
bindCreators(visibleCreators, calculateProfileSize())
creatorGrid.post {
bindCreators(visibleCreators, calculateProfileSize())
}
HomeFollowAllButtonBinder.bind(
view = followAllButton,
creatorIds = creators.map { it.creatorId },
isFollowCompleted = isFollowCompleted,
onClick = onFollowAllClick
)
}
private fun calculateProfileSize(): Int {
val gridWidth = creatorGrid.width.takeIf { it > 0 }
?: (resolvedCardWidth() - itemView.paddingStart - itemView.paddingEnd)
val totalColumnGap = itemView.resources.getDimensionPixelSize(R.dimen.spacing_14) * (GRID_COLUMN_COUNT - 1)
return (gridWidth - totalColumnGap) / GRID_COLUMN_COUNT
}
private fun resolvedCardWidth(): Int {
val parentContentWidth = parentContentWidth()
itemView.width.takeIf { it > 0 }?.let { return minOf(it, parentContentWidth) }
itemView.layoutParams.width.takeIf { it > 0 }?.let { return minOf(it, parentContentWidth) }
parentContentWidth.takeIf { it > 0 }?.let { return it }
return itemView.resources.displayMetrics.widthPixels - parentHorizontalInset()
}
private fun parentContentWidth(): Int {
return parent.width - parentHorizontalInset()
}
private fun parentHorizontalInset(): Int {
val paddingInset = parent.paddingStart + parent.paddingEnd
return paddingInset.takeIf { it > 0 } ?: itemView.resources.getDimensionPixelSize(R.dimen.spacing_28)
}
private fun bindCreators(creators: List<HomeRecommendationCreatorUiModel>, profileSize: Int) {
creatorGrid.removeAllViews()
creators.forEachIndexed { index, creator ->
val profileView = LayoutInflater.from(creatorGrid.context).inflate(
R.layout.item_home_genre_creator_profile,
creatorGrid,
false
)
val row = index / GRID_COLUMN_COUNT
val column = index % GRID_COLUMN_COUNT
profileView.layoutParams = GridLayout.LayoutParams(
GridLayout.spec(row),
GridLayout.spec(column)
).apply {
width = profileSize
height = ViewGroup.LayoutParams.WRAP_CONTENT
if (column < GRID_COLUMN_COUNT - 1) {
marginEnd = profileView.resources.getDimensionPixelSize(R.dimen.spacing_14)
}
if (row > 0) {
topMargin = profileView.resources.getDimensionPixelSize(R.dimen.spacing_14)
}
}
profileView.findViewById<ImageView>(R.id.iv_home_genre_creator_profile).apply {
if (creator.profileImage == null) {
setImageDrawable(null)
} else {
loadUrl(creator.profileImage)
}
}
profileView.findViewById<TextView>(R.id.tv_home_genre_creator_profile_nickname).text = creator.nickname
creatorGrid.addView(profileView)
}
}
}
companion object {
private const val CREATOR_COUNT_PER_GROUP = 8
private const val GRID_COLUMN_COUNT = 4
}
}
private fun cheerGroupLayoutParams(parent: ViewGroup): RecyclerView.LayoutParams {
val paddingInset = parent.paddingStart + parent.paddingEnd
val horizontalInset = paddingInset.takeIf { it > 0 } ?: parent.resources.getDimensionPixelSize(R.dimen.spacing_28)
val width = (parent.width - horizontalInset).takeIf { it > 0 } ?: ViewGroup.LayoutParams.MATCH_PARENT
return RecyclerView.LayoutParams(width, ViewGroup.LayoutParams.WRAP_CONTENT)
}

View File

@@ -189,14 +189,10 @@
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_home_cheer_creators" android:id="@+id/rv_home_cheer_creators"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="160dp" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_20" android:paddingHorizontal="@dimen/spacing_14"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" /> app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
<include
android:id="@+id/view_home_cheer_follow_all"
layout="@layout/view_home_follow_all_button" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout

View File

@@ -25,6 +25,7 @@ import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreato
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveUiModel
import kr.co.vividnext.sodalive.v2.main.home.model.visibleHomeGenreCreatorGroups import kr.co.vividnext.sodalive.v2.main.home.model.visibleHomeGenreCreatorGroups
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeAiCharacterAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeAiCharacterAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeCheerCreatorAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFirstAudioAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFirstAudioAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFollowAllButtonBinder import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFollowAllButtonBinder
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeGenreCreatorAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeGenreCreatorAdapter
@@ -293,6 +294,10 @@ class HomeMainFragmentLayoutTest {
assertEquals(4, creatorGrid.columnCount) assertEquals(4, creatorGrid.columnCount)
assertEquals(2, creatorGrid.rowCount) assertEquals(2, creatorGrid.rowCount)
assertNotNull(followAllButton) assertNotNull(followAllButton)
val followAllLayoutParams = followAllButton.layoutParams as ViewGroup.MarginLayoutParams
assertEquals(0, followAllLayoutParams.marginStart)
assertEquals(14.dpToPx(), followAllLayoutParams.topMargin)
assertEquals(0, followAllLayoutParams.marginEnd)
} }
@Test @Test
@@ -473,6 +478,79 @@ class HomeMainFragmentLayoutTest {
assertEquals((10L..17L).toList(), clickedCreatorIds) assertEquals((10L..17L).toList(), clickedCreatorIds)
} }
@Test
fun `cheer creator group item matches figma card structure`() {
val group = inflateViewWithParent(R.layout.item_home_cheer_creator_group)
val creatorGrid = group.findViewById<GridLayout>(R.id.gl_home_cheer_creator_profiles)
val followAllButton = group.findViewById<View>(R.id.view_home_cheer_group_follow_all)
assertEquals(ViewGroup.LayoutParams.MATCH_PARENT, group.layoutParams.width)
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, group.layoutParams.height)
assertEquals(R.drawable.bg_home_cheer_creator_group, shadowOf(group.background).createdFromResId)
assertEquals(14.dpToPx(), group.paddingStart)
assertEquals(14.dpToPx(), group.paddingTop)
assertEquals(14.dpToPx(), group.paddingEnd)
assertEquals(14.dpToPx(), group.paddingBottom)
assertEquals(ViewGroup.LayoutParams.MATCH_PARENT, creatorGrid.layoutParams.width)
assertEquals(4, creatorGrid.columnCount)
assertEquals(2, creatorGrid.rowCount)
assertNotNull(followAllButton)
}
@Test
fun `home cheer creator section uses full width card with horizontal padding`() {
val root = inflateView(R.layout.fragment_v2_main_home)
val cheerList = root.findViewById<RecyclerView>(R.id.rv_home_cheer_creators)
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, cheerList.layoutParams.height)
assertEquals(14.dpToPx(), cheerList.paddingStart)
assertEquals(14.dpToPx(), cheerList.paddingEnd)
}
@Test
fun `cheer creator adapter sizes card to parent width minus horizontal padding`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val parent = RecyclerView(context)
parent.setPadding(14.dpToPx(), 0, 14.dpToPx(), 0)
parent.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
parent.layout(0, 0, 402.dpToPx(), 600.dpToPx())
val viewHolder = HomeCheerCreatorAdapter(onFollowAllClick = {}).onCreateViewHolder(parent, 0)
assertEquals(374.dpToPx(), viewHolder.itemView.layoutParams.width)
}
@Test
fun `cheer creator adapter renders max eight creators`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val parent = RecyclerView(context)
parent.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
val adapter = HomeCheerCreatorAdapter(onFollowAllClick = {})
adapter.submitSection(genreCreators(1L) + genreCreators(11L), isFollowCompleted = false)
val viewHolder = adapter.onCreateViewHolder(parent, 0)
adapter.onBindViewHolder(viewHolder, 0)
assertEquals(1, adapter.itemCount)
assertEquals(8, viewHolder.itemView.findViewById<GridLayout>(R.id.gl_home_cheer_creator_profiles).childCount)
}
@Test
fun `cheer creator adapter follow all uses all cheer creator ids`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val parent = RecyclerView(context)
parent.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
val clickedCreatorIds = mutableListOf<Long>()
val adapter = HomeCheerCreatorAdapter(onFollowAllClick = { clickedCreatorIds.addAll(it) })
adapter.submitSection(genreCreators(10L), isFollowCompleted = false)
val viewHolder = adapter.onCreateViewHolder(parent, 0)
adapter.onBindViewHolder(viewHolder, 0)
viewHolder.itemView.findViewById<View>(R.id.view_home_cheer_group_follow_all).performClick()
assertEquals((10L..17L).toList(), clickedCreatorIds)
}
@Test @Test
fun `genre creator visible groups hide all empty groups before fragment visibility`() { fun `genre creator visible groups hide all empty groups before fragment visibility`() {
val section = HomeRecommendationGenreCreatorSection( val section = HomeRecommendationGenreCreatorSection(