From 462d9c90b5789d5396a95a437d139d34f558d386 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 28 May 2026 11:51:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(banner):=20=EB=B0=B0=EB=84=88=20preview=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=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 | 26 +++++++- .../sodalive/v2/widget/banner/BannerView.kt | 24 +++++++ .../v2/widget/banner/BannerViewTest.kt | 64 +++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) 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 index f4efc9e6..3b42cea0 100644 --- 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 @@ -16,6 +16,7 @@ class BannerAdapter( private val items = mutableListOf() private var itemSizePx: Int = ViewGroup.LayoutParams.MATCH_PARENT + private var previewImageResId: Int = 0 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BannerViewHolder { val view = LayoutInflater.from(parent.context).inflate(R.layout.item_banner, parent, false) @@ -32,14 +33,24 @@ class BannerAdapter( } fun submitItems(items: List) { + val previousItemCount = itemCount this.items.clear() this.items.addAll(items) - notifyDataSetChanged() + val newItemCount = itemCount + when { + previousItemCount == 0 && newItemCount > 0 -> notifyItemRangeInserted(0, newItemCount) + previousItemCount > 0 && newItemCount == 0 -> notifyItemRangeRemoved(0, previousItemCount) + previousItemCount == newItemCount -> notifyItemRangeChanged(0, newItemCount) + else -> { + notifyItemRangeRemoved(0, previousItemCount) + notifyItemRangeInserted(0, newItemCount) + } + } } fun setItemSizePx(itemSizePx: Int) { this.itemSizePx = itemSizePx - notifyDataSetChanged() + notifyItemsChanged() } fun setOnClickItem(listener: ((BannerItem) -> Unit)?) { @@ -50,6 +61,11 @@ class BannerAdapter( onBindImage = listener } + fun setPreviewImageResource(resId: Int) { + previewImageResId = resId + notifyItemsChanged() + } + fun toRealIndex(position: Int): Int = if (items.isEmpty()) 0 else position % items.size fun startPosition(realIndex: Int): Int { @@ -74,6 +90,7 @@ class BannerAdapter( height = ViewGroup.LayoutParams.MATCH_PARENT } setRadiusClipping(imageView) + if (previewImageResId != 0) imageView.setImageResource(previewImageResId) onBindImage?.invoke(imageView, item) itemView.setOnClickListener { onClickItem?.invoke(item) } } @@ -88,6 +105,11 @@ class BannerAdapter( } } + private fun notifyItemsChanged() { + val count = itemCount + if (count > 0) notifyItemRangeChanged(0, count) + } + 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 index 281e7c78..ccd14f33 100644 --- 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 @@ -59,6 +59,7 @@ class BannerView @JvmOverloads constructor( separatorText = findViewById(R.id.tv_banner_counter_separator) totalCountText = findViewById(R.id.tv_banner_total_count) setUpRecyclerView() + applyPreviewAttributes(attrs) updateCounter() } @@ -114,6 +115,29 @@ class BannerView @JvmOverloads constructor( adapter.setOnBindImage(listener) } + private fun applyPreviewAttributes(attrs: AttributeSet?) { + if (attrs == null) return + + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.BannerView) + try { + val previewItemCount = typedArray.getInt(R.styleable.BannerView_bannerPreviewItemCount, 0) + val previewCurrentIndex = typedArray.getInt(R.styleable.BannerView_bannerPreviewCurrentIndex, 0) + val previewImageResId = typedArray.getResourceId(R.styleable.BannerView_bannerPreviewImage, 0) + if (previewImageResId != 0) adapter.setPreviewImageResource(previewImageResId) + if (previewItemCount > 0) { + items = List(previewItemCount) { index -> + BannerItem(bannerId = "preview-$index", imageUrl = "") + } + currentIndex = BannerState.from(previewItemCount, previewCurrentIndex).currentIndex + visibility = VISIBLE + adapter.submitItems(items) + scrollToCurrentBanner() + } + } finally { + typedArray.recycle() + } + } + private fun setUpRecyclerView() { requireNotNull(recyclerView).apply { layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) 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 index 00f90866..c9db7010 100644 --- 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 @@ -17,6 +17,7 @@ import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config @@ -100,6 +101,41 @@ class BannerViewTest { assertEquals("02", view.findViewById(R.id.tv_banner_total_count).text.toString()) } + @Test + fun `배너 view는 preview 속성의 count와 current index를 counter에 반영한다`() { + val context = ApplicationProvider.getApplicationContext() + val attrs = Robolectric.buildAttributeSet() + .addAttribute(R.attr.bannerPreviewItemCount, "5") + .addAttribute(R.attr.bannerPreviewCurrentIndex, "2") + .build() + + val view = BannerView(context, attrs) + + assertEquals(View.VISIBLE, view.visibility) + assertEquals(View.VISIBLE, view.findViewById(R.id.layout_banner_counter).visibility) + assertEquals("03", view.findViewById(R.id.tv_banner_current_index).text.toString()) + assertEquals("05", view.findViewById(R.id.tv_banner_total_count).text.toString()) + } + + @Test + fun `배너 view는 preview image 속성을 image view에 적용한다`() { + val context = ApplicationProvider.getApplicationContext() + val attrs = Robolectric.buildAttributeSet() + .addAttribute(R.attr.bannerPreviewItemCount, "1") + .addAttribute(R.attr.bannerPreviewImage, "@drawable/bg_banner_preview_placeholder") + .build() + + val view = BannerView(context, attrs) + view.measure(exactly(402.dpToPx()), exactly(402.dpToPx())) + view.layout(0, 0, 402.dpToPx(), 402.dpToPx()) + val recyclerView = view.findViewById(R.id.rv_banner) + val holder = recyclerView.findViewHolderForAdapterPosition(0) + ?: recyclerView.adapter!!.onCreateViewHolder(recyclerView, 0).also { recyclerView.adapter!!.onBindViewHolder(it, 0) } + val imageView = holder.itemView.findViewById(R.id.iv_banner) + + assertNotNull(imageView.drawable) + } + @Test fun `배너 view는 정사각형 item 크기와 좌우 padding 및 간격을 적용한다`() { val view = inflateBannerView() @@ -166,6 +202,34 @@ class BannerViewTest { assertEquals(0, itemOffset.right) } + @Test + fun `배너 item은 click listener가 null이어도 클릭으로 crash가 발생하지 않는다`() { + val context = ApplicationProvider.getApplicationContext() + val adapter = BannerAdapter() + adapter.submitItems(listOf(sampleItem("1"))) + adapter.setOnClickItem(null) + val holder = adapter.onCreateViewHolder(FrameLayout(context), 0) + adapter.onBindViewHolder(holder, 0) + + assertTrue(holder.itemView.performClick()) + } + + @Test + fun `배너 view는 첫 번째 배너 클릭 시 첫 번째 item을 callback으로 전달한다`() { + val view = inflateBannerView() + val firstItem = sampleItem("1") + var clickedItem: BannerItem? = null + + view.setOnBannerClickListener { clickedItem = it } + view.setItems(listOf(firstItem)) + val recyclerView = view.findViewById(R.id.rv_banner) + val holder = recyclerView.adapter!!.onCreateViewHolder(recyclerView, 0) + recyclerView.adapter!!.onBindViewHolder(holder, 0) + holder.itemView.performClick() + + assertEquals(firstItem, clickedItem) + } + @Test fun `배너 item image는 radius clipping 대상으로 설정된다`() { val context = ApplicationProvider.getApplicationContext()