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:
@@ -52,6 +52,12 @@ class BannerAdapter(
|
|||||||
|
|
||||||
fun toRealIndex(position: Int): Int = if (items.isEmpty()) 0 else position % items.size
|
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) {
|
inner class BannerViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||||
private val imageView: ImageView = itemView.findViewById(R.id.iv_banner)
|
private val imageView: ImageView = itemView.findViewById(R.id.iv_banner)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.widget.banner
|
package kr.co.vividnext.sodalive.v2.widget.banner
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -8,6 +10,7 @@ import android.widget.FrameLayout
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||||
import androidx.recyclerview.widget.PagerSnapHelper
|
import androidx.recyclerview.widget.PagerSnapHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import kr.co.vividnext.sodalive.R
|
import kr.co.vividnext.sodalive.R
|
||||||
@@ -28,8 +31,23 @@ class BannerView @JvmOverloads constructor(
|
|||||||
private val snapHelper = PagerSnapHelper()
|
private val snapHelper = PagerSnapHelper()
|
||||||
private var items: List<BannerItem> = emptyList()
|
private var items: List<BannerItem> = emptyList()
|
||||||
private var currentIndex: Int = 0
|
private var currentIndex: Int = 0
|
||||||
|
private var currentAdapterPosition: Int = 0
|
||||||
private var itemSpacingPx: Int = 0
|
private var itemSpacingPx: Int = 0
|
||||||
private var spacingDecoration: RecyclerView.ItemDecoration? = null
|
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 {
|
init {
|
||||||
clipToPadding = false
|
clipToPadding = false
|
||||||
@@ -44,6 +62,18 @@ class BannerView @JvmOverloads constructor(
|
|||||||
updateCounter()
|
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) {
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||||
super.onSizeChanged(w, h, oldw, oldh)
|
super.onSizeChanged(w, h, oldw, oldh)
|
||||||
if (w > 0) applyLayoutSize(w)
|
if (w > 0) applyLayoutSize(w)
|
||||||
@@ -53,9 +83,12 @@ class BannerView @JvmOverloads constructor(
|
|||||||
this.items = items
|
this.items = items
|
||||||
currentIndex = BannerState.from(items.size, currentIndex).currentIndex
|
currentIndex = BannerState.from(items.size, currentIndex).currentIndex
|
||||||
visibility = if (items.isEmpty()) GONE else VISIBLE
|
visibility = if (items.isEmpty()) GONE else VISIBLE
|
||||||
|
stopAutoScroll()
|
||||||
adapter.submitItems(items)
|
adapter.submitItems(items)
|
||||||
|
scrollToCurrentBanner()
|
||||||
if (width > 0) applyLayoutSize(width)
|
if (width > 0) applyLayoutSize(width)
|
||||||
updateCounter()
|
updateCounter()
|
||||||
|
startAutoScrollIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setOnBannerClickListener(listener: ((BannerItem) -> Unit)?) {
|
fun setOnBannerClickListener(listener: ((BannerItem) -> Unit)?) {
|
||||||
@@ -73,11 +106,7 @@ class BannerView @JvmOverloads constructor(
|
|||||||
clipToPadding = false
|
clipToPadding = false
|
||||||
clipChildren = false
|
clipChildren = false
|
||||||
if (onFlingListener == null) snapHelper.attachToRecyclerView(this)
|
if (onFlingListener == null) snapHelper.attachToRecyclerView(this)
|
||||||
addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
addOnScrollListener(bannerScrollListener)
|
||||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
|
||||||
if (newState == RecyclerView.SCROLL_STATE_IDLE) updateCurrentIndexFromSnap()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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() {
|
private fun updateCurrentIndexFromSnap() {
|
||||||
val layoutManager = requireNotNull(recyclerView).layoutManager ?: return
|
val layoutManager = requireNotNull(recyclerView).layoutManager ?: return
|
||||||
val snapView = snapHelper.findSnapView(layoutManager) ?: return
|
val snapView = snapHelper.findSnapView(layoutManager) ?: return
|
||||||
val position = layoutManager.getPosition(snapView)
|
val position = layoutManager.getPosition(snapView)
|
||||||
|
currentAdapterPosition = position
|
||||||
currentIndex = adapter.toRealIndex(position)
|
currentIndex = adapter.toRealIndex(position)
|
||||||
updateCounter()
|
updateCounter()
|
||||||
}
|
}
|
||||||
@@ -118,6 +181,17 @@ class BannerView @JvmOverloads constructor(
|
|||||||
|
|
||||||
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
|
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 class BannerSpacingDecoration(
|
||||||
private val spacingPx: Int
|
private val spacingPx: Int
|
||||||
) : RecyclerView.ItemDecoration() {
|
) : RecyclerView.ItemDecoration() {
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows.shadowOf
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@Config(sdk = [28], application = Application::class)
|
@Config(sdk = [28], application = Application::class)
|
||||||
@@ -148,11 +150,152 @@ class BannerViewTest {
|
|||||||
assertNotNull(imageView.outlineProvider)
|
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 {
|
private fun inflateBannerView(): BannerView {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
return BannerView(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(
|
private fun sampleItem(id: String) = BannerItem(
|
||||||
bannerId = id,
|
bannerId = id,
|
||||||
imageUrl = "https://example.com/banner-$id.png"
|
imageUrl = "https://example.com/banner-$id.png"
|
||||||
|
|||||||
Reference in New Issue
Block a user