feat(banner): Phase 8 배너 동작을 보완한다

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 13:10:38 +09:00
parent df782d7968
commit 8dd2371ce4
3 changed files with 151 additions and 9 deletions

View File

@@ -9,6 +9,10 @@ import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView 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.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.PagerSnapHelper import androidx.recyclerview.widget.PagerSnapHelper
@@ -37,6 +41,19 @@ class BannerView @JvmOverloads constructor(
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
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() { private val bannerScrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
when (newState) { when (newState) {
@@ -66,11 +83,13 @@ class BannerView @JvmOverloads constructor(
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
hasWindowAttachmentForAutoScroll = true hasWindowAttachmentForAutoScroll = true
observeLifecycleOwner()
startAutoScrollIfNeeded() startAutoScrollIfNeeded()
} }
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {
stopAutoScroll() stopAutoScroll()
clearLifecycleOwner()
hasWindowAttachmentForAutoScroll = false hasWindowAttachmentForAutoScroll = false
super.onDetachedFromWindow() super.onDetachedFromWindow()
} }
@@ -96,7 +115,7 @@ class BannerView @JvmOverloads constructor(
fun setItems(items: List<BannerItem>) { fun setItems(items: List<BannerItem>) {
this.items = items this.items = items
currentIndex = BannerState.from(items.size, currentIndex).currentIndex currentIndex = 0
visibility = if (items.isEmpty()) GONE else VISIBLE visibility = if (items.isEmpty()) GONE else VISIBLE
stopAutoScroll() stopAutoScroll()
adapter.submitItems(items) adapter.submitItems(items)
@@ -187,6 +206,7 @@ class BannerView @JvmOverloads constructor(
private fun startAutoScrollIfNeeded() { private fun startAutoScrollIfNeeded() {
stopAutoScroll() stopAutoScroll()
if (!hasWindowAttachmentForAutoScroll) return if (!hasWindowAttachmentForAutoScroll) return
if (!isLifecycleStartedForAutoScroll) return
if (BannerState.from(items.size, currentIndex).displayMode != BannerDisplayMode.Carousel) return if (BannerState.from(items.size, currentIndex).displayMode != BannerDisplayMode.Carousel) return
autoScrollHandler.postDelayed(autoScrollRunnable, AUTO_SCROLL_INTERVAL_MS) autoScrollHandler.postDelayed(autoScrollRunnable, AUTO_SCROLL_INTERVAL_MS)
} }
@@ -196,9 +216,15 @@ class BannerView @JvmOverloads constructor(
} }
private fun updateCurrentIndexFromSnap() { private fun updateCurrentIndexFromSnap() {
val layoutManager = requireNotNull(recyclerView).layoutManager ?: return val recyclerView = requireNotNull(recyclerView)
val snapView = snapHelper.findSnapView(layoutManager) ?: return val layoutManager = recyclerView.layoutManager ?: return
val position = layoutManager.getPosition(snapView) 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 currentAdapterPosition = position
currentIndex = adapter.toRealIndex(position) currentIndex = adapter.toRealIndex(position)
updateCounter() updateCounter()
@@ -213,10 +239,25 @@ class BannerView @JvmOverloads constructor(
val formatted = BannerCounterFormatter.format(state.currentIndex, items.size) val formatted = BannerCounterFormatter.format(state.currentIndex, items.size)
val parts = formatted.split(" ") val parts = formatted.split(" ")
currentIndexText?.text = parts[0] currentIndexText?.text = parts[0]
separatorText?.text = parts[1] separatorText?.text = " ${parts[1]} "
totalCountText?.text = parts[2] 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() private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
companion object { companion object {

View File

@@ -33,7 +33,7 @@
<TextView <TextView
android:id="@+id/tv_banner_current_index" android:id="@+id/tv_banner_current_index"
style="@style/Typography.Caption2" style="@style/Typography.Body5"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textColor="@color/white" android:textColor="@color/white"
@@ -41,7 +41,7 @@
<TextView <TextView
android:id="@+id/tv_banner_counter_separator" android:id="@+id/tv_banner_counter_separator"
style="@style/Typography.Caption2" style="@style/Typography.Body5"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textColor="@color/gray_400" android:textColor="@color/gray_400"
@@ -49,7 +49,7 @@
<TextView <TextView
android:id="@+id/tv_banner_total_count" android:id="@+id/tv_banner_total_count"
style="@style/Typography.Caption2" style="@style/Typography.Body5"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textColor="@color/gray_400" android:textColor="@color/gray_400"

View File

@@ -8,6 +8,8 @@ import android.view.View.MeasureSpec
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
@@ -101,6 +103,26 @@ class BannerViewTest {
assertEquals("02", view.findViewById<TextView>(R.id.tv_banner_total_count).text.toString()) assertEquals("02", view.findViewById<TextView>(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<TextView>(R.id.tv_banner_current_index)
val separator = view.findViewById<TextView>(R.id.tv_banner_counter_separator)
val totalCount = view.findViewById<TextView>(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 @Test
fun `배너 view는 preview 속성의 count와 current index를 counter에 반영한다`() { fun `배너 view는 preview 속성의 count와 current index를 counter에 반영한다`() {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
@@ -293,6 +315,27 @@ class BannerViewTest {
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString()) assertEquals("01", view.findViewById<TextView>(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<TextView>(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<TextView>(R.id.tv_banner_current_index).text.toString())
}
@Test @Test
fun `배너 view는 단일 item이면 attach 후 5초가 지나도 자동 이동하지 않는다`() { fun `배너 view는 단일 item이면 attach 후 5초가 지나도 자동 이동하지 않는다`() {
val view = inflateBannerView() val view = inflateBannerView()
@@ -346,6 +389,47 @@ class BannerViewTest {
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString()) assertEquals("01", view.findViewById<TextView>(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<TextView>(R.id.tv_banner_current_index).text.toString())
view.setItems(listOf(sampleItem("4"), sampleItem("5"), sampleItem("6")))
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
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는 수동 경계와 빠른 위치 변경 후 idle에서 counter를 실제 index와 동기화한다`() {
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 firstPosition = adapter.startPosition(0)
val lastPosition = adapter.startPosition(2)
view.setCurrentPositionForTest(firstPosition - 1)
view.dispatchScrollStateForTest(RecyclerView.SCROLL_STATE_IDLE)
assertEquals("03", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
view.setCurrentPositionForTest(lastPosition + 1)
view.dispatchScrollStateForTest(RecyclerView.SCROLL_STATE_IDLE)
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
view.setCurrentPositionForTest(firstPosition + 5)
view.dispatchScrollStateForTest(RecyclerView.SCROLL_STATE_IDLE)
assertEquals("03", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
}
private fun inflateBannerView(): BannerView { private fun inflateBannerView(): BannerView {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
return BannerView(context) return BannerView(context)
@@ -372,6 +456,7 @@ class BannerViewTest {
} }
private fun BannerView.setCurrentPositionForTest(position: Int) { private fun BannerView.setCurrentPositionForTest(position: Int) {
findViewById<RecyclerView>(R.id.rv_banner).scrollToPosition(position)
BannerView::class.java.getDeclaredField("currentAdapterPosition").apply { BannerView::class.java.getDeclaredField("currentAdapterPosition").apply {
isAccessible = true isAccessible = true
setInt(this@setCurrentPositionForTest, position) setInt(this@setCurrentPositionForTest, position)
@@ -407,4 +492,20 @@ class BannerViewTest {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
return (this * context.resources.displayMetrics.density).toInt() 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)
}
}
} }