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:
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user