From fe509365e2efd39f49c4086071091d9bd4c35a1f Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 28 May 2026 00:08:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(banner):=20=EB=B0=B0=EB=84=88=20=EC=96=B4?= =?UTF-8?q?=EB=8C=91=ED=84=B0=EC=99=80=20=EB=B7=B0=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../v2/widget/banner/BannerAdapter.kt | 88 +++++++++ .../sodalive/v2/widget/banner/BannerView.kt | 133 ++++++++++++++ .../v2/widget/banner/BannerViewTest.kt | 167 ++++++++++++++++++ 3 files changed, 388 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerAdapter.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerAdapter.kt new file mode 100644 index 00000000..21de937f --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerAdapter.kt @@ -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() { + + private val items = mutableListOf() + 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) { + 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 + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt new file mode 100644 index 00000000..0779a425 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt @@ -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 = 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) { + 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 + } + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt new file mode 100644 index 00000000..89619b35 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt @@ -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() + 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(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(R.id.layout_banner_counter).visibility) + } + + @Test + fun `배너 view는 코드로 생성해도 내부 layout을 포함한다`() { + val context = ApplicationProvider.getApplicationContext() + val view = BannerView(context) + + view.setItems(listOf(sampleItem("1"))) + + assertNotNull(view.findViewById(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(R.id.layout_banner_counter).visibility) + assertEquals("01", view.findViewById(R.id.tv_banner_current_index).text.toString()) + assertEquals("/", view.findViewById(R.id.tv_banner_counter_separator).text.toString()) + assertEquals("02", view.findViewById(R.id.tv_banner_total_count).text.toString()) + } + + @Test + fun `배너 view는 정사각형 item 크기와 좌우 padding 및 간격을 적용한다`() { + val view = inflateBannerView() + val recyclerView = view.findViewById(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(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() + val adapter = BannerAdapter() + adapter.submitItems(listOf(sampleItem("1"))) + + val holder = adapter.onCreateViewHolder(FrameLayout(context), 0) + adapter.onBindViewHolder(holder, 0) + val imageView = holder.itemView.findViewById(R.id.iv_banner) + + assertTrue(imageView.clipToOutline) + assertNotNull(imageView.outlineProvider) + } + + private fun inflateBannerView(): BannerView { + val context = ApplicationProvider.getApplicationContext() + 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() + return (this * context.resources.displayMetrics.density).toInt() + } +}