feat(feed): 피드 어댑터와 뷰 테스트를 추가한다

This commit is contained in:
2026-05-21 15:53:40 +09:00
parent 59ea5de00a
commit a8e0f2377d
2 changed files with 244 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
package kr.co.vividnext.sodalive.v2.widget.feed
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kotlin.math.roundToInt
class FeedAdapter(
private val widthMode: FeedWidthMode = FeedWidthMode.FigmaFixed,
private val horizontalItemDecorationDp: Int = 0,
private val onClickItem: (FeedItem) -> Unit,
private val onBindImages: (FeedImageViews, FeedItem) -> Unit = { _, _ -> }
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val items = mutableListOf<FeedItem>()
override fun getItemViewType(position: Int): Int = when (items[position].variant) {
FeedVariant.Rank -> VIEW_TYPE_RANK
FeedVariant.Live -> VIEW_TYPE_LIVE
FeedVariant.Content -> VIEW_TYPE_CONTENT
FeedVariant.Community -> VIEW_TYPE_COMMUNITY
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_RANK -> RankViewHolder(inflater.inflate(R.layout.view_feed_rank, parent, false) as FeedRankView, parent)
VIEW_TYPE_LIVE -> LiveViewHolder(inflater.inflate(R.layout.view_feed_live, parent, false) as FeedLiveView, parent)
VIEW_TYPE_CONTENT -> ContentViewHolder(inflater.inflate(R.layout.view_feed_content, parent, false) as FeedContentView, parent)
VIEW_TYPE_COMMUNITY -> CommunityViewHolder(inflater.inflate(R.layout.view_feed_community, parent, false) as FeedCommunityView, parent)
else -> error("Unknown viewType: $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) {
is FeedItem.Rank -> (holder as RankViewHolder).bind(item)
is FeedItem.Live -> (holder as LiveViewHolder).bind(item)
is FeedItem.Content -> (holder as ContentViewHolder).bind(item)
is FeedItem.Community -> (holder as CommunityViewHolder).bind(item)
}
}
override fun getItemCount(): Int = items.size
fun submitItems(items: List<FeedItem>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
private inner class RankViewHolder(
private val view: FeedRankView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: FeedItem.Rank) {
view.setFeedSize(calculateSize(item.variant, parent))
view.bind(item)
view.setOnFeedClick(onClickItem)
onBindImages(FeedImageViews(primary = view.imageView()), item)
}
}
private inner class LiveViewHolder(
private val view: FeedLiveView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: FeedItem.Live) {
view.setFeedSize(calculateSize(item.variant, parent))
view.bind(item)
view.setOnFeedClick(onClickItem)
onBindImages(FeedImageViews(profile = view.profileImageView()), item)
}
}
private inner class ContentViewHolder(
private val view: FeedContentView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: FeedItem.Content) {
view.setFeedSize(calculateSize(item.variant, parent))
view.bind(item)
view.setOnFeedClick(onClickItem)
onBindImages(FeedImageViews(primary = view.contentImageView(), profile = view.profileImageView()), item)
}
}
private inner class CommunityViewHolder(
private val view: FeedCommunityView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: FeedItem.Community) {
view.setFeedSize(calculateSize(item.variant, parent))
view.bind(item)
view.setOnFeedClick(onClickItem)
onBindImages(FeedImageViews(profile = view.profileImageView()), item)
}
}
private fun calculateSize(variant: FeedVariant, parent: ViewGroup): FeedSize {
val parentWidthPx = parent.width.takeIf { it > 0 } ?: parent.resources.displayMetrics.widthPixels
val availableWidthPx = parentWidthPx - parent.paddingLeft - parent.paddingRight
val availableWidthDp = (availableWidthPx / parent.resources.displayMetrics.density).roundToInt()
return FeedSize.from(variant, widthMode, availableWidthDp, horizontalItemDecorationDp)
}
companion object {
private const val VIEW_TYPE_RANK = 1
private const val VIEW_TYPE_LIVE = 2
private const val VIEW_TYPE_CONTENT = 3
private const val VIEW_TYPE_COMMUNITY = 4
}
}
data class FeedImageViews(
val primary: ImageView? = null,
val profile: ImageView? = null
)

