feat(banner): 배너 어댑터와 뷰를 추가한다

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-28 00:08:41 +09:00
parent 2e5af796e4
commit fe509365e2
3 changed files with 388 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
package kr.co.vividnext.sodalive.v2.widget.banner
import android.graphics.Outline
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
class BannerAdapter(
private var onClickItem: ((BannerItem) -> Unit)? = null,
private var onBindImage: ((ImageView, BannerItem) -> Unit)? = null
) : RecyclerView.Adapter<BannerAdapter.BannerViewHolder>() {
private val items = mutableListOf<BannerItem>()
private var itemSizePx: Int = ViewGroup.LayoutParams.MATCH_PARENT
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BannerViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_banner, parent, false)
return BannerViewHolder(view)
}
override fun onBindViewHolder(holder: BannerViewHolder, position: Int) {
holder.bind(items[toRealIndex(position)], itemSizePx)
}
override fun getItemCount(): Int = when (items.size) {
0, 1 -> items.size
else -> VIRTUAL_ITEM_COUNT
}
fun submitItems(items: List<BannerItem>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
fun setItemSizePx(itemSizePx: Int) {
this.itemSizePx = itemSizePx
notifyDataSetChanged()
}
fun setOnClickItem(listener: ((BannerItem) -> Unit)?) {
onClickItem = listener
}
fun setOnBindImage(listener: ((ImageView, BannerItem) -> Unit)?) {
onBindImage = listener
}
fun toRealIndex(position: Int): Int = if (items.isEmpty()) 0 else position % items.size
inner class BannerViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val imageView: ImageView = itemView.findViewById(R.id.iv_banner)
fun bind(item: BannerItem, itemSizePx: Int) {
itemView.layoutParams = (itemView.layoutParams ?: ViewGroup.LayoutParams(itemSizePx, itemSizePx)).apply {
width = itemSizePx
height = itemSizePx
}
imageView.layoutParams = (imageView.layoutParams ?: ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)).apply {
width = ViewGroup.LayoutParams.MATCH_PARENT
height = ViewGroup.LayoutParams.MATCH_PARENT
}
setRadiusClipping(imageView)
onBindImage?.invoke(imageView, item)
itemView.setOnClickListener { onClickItem?.invoke(item) }
}
}
private fun setRadiusClipping(view: View) {
view.clipToOutline = true
view.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, view.resources.getDimension(R.dimen.radius_14))
}
}
}
private companion object {
const val VIRTUAL_ITEM_COUNT = Int.MAX_VALUE
}
}

View File

