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
}
}
}