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:
2026-05-28 10:50:23 +09:00
parent 31b4e93bed
commit bc15a0997e
3 changed files with 228 additions and 5 deletions

View File

@@ -52,6 +52,12 @@ class BannerAdapter(
fun toRealIndex(position: Int): Int = if (items.isEmpty()) 0 else position % items.size
fun startPosition(realIndex: Int): Int {
if (items.size <= 1) return realIndex
val center = VIRTUAL_ITEM_COUNT / 2
return center - (center % items.size) + realIndex
}
inner class BannerViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val imageView: ImageView = itemView.findViewById(R.id.iv_banner)

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.v2.widget.banner
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
@@ -8,6 +10,7 @@ import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
@@ -28,8 +31,23 @@ class BannerView @JvmOverloads constructor(
private val snapHelper = PagerSnapHelper()
private var items: List<BannerItem> = emptyList()
private var currentIndex: Int = 0
private var currentAdapterPosition: Int = 0
private var itemSpacingPx: Int = 0
private var spacingDecoration: RecyclerView.ItemDecoration? = null
private val autoScrollHandler = Handler(Looper.getMainLooper())
private val autoScrollRunnable = Runnable { moveToNextBanner() }
private var hasWindowAttachmentForAutoScroll = false
private val bannerScrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
when (newState) {
RecyclerView.SCROLL_STATE_DRAGGING -> stopAutoScroll()
RecyclerView.SCROLL_STATE_IDLE -> {
updateCurrentIndexFromSnap()
startAutoScrollIfNeeded()
}
}
}
}
init {
clipToPadding = false
@@ -44,6 +62,18 @@ class BannerView @JvmOverloads constructor(
updateCounter()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
hasWindowAttachmentForAutoScroll = true
startAutoScrollIfNeeded()
}
override fun onDetachedFromWindow() {
stopAutoScroll()
hasWindowAttachmentForAutoScroll = false
super.onDetachedFromWindow()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
if (w > 0) applyLayoutSize(w)
@@ -53,9 +83,12 @@ class BannerView @JvmOverloads constructor(
this.items = items
currentIndex = BannerState.from(items.size, currentIndex).currentIndex
visibility = if (items.isEmpty()) GONE else VISIBLE
stopAutoScroll()
adapter.submitItems(items)
scrollToCurrentBanner()
if (width > 0) applyLayoutSize(width)
updateCounter()
startAutoScrollIfNeeded()
}
fun setOnBannerClickListener(listener: ((BannerItem) -> Unit)?) {
@@ -73,11 +106,7 @@ class BannerView @JvmOverloads constructor(
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()
}
})
addOnScrollListener(bannerScrollListener)
}
}
@@ -95,10 +124,44 @@ class BannerView @JvmOverloads constructor(
}
}
private fun scrollToCurrentBanner() {
val recyclerView = requireNotNull(recyclerView)
currentAdapterPosition = if (items.size > 1) {
adapter.startPosition(currentIndex)
} else {
currentIndex
}
recyclerView.scrollToPosition(currentAdapterPosition)
}
private fun moveToNextBanner() {
if (BannerState.from(items.size, currentIndex).displayMode != BannerDisplayMode.Carousel) return
val recyclerView = requireNotNull(recyclerView)
val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return
val nextPosition = currentAdapterPosition + 1
currentAdapterPosition = nextPosition
currentIndex = adapter.toRealIndex(nextPosition)
layoutManager.startSmoothScroll(BannerSmoothScroller(context).apply { targetPosition = nextPosition })
updateCounter()
startAutoScrollIfNeeded()
}
private fun startAutoScrollIfNeeded() {
stopAutoScroll()
if (!hasWindowAttachmentForAutoScroll) return
if (BannerState.from(items.size, currentIndex).displayMode != BannerDisplayMode.Carousel) return
autoScrollHandler.postDelayed(autoScrollRunnable, AUTO_SCROLL_INTERVAL_MS)
}
private fun stopAutoScroll() {
autoScrollHandler.removeCallbacks(autoScrollRunnable)
}
private fun updateCurrentIndexFromSnap() {
val layoutManager = requireNotNull(recyclerView).layoutManager ?: return
val snapView = snapHelper.findSnapView(layoutManager) ?: return
val position = layoutManager.getPosition(snapView)
currentAdapterPosition = position
currentIndex = adapter.toRealIndex(position)
updateCounter()
}
@@ -118,6 +181,17 @@ class BannerView @JvmOverloads constructor(
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
companion object {
private const val AUTO_SCROLL_INTERVAL_MS = 5_000L
private const val SCROLL_ANIMATION_DURATION_MS = 350
fun scrollAnimationDurationMsForTest(): Int = SCROLL_ANIMATION_DURATION_MS
}
private class BannerSmoothScroller(context: Context) : LinearSmoothScroller(context) {
override fun calculateTimeForScrolling(dx: Int): Int = SCROLL_ANIMATION_DURATION_MS
}
private class BannerSpacingDecoration(
private val spacingPx: Int
) : RecyclerView.ItemDecoration() {

View File

@@ -18,7 +18,9 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import java.time.Duration
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
@@ -148,11 +150,152 @@ class BannerViewTest {
assertNotNull(imageView.outlineProvider)
}
@Test
fun `배너 view는 attach 후 5초가 지나면 다음 배너로 자동 이동한다`() {
val view = inflateBannerView()
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())
view.dispatchAttachedForTest()
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000))
assertEquals("02", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
}
@Test
fun `배너 view는 사용자 drag 중에는 자동 이동 timer를 다시 시작하지 않는다`() {
val view = inflateBannerView()
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())
view.dispatchAttachedForTest()
view.dispatchScrollStateForTest(RecyclerView.SCROLL_STATE_DRAGGING)
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000))
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
view.dispatchScrollStateForTest(RecyclerView.SCROLL_STATE_IDLE)
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(4_999))
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(1))
assertEquals("02", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
}
@Test
fun `배너 view는 detach 되면 자동 이동 callback을 제거한다`() {
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())
view.dispatchAttachedForTest()
view.dispatchDetachedForTest()
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000))
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
}
@Test
fun `배너 view는 단일 item이면 attach 후 5초가 지나도 자동 이동하지 않는다`() {
val view = inflateBannerView()
view.setItems(listOf(sampleItem("1")))
view.measure(exactly(402.dpToPx()), exactly(402.dpToPx()))
view.layout(0, 0, 402.dpToPx(), 402.dpToPx())
view.dispatchAttachedForTest()
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000))
assertEquals(View.GONE, view.findViewById<View>(R.id.layout_banner_counter).visibility)
}
@Test
fun `배너 view는 마지막 배너 다음에 첫 번째 counter로 순환한다`() {
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())
view.dispatchAttachedForTest()
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000))
assertEquals("02", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000))
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
}
@Test
fun `배너 view는 자동 전환 animation duration을 350ms로 사용한다`() {
assertEquals(350, BannerView.scrollAnimationDurationMsForTest())
}
@Test
fun `배너 view는 현재 virtual position 기준으로 다음 배너로 이동한다`() {
val view = inflateBannerView()
val recyclerView = view.findViewById<RecyclerView>(R.id.rv_banner)
view.setItems(listOf(sampleItem("1"), sampleItem("2"), sampleItem("3")))
val adapter = requireNotNull(recyclerView.adapter as? BannerAdapter)
val previousVirtualPosition = adapter.startPosition(0) - 1
view.measure(exactly(402.dpToPx()), exactly(402.dpToPx()))
view.layout(0, 0, 402.dpToPx(), 402.dpToPx())
view.setCurrentPositionForTest(previousVirtualPosition)
view.dispatchAttachedForTest()
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000))
assertEquals(previousVirtualPosition + 1, view.currentAdapterPositionForTest())
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
}
private fun inflateBannerView(): BannerView {
val context = ApplicationProvider.getApplicationContext<Context>()
return BannerView(context)
}
private fun BannerView.dispatchAttachedForTest() {
BannerView::class.java.getDeclaredMethod("onAttachedToWindow").apply {
isAccessible = true
invoke(this@dispatchAttachedForTest)
}
}
private fun BannerView.dispatchDetachedForTest() {
BannerView::class.java.getDeclaredMethod("onDetachedFromWindow").apply {
isAccessible = true
invoke(this@dispatchDetachedForTest)
}
}
private fun BannerView.dispatchScrollStateForTest(state: Int) {
val listenerField = BannerView::class.java.getDeclaredField("bannerScrollListener").apply { isAccessible = true }
val listener = listenerField.get(this) as RecyclerView.OnScrollListener
listener.onScrollStateChanged(findViewById(R.id.rv_banner), state)
}
private fun BannerView.setCurrentPositionForTest(position: Int) {
BannerView::class.java.getDeclaredField("currentAdapterPosition").apply {
isAccessible = true
setInt(this@setCurrentPositionForTest, position)
}
BannerView::class.java.getDeclaredField("currentIndex").apply {
isAccessible = true
setInt(this@setCurrentPositionForTest, requireNotNull(findViewById<RecyclerView>(R.id.rv_banner).adapter as? BannerAdapter).toRealIndex(position))
}
}
private fun BannerView.currentAdapterPositionForTest(): Int {
return BannerView::class.java.getDeclaredField("currentAdapterPosition").run {
isAccessible = true
getInt(this@currentAdapterPositionForTest)
}
}
private fun sampleItem(id: String) = BannerItem(
bannerId = id,
imageUrl = "https://example.com/banner-$id.png"