feat(banner): 배너 시각 정렬을 보완한다

This commit is contained in:
2026-05-28 14:41:17 +09:00
parent ecb9f5a260
commit a35310e536
2 changed files with 78 additions and 3 deletions

View File

@@ -38,6 +38,7 @@ class BannerView @JvmOverloads constructor(
private var currentAdapterPosition: Int = 0 private var currentAdapterPosition: Int = 0
private var itemSpacingPx: Int = 0 private var itemSpacingPx: Int = 0
private var spacingDecoration: RecyclerView.ItemDecoration? = null private var spacingDecoration: RecyclerView.ItemDecoration? = null
private var shouldAlignCurrentPositionAfterLayout = false
private val autoScrollHandler = Handler(Looper.getMainLooper()) private val autoScrollHandler = Handler(Looper.getMainLooper())
private val autoScrollRunnable = Runnable { moveToNextBanner() } private val autoScrollRunnable = Runnable { moveToNextBanner() }
private var hasWindowAttachmentForAutoScroll = false private var hasWindowAttachmentForAutoScroll = false
@@ -116,6 +117,7 @@ class BannerView @JvmOverloads constructor(
fun setItems(items: List<BannerItem>) { fun setItems(items: List<BannerItem>) {
this.items = items this.items = items
currentIndex = 0 currentIndex = 0
shouldAlignCurrentPositionAfterLayout = true
visibility = if (items.isEmpty()) GONE else VISIBLE visibility = if (items.isEmpty()) GONE else VISIBLE
stopAutoScroll() stopAutoScroll()
adapter.submitItems(items) adapter.submitItems(items)
@@ -179,6 +181,15 @@ class BannerView @JvmOverloads constructor(
spacingDecoration?.let(::removeItemDecoration) spacingDecoration?.let(::removeItemDecoration)
spacingDecoration = BannerSpacingDecoration(itemSpacingPx).also(::addItemDecoration) spacingDecoration = BannerSpacingDecoration(itemSpacingPx).also(::addItemDecoration)
} }
counterContainer?.let { counter ->
val params = counter.layoutParams as? LayoutParams ?: return@let
params.marginEnd = (size.sideInsetDp + COUNTER_ITEM_MARGIN_DP).dpToPx()
counter.layoutParams = params
}
if (shouldAlignCurrentPositionAfterLayout) {
scrollToCurrentBanner()
shouldAlignCurrentPositionAfterLayout = false
}
} }
private fun scrollToCurrentBanner() { private fun scrollToCurrentBanner() {
@@ -188,8 +199,16 @@ class BannerView @JvmOverloads constructor(
} else { } else {
currentIndex currentIndex
} }
val layoutManager = recyclerView.layoutManager as? LinearLayoutManager
if (items.size > 1 && layoutManager != null) {
layoutManager.scrollToPositionWithOffset(
currentAdapterPosition,
-(itemSpacingPx / 2)
)
} else {
recyclerView.scrollToPosition(currentAdapterPosition) recyclerView.scrollToPosition(currentAdapterPosition)
} }
}
private fun moveToNextBanner() { private fun moveToNextBanner() {
if (BannerState.from(items.size, currentIndex).displayMode != BannerDisplayMode.Carousel) return if (BannerState.from(items.size, currentIndex).displayMode != BannerDisplayMode.Carousel) return
@@ -263,6 +282,7 @@ class BannerView @JvmOverloads constructor(
companion object { companion object {
private const val AUTO_SCROLL_INTERVAL_MS = 5_000L private const val AUTO_SCROLL_INTERVAL_MS = 5_000L
private const val SCROLL_ANIMATION_DURATION_MS = 350 private const val SCROLL_ANIMATION_DURATION_MS = 350
private const val COUNTER_ITEM_MARGIN_DP = 14
fun scrollAnimationDurationMsForTest(): Int = SCROLL_ANIMATION_DURATION_MS fun scrollAnimationDurationMsForTest(): Int = SCROLL_ANIMATION_DURATION_MS
} }
@@ -280,7 +300,9 @@ class BannerView @JvmOverloads constructor(
parent: RecyclerView, parent: RecyclerView,
state: RecyclerView.State state: RecyclerView.State
) { ) {
outRect.right = spacingPx val leftSpacing = spacingPx / 2
outRect.left = leftSpacing
outRect.right = spacingPx - leftSpacing
} }
} }
} }