@@ -0,0 +1,133 @@
package kr.co.vividnext.sodalive.v2.widget.banner
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kotlin.math.roundToInt
class BannerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var recyclerView: RecyclerView? = null
private var counterContainer: View? = null
private var currentIndexText: TextView? = null
private var separatorText: TextView? = null
private var totalCountText: TextView? = null
private val adapter = BannerAdapter()
private val snapHelper = PagerSnapHelper()
private var items: List<BannerItem> = emptyList()
private var currentIndex: Int = 0
private var itemSpacingPx: Int = 0
private var spacingDecoration: RecyclerView.ItemDecoration? = null
init {
clipToPadding = false
clipChildren = false
LayoutInflater.from(context).inflate(R.layout.view_banner, this, true)
recyclerView = findViewById(R.id.rv_banner)
counterContainer = findViewById(R.id.layout_banner_counter)
currentIndexText = findViewById(R.id.tv_banner_current_index)
separatorText = findViewById(R.id.tv_banner_counter_separator)
totalCountText = findViewById(R.id.tv_banner_total_count)
setUpRecyclerView()
updateCounter()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
if (w > 0) applyLayoutSize(w)
}
fun setItems(items: List<BannerItem>) {
this.items = items
currentIndex = BannerState.from(items.size, currentIndex).currentIndex
visibility = if (items.isEmpty()) GONE else VISIBLE
adapter.submitItems(items)
if (width > 0) applyLayoutSize(width)
updateCounter()
}
fun setOnBannerClickListener(listener: ((BannerItem) -> Unit)?) {
adapter.setOnClickItem(listener)
}
fun setOnBindBannerImage(listener: ((ImageView, BannerItem) -> Unit)?) {
adapter.setOnBindImage(listener)
}
private fun setUpRecyclerView() {
requireNotNull(recyclerView).apply {
layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
adapter = this@BannerView.adapter
clipToPadding = false
clipChildren = false
if (onFlingListener == null) snapHelper.attachToRecyclerView(this)
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_IDLE) updateCurrentIndexFromSnap()
}
})
}
}
private fun applyLayoutSize(widthPx: Int) {
val density = resources.displayMetrics.density
val screenWidthDp = (widthPx / density).roundToInt()
val size = BannerLayoutCalculator.calculate(screenWidthDp, density)
val itemSizePx = size.itemWidthDp.dpToPx()
itemSpacingPx = if (items.size > 1) size.itemSpacingDp.dpToPx() else 0
adapter.setItemSizePx(itemSizePx)
requireNotNull(recyclerView).apply {
setPadding(size.sideInsetDp.dpToPx(), 0, size.sideInsetDp.dpToPx(), 0)
spacingDecoration?.let(::removeItemDecoration)
spacingDecoration = BannerSpacingDecoration(itemSpacingPx).also(::addItemDecoration)
}
}
private fun updateCurrentIndexFromSnap() {
val layoutManager = requireNotNull(recyclerView).layoutManager ?: return
val snapView = snapHelper.findSnapView(layoutManager) ?: return
val position = layoutManager.getPosition(snapView)
currentIndex = adapter.toRealIndex(position)
updateCounter()
}
private fun updateCounter() {
val state = BannerState.from(items.size, currentIndex)
val showCounter = state.displayMode == BannerDisplayMode.Carousel
counterContainer?.visibility = if (showCounter) VISIBLE else GONE
if (!showCounter) return
val formatted = BannerCounterFormatter.format(state.currentIndex, items.size)
val parts = formatted.split(" ")
currentIndexText?.text = parts[0]
separatorText?.text = parts[1]
totalCountText?.text = parts[2]
}
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
private class BannerSpacingDecoration(
private val spacingPx: Int
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: android.graphics.Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
outRect.right = spacingPx
}
}
}

View File

