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:
2026-05-28 10:50:23 +09:00
parent 31b4e93bed
commit bc15a0997e
3 changed files with 228 additions and 5 deletions

View File

@@ -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)

View File

@@ -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<BannerItem> = 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() {