View File

@@ -0,0 +1,124 @@
package kr.co.vividnext.sodalive.v2.widget.feed
import android.app.Application
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
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 FeedViewTest {
@Test
fun `live bind displays server provided ended message in one constrained text view`() {
val view = inflateView<FeedLiveView>(R.layout.view_feed_live)
val item = sampleLiveItem(endedMessage = "서버에서 내려온 라이브 종료 문구")
view.bind(item)
assertEquals("서버에서 내려온 라이브 종료 문구", view.endedMessageTextView().text.toString())
}
@Test
fun `content category tag and time share bottom meta row`() {
val view = inflateView<FeedContentView>(R.layout.view_feed_content)
val category = view.findViewById<TextView>(R.id.tv_feed_content_category)
val time = view.findViewById<TextView>(R.id.tv_feed_content_created_at)
assertSame(view.findViewById<View>(R.id.ll_feed_content_meta), category.parent)
assertSame(category.parent, time.parent)
}
@Test
fun `community can hide empty body and keyword rows by caller policy`() {
val view = inflateView<FeedCommunityView>(R.layout.view_feed_community)
view.setHideEmptyTextRows(true)
view.bind(sampleCommunityItem(bodyText = "", keywordText = ""))
assertEquals(View.GONE, view.findViewById<TextView>(R.id.tv_feed_community_body).visibility)
assertEquals(View.GONE, view.findViewById<TextView>(R.id.tv_feed_community_keyword).visibility)
}
@Test
fun `rank overlay uses figma overflow position`() {
val view = inflateView<FeedRankView>(R.layout.view_feed_rank)
val imageContainer = view.findViewById<FrameLayout>(R.id.fl_feed_rank_image_container)
val overlay = view.findViewById<TextView>(R.id.tv_feed_rank_overlay)
val params = overlay.layoutParams as FrameLayout.LayoutParams
assertFalse(imageContainer.clipChildren)
assertFalse(imageContainer.clipToPadding)
assertEquals((-6).dpToPx(), params.marginEnd)
assertEquals((-22).dpToPx(), params.bottomMargin)
}
@Test
fun `feed content images expose simple accessibility descriptions`() {
val rankView = inflateView<FeedRankView>(R.layout.view_feed_rank)
val liveView = inflateView<FeedLiveView>(R.layout.view_feed_live)
val contentView = inflateView<FeedContentView>(R.layout.view_feed_content)
val communityView = inflateView<FeedCommunityView>(R.layout.view_feed_community)
assertNotEmptyContentDescription(rankView.findViewById(R.id.iv_feed_rank_image))
assertNotEmptyContentDescription(liveView.findViewById(R.id.iv_feed_live_profile))
assertNotEmptyContentDescription(contentView.findViewById(R.id.iv_feed_content_image))
assertNotEmptyContentDescription(contentView.findViewById(R.id.iv_feed_content_profile))
assertNotEmptyContentDescription(communityView.findViewById(R.id.iv_feed_community_profile))
}
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 sampleLiveItem(endedMessage: String) = FeedItem.Live(
feedId = "feed-live-1",
creatorId = "creator-1",
creatorName = "크리에이터이름",
creatorImageUrl = "https://example.com/profile.png",
liveId = "live-1",
liveTitle = "라이브 방송 이름",
createdAtText = "2분 전",
endedMessage = endedMessage
)
private fun sampleCommunityItem(
bodyText: String,
keywordText: String
) = FeedItem.Community(
feedId = "feed-community-1",
creatorId = "creator-1",
creatorName = "크리에이터 이름",
creatorImageUrl = "https://example.com/profile.png",
postId = "post-1",
bodyText = bodyText,
keywordText = keywordText,
createdAtText = "2분 전",
commentCount = 5,
likeCount = 6
)
private fun Int.dpToPx(): Int {
val context = ApplicationProvider.getApplicationContext<Context>()
return (this * context.resources.displayMetrics.density).toInt()
}
private fun assertNotEmptyContentDescription(imageView: ImageView) {
assertTrue(!imageView.contentDescription.isNullOrEmpty())
}
}