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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user