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 0536a0c1..d1aa4de4 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 @@ -9,6 +9,10 @@ import android.view.View import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.PagerSnapHelper @@ -37,6 +41,19 @@ class BannerView @JvmOverloads constructor( private val autoScrollHandler = Handler(Looper.getMainLooper()) private val autoScrollRunnable = Runnable { moveToNextBanner() } private var hasWindowAttachmentForAutoScroll = false + private var isLifecycleStartedForAutoScroll = true + private var lifecycleOwner: LifecycleOwner? = null + private val lifecycleObserver = object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + isLifecycleStartedForAutoScroll = true + startAutoScrollIfNeeded() + } + + override fun onStop(owner: LifecycleOwner) { + isLifecycleStartedForAutoScroll = false + stopAutoScroll() + } + } private val bannerScrollListener = object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { when (newState) { @@ -66,11 +83,13 @@ class BannerView @JvmOverloads constructor( override fun onAttachedToWindow() { super.onAttachedToWindow() hasWindowAttachmentForAutoScroll = true + observeLifecycleOwner() startAutoScrollIfNeeded() } override fun onDetachedFromWindow() { stopAutoScroll() + clearLifecycleOwner() hasWindowAttachmentForAutoScroll = false super.onDetachedFromWindow() } @@ -96,7 +115,7 @@ class BannerView @JvmOverloads constructor( fun setItems(items: List) { this.items = items - currentIndex = BannerState.from(items.size, currentIndex).currentIndex + currentIndex = 0 visibility = if (items.isEmpty()) GONE else VISIBLE stopAutoScroll() adapter.submitItems(items) @@ -187,6 +206,7 @@ class BannerView @JvmOverloads constructor( private fun startAutoScrollIfNeeded() { stopAutoScroll() if (!hasWindowAttachmentForAutoScroll) return + if (!isLifecycleStartedForAutoScroll) return if (BannerState.from(items.size, currentIndex).displayMode != BannerDisplayMode.Carousel) return autoScrollHandler.postDelayed(autoScrollRunnable, AUTO_SCROLL_INTERVAL_MS) } @@ -196,9 +216,15 @@ class BannerView @JvmOverloads constructor( } private fun updateCurrentIndexFromSnap() { - val layoutManager = requireNotNull(recyclerView).layoutManager ?: return - val snapView = snapHelper.findSnapView(layoutManager) ?: return - val position = layoutManager.getPosition(snapView) + val recyclerView = requireNotNull(recyclerView) + val layoutManager = recyclerView.layoutManager ?: return + val snapView = snapHelper.findSnapView(layoutManager) + val snapPosition = snapView?.let(layoutManager::getPosition) ?: RecyclerView.NO_POSITION + val position = if (snapPosition == RecyclerView.NO_POSITION) { + currentAdapterPosition + } else { + snapPosition + } currentAdapterPosition = position currentIndex = adapter.toRealIndex(position) updateCounter() @@ -213,10 +239,25 @@ class BannerView @JvmOverloads constructor( val formatted = BannerCounterFormatter.format(state.currentIndex, items.size) val parts = formatted.split(" ") currentIndexText?.text = parts[0] - separatorText?.text = parts[1] + separatorText?.text = " ${parts[1]} " totalCountText?.text = parts[2] } + private fun observeLifecycleOwner() { + val owner = findViewTreeLifecycleOwner() ?: return + if (lifecycleOwner === owner) return + clearLifecycleOwner() + lifecycleOwner = owner + isLifecycleStartedForAutoScroll = owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + owner.lifecycle.addObserver(lifecycleObserver) + } + + private fun clearLifecycleOwner() { + lifecycleOwner?.lifecycle?.removeObserver(lifecycleObserver) + lifecycleOwner = null + isLifecycleStartedForAutoScroll = true + } + private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt() companion object { diff --git a/app/src/main/res/layout/view_banner.xml b/app/src/main/res/layout/view_banner.xml index 74c5509c..d2e360e0 100644 --- a/app/src/main/res/layout/view_banner.xml +++ b/app/src/main/res/layout/view_banner.xml @@ -33,7 +33,7 @@ (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(" / ", 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 counter는 PRD 시각 형식과 색상 및 14sp 크기를 유지한다`() { + val view = inflateBannerView() + + view.setItems(listOf(sampleItem("1"), sampleItem("2"))) + + val currentIndex = view.findViewById(R.id.tv_banner_current_index) + val separator = view.findViewById(R.id.tv_banner_counter_separator) + val totalCount = view.findViewById(R.id.tv_banner_total_count) + val displayedCounter = currentIndex.text.toString() + separator.text + totalCount.text + + assertEquals("01 / 02", displayedCounter) + assertEquals(view.resources.getColor(R.color.white), currentIndex.currentTextColor) + assertEquals(view.resources.getColor(R.color.gray_400), separator.currentTextColor) + assertEquals(view.resources.getColor(R.color.gray_400), totalCount.currentTextColor) + assertEquals(14f, currentIndex.textSize / view.resources.displayMetrics.scaledDensity) + assertEquals(14f, separator.textSize / view.resources.displayMetrics.scaledDensity) + assertEquals(14f, totalCount.textSize / view.resources.displayMetrics.scaledDensity) + } + @Test fun `배너 view는 preview 속성의 count와 current index를 counter에 반영한다`() { val context = ApplicationProvider.getApplicationContext() @@ -293,6 +315,27 @@ class BannerViewTest { assertEquals("01", view.findViewById(R.id.tv_banner_current_index).text.toString()) } + @Test + fun `배너 view는 lifecycle stop에서 자동 이동을 멈추고 start에서 다시 예약한다`() { + val owner = TestLifecycleOwner() + val view = inflateBannerView() + view.setTag(lifecycleOwnerTagId(), owner) + + 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() + owner.handleEvent(Lifecycle.Event.ON_START) + owner.handleEvent(Lifecycle.Event.ON_STOP) + shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000)) + + assertEquals("01", view.findViewById(R.id.tv_banner_current_index).text.toString()) + + owner.handleEvent(Lifecycle.Event.ON_START) + 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는 단일 item이면 attach 후 5초가 지나도 자동 이동하지 않는다`() { val view = inflateBannerView() @@ -346,6 +389,47 @@ class BannerViewTest { assertEquals("01", view.findViewById(R.id.tv_banner_current_index).text.toString()) } + @Test + fun `배너 view는 새 목록을 설정하면 첫 번째 배너 기준으로 다시 시작한다`() { + 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()) + + view.setItems(listOf(sampleItem("4"), sampleItem("5"), sampleItem("6"))) + + assertEquals("01", view.findViewById(R.id.tv_banner_current_index).text.toString()) + 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는 수동 경계와 빠른 위치 변경 후 idle에서 counter를 실제 index와 동기화한다`() { + 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 firstPosition = adapter.startPosition(0) + val lastPosition = adapter.startPosition(2) + + view.setCurrentPositionForTest(firstPosition - 1) + view.dispatchScrollStateForTest(RecyclerView.SCROLL_STATE_IDLE) + assertEquals("03", view.findViewById(R.id.tv_banner_current_index).text.toString()) + + view.setCurrentPositionForTest(lastPosition + 1) + view.dispatchScrollStateForTest(RecyclerView.SCROLL_STATE_IDLE) + assertEquals("01", view.findViewById(R.id.tv_banner_current_index).text.toString()) + + view.setCurrentPositionForTest(firstPosition + 5) + view.dispatchScrollStateForTest(RecyclerView.SCROLL_STATE_IDLE) + assertEquals("03", view.findViewById(R.id.tv_banner_current_index).text.toString()) + } + private fun inflateBannerView(): BannerView { val context = ApplicationProvider.getApplicationContext() return BannerView(context) @@ -372,6 +456,7 @@ class BannerViewTest { } private fun BannerView.setCurrentPositionForTest(position: Int) { + findViewById(R.id.rv_banner).scrollToPosition(position) BannerView::class.java.getDeclaredField("currentAdapterPosition").apply { isAccessible = true setInt(this@setCurrentPositionForTest, position) @@ -407,4 +492,20 @@ class BannerViewTest { val context = ApplicationProvider.getApplicationContext() return (this * context.resources.displayMetrics.density).toInt() } + + private fun lifecycleOwnerTagId(): Int { + return Class.forName("androidx.lifecycle.runtime.R\$id") + .getDeclaredField("view_tree_lifecycle_owner") + .getInt(null) + } + + private class TestLifecycleOwner : androidx.lifecycle.LifecycleOwner { + private val registry = LifecycleRegistry(this) + + override val lifecycle: Lifecycle = registry + + fun handleEvent(event: Lifecycle.Event) { + registry.handleLifecycleEvent(event) + } + } }