View File

@@ -173,11 +173,63 @@ class BannerViewTest {
assertEquals(20.dpToPx(), recyclerView.paddingLeft) assertEquals(20.dpToPx(), recyclerView.paddingLeft)
assertEquals(20.dpToPx(), recyclerView.paddingRight) assertEquals(20.dpToPx(), recyclerView.paddingRight)
assertEquals(8.dpToPx(), itemOffset.right) assertEquals(4.dpToPx(), itemOffset.left)
assertEquals(4.dpToPx(), itemOffset.right)
assertEquals(8.dpToPx(), itemOffset.left + itemOffset.right)
assertEquals(362.dpToPx(), holder.itemView.layoutParams.width) assertEquals(362.dpToPx(), holder.itemView.layoutParams.width)
assertEquals(362.dpToPx(), holder.itemView.layoutParams.height) assertEquals(362.dpToPx(), holder.itemView.layoutParams.height)
} }
@Test
fun `배너 view는 carousel item 간격을 좌우 대칭으로 적용한다`() {
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(4.dpToPx(), itemOffset.left)
assertEquals(4.dpToPx(), itemOffset.right)
assertEquals(8.dpToPx(), itemOffset.left + itemOffset.right)
}
@Test
fun `배너 view counter는 현재 item 내부 우상단 기준 margin을 적용한다`() {
val view = inflateBannerView()
view.setItems(listOf(sampleItem("1"), sampleItem("2")))
view.measure(exactly(402.dpToPx()), exactly(402.dpToPx()))
view.layout(0, 0, 402.dpToPx(), 402.dpToPx())
val counter = view.findViewById<View>(R.id.layout_banner_counter)
val params = counter.layoutParams as FrameLayout.LayoutParams
assertEquals(14.dpToPx(), params.topMargin)
assertEquals(34.dpToPx(), params.marginEnd)
}
@Test
fun `배너 view는 최초 layout 직후 현재 item 중심을 view 중심에 맞춘다`() {
val view = inflateBannerView()
val recyclerView = view.findViewById<RecyclerView>(R.id.rv_banner)
view.setItems(listOf(sampleItem("1"), sampleItem("2"), sampleItem("3")))
view.measure(exactly(402.dpToPx()), exactly(402.dpToPx()))
view.layout(0, 0, 402.dpToPx(), 402.dpToPx())
val layoutManager = requireNotNull(recyclerView.layoutManager)
val currentItem = requireNotNull(layoutManager.findViewByPosition(view.currentAdapterPositionForTest()))
val itemCenter = (currentItem.left + currentItem.right) / 2
val viewCenter = view.width / 2
assertEquals(viewCenter, itemCenter)
assertEquals(20.dpToPx(), currentItem.left)
assertEquals(20.dpToPx(), view.width - currentItem.right)
}
@Test @Test
fun `배너 view는 wrap content 높이면 402dp 폭 기준 362dp 높이로 측정된다`() { fun `배너 view는 wrap content 높이면 402dp 폭 기준 362dp 높이로 측정된다`() {
val view = inflateBannerView() val view = inflateBannerView()
@@ -221,6 +273,7 @@ class BannerViewTest {
val itemOffset = Rect() val itemOffset = Rect()
recyclerView.getItemDecorationAt(0).getItemOffsets(itemOffset, holder.itemView, recyclerView, RecyclerView.State()) recyclerView.getItemDecorationAt(0).getItemOffsets(itemOffset, holder.itemView, recyclerView, RecyclerView.State())
assertEquals(0, itemOffset.left)
assertEquals(0, itemOffset.right) assertEquals(0, itemOffset.right)
} }