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