@@ -0,0 +1,167 @@
package kr.co.vividnext.sodalive.v2.widget.banner
import android.app.Application
import android.content.Context
import android.graphics.Rect
import android.view.View
import android.view.View.MeasureSpec
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
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 BannerViewTest {
@Test
fun `adapter는 빈 목록과 단일 목록에서 실제 아이템 개수를 사용한다`() {
val adapter = BannerAdapter()
adapter.submitItems(emptyList())
assertEquals(0, adapter.itemCount)
adapter.submitItems(listOf(sampleItem("1")))
assertEquals(1, adapter.itemCount)
}
@Test
fun `adapter는 가상 위치를 실제 아이템으로 변환하고 이미지 바인딩과 클릭 콜백을 전달한다`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val parent = FrameLayout(context)
val items = listOf(sampleItem("1"), sampleItem("2"))
var boundImageView: ImageView? = null
var boundItem: BannerItem? = null
var clickedItem: BannerItem? = null
val adapter = BannerAdapter(
onClickItem = { clickedItem = it },
onBindImage = { imageView, item ->
boundImageView = imageView
boundItem = item
}
)
adapter.submitItems(items)
val holder = adapter.onCreateViewHolder(parent, 0)
adapter.onBindViewHolder(holder, 3)
holder.itemView.performClick()
assertTrue(adapter.itemCount > items.size)
assertSame(holder.itemView.findViewById<ImageView>(R.id.iv_banner), boundImageView)
assertEquals(items[1], boundItem)
assertEquals(items[1], clickedItem)
}
@Test
fun `배너 view는 빈 목록이면 숨기고 단일 목록이면 counter를 숨긴다`() {
val view = inflateBannerView()
view.setItems(emptyList())
assertEquals(View.GONE, view.visibility)
view.setItems(listOf(sampleItem("1")))
assertEquals(View.VISIBLE, view.visibility)
assertEquals(View.GONE, view.findViewById<View>(R.id.layout_banner_counter).visibility)
}
@Test
fun `배너 view는 코드로 생성해도 내부 layout을 포함한다`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val view = BannerView(context)
view.setItems(listOf(sampleItem("1")))
assertNotNull(view.findViewById<RecyclerView>(R.id.rv_banner))
assertEquals(View.VISIBLE, view.visibility)
}
@Test
fun `배너 view는 carousel 목록이면 형식화된 counter를 표시한다`() {
val view = inflateBannerView()
view.setItems(listOf(sampleItem("1"), sampleItem("2")))
assertEquals(View.VISIBLE, view.visibility)
assertEquals(View.VISIBLE, view.findViewById<View>(R.id.layout_banner_counter).visibility)
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
assertEquals("/", view.findViewById<TextView>(R.id.tv_banner_counter_separator).text.toString())
assertEquals("02", view.findViewById<TextView>(R.id.tv_banner_total_count).text.toString())
}
@Test
fun `배너 view는 정사각형 item 크기와 좌우 padding 및 간격을 적용한다`() {
val view = inflateBannerView()
val recyclerView = view.findViewById<RecyclerView>(R.id.rv_banner)
view.setItems(listOf(sampleItem("1"), sampleItem("2")))
view.measure(exactly(402.dpToPx()), exactly(402.dpToPx()))
view.layout(0, 0, 402.dpToPx(), 402.dpToPx())
val holder = recyclerView.adapter!!.onCreateViewHolder(recyclerView, 0)
recyclerView.adapter!!.onBindViewHolder(holder, 0)
val itemOffset = Rect()
recyclerView.getItemDecorationAt(0).getItemOffsets(itemOffset, holder.itemView, recyclerView, RecyclerView.State())
assertEquals(20.dpToPx(), recyclerView.paddingLeft)
assertEquals(20.dpToPx(), recyclerView.paddingRight)
assertEquals(8.dpToPx(), itemOffset.right)
assertEquals(362.dpToPx(), holder.itemView.layoutParams.width)
assertEquals(362.dpToPx(), holder.itemView.layoutParams.height)
}
@Test
fun `배너 view는 단일 item이면 item 간격을 적용하지 않는다`() {
val view = inflateBannerView()
val recyclerView = view.findViewById<RecyclerView>(R.id.rv_banner)
view.setItems(listOf(sampleItem("1")))
view.measure(exactly(402.dpToPx()), exactly(402.dpToPx()))
view.layout(0, 0, 402.dpToPx(), 402.dpToPx())
val holder = recyclerView.adapter!!.onCreateViewHolder(recyclerView, 0)
recyclerView.adapter!!.onBindViewHolder(holder, 0)
val itemOffset = Rect()
recyclerView.getItemDecorationAt(0).getItemOffsets(itemOffset, holder.itemView, recyclerView, RecyclerView.State())
assertEquals(0, itemOffset.right)
}
@Test
fun `배너 item image는 radius clipping 대상으로 설정된다`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val adapter = BannerAdapter()
adapter.submitItems(listOf(sampleItem("1")))
val holder = adapter.onCreateViewHolder(FrameLayout(context), 0)
adapter.onBindViewHolder(holder, 0)
val imageView = holder.itemView.findViewById<ImageView>(R.id.iv_banner)
assertTrue(imageView.clipToOutline)
assertNotNull(imageView.outlineProvider)
}
private fun inflateBannerView(): BannerView {
val context = ApplicationProvider.getApplicationContext<Context>()
return BannerView(context)
}
private fun sampleItem(id: String) = BannerItem(
bannerId = id,
imageUrl = "https://example.com/banner-$id.png"
)
private fun exactly(size: Int): Int = MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY)
private fun Int.dpToPx(): Int {
val context = ApplicationProvider.getApplicationContext<Context>()
return (this * context.resources.displayMetrics.density).toInt()
}
}