From bc15a0997ecdf6cd002e86ecb76a18b74a0bed62 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 28 May 2026 10:50:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(banner):=20=EB=B0=B0=EB=84=88=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=A0=84=ED=99=98=20=EB=8F=99=EC=9E=91=EC=9D=84=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 | 6 + .../sodalive/v2/widget/banner/BannerView.kt | 84 +++++++++- .../v2/widget/banner/BannerViewTest.kt | 143 ++++++++++++++++++ 3 files changed, 228 insertions(+), 5 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 21de937f..f4efc9e6 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 @@ -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) 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 0779a425..d32f82b2 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 @@ -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 = 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() { 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 89619b35..bb078d84 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 @@ -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(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(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(R.id.tv_banner_current_index).text.toString()) + + shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(1)) + assertEquals("02", view.findViewById(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(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(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(R.id.tv_banner_current_index).text.toString()) + + shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000)) + + assertEquals("01", view.findViewById(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(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(R.id.tv_banner_current_index).text.toString()) + } + private fun inflateBannerView(): BannerView { val context = ApplicationProvider.getApplicationContext() 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(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"