Coverage Summary for Class: RecyclerScrollStateView (kr.open.library.simple_ui.xml.ui.view.recyclerview)

Class Method, % Branch, % Line, % Instruction, %
RecyclerScrollStateView 50% (12/24) 20.5% (9/44) 33.3% (30/90) 44.1% (217/492)
RecyclerScrollStateView$Companion
RecyclerScrollStateView$scrollListener$1 33.3% (1/3) 0% (0/4) 11.1% (1/9) 6.2% (2/32)
RecyclerScrollStateView$setOnReachEdgeListener$1 50% (1/2) 50% (1/2) 22.2% (2/9)
RecyclerScrollStateView$setOnScrollDirectionListener$1 50% (1/2) 50% (1/2) 28.6% (2/7)
Total 48.4% (15/31) 18.8% (9/48) 32% (33/103) 41.3% (223/540)


 package kr.open.library.simple_ui.xml.ui.view.recyclerview
 
 import android.content.Context
 import android.util.AttributeSet
 import androidx.annotation.VisibleForTesting
 import androidx.recyclerview.widget.RecyclerView
 import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kr.open.library.simple_ui.core.logcat.Logx
 import kr.open.library.simple_ui.xml.R
 
 /**
  * A custom RecyclerView that provides edge reach and scroll direction detection.<br>
  * This view extends RecyclerView and adds functionality to detect when the user
  * has scrolled to the edge of the view and the direction of the scroll.<br><br>
  * 가장자리 도달 및 스크롤 방향 감지 기능을 제공하는 커스텀 RecyclerView입니다.<br>
  * 이 뷰는 RecyclerView를 확장하여 사용자가 뷰의 가장자리로 스크롤했을 때와
  * 스크롤 방향을 감지하는 기능을 추가합니다.<br>
  *
  * Features:<br>
  * - Detects when scroll reaches top, bottom, left, or right edges<br>
  * - Tracks scroll direction (UP, DOWN, LEFT, RIGHT, IDLE)<br>
  * - Supports both listener callbacks and Kotlin Flow<br>
  * - Configurable thresholds for edge detection and direction sensitivity<br>
  * - Automatic lifecycle management for scroll listeners<br><br>
  * 기능:<br>
  * - 스크롤이 상단, 하단, 좌측, 우측 가장자리에 도달하는지 감지<br>
  * - 스크롤 방향 추적 (UP, DOWN, LEFT, RIGHT, IDLE)<br>
  * - 리스너 콜백과 Kotlin Flow 모두 지원<br>
  * - 가장자리 감지 및 방향 민감도에 대한 구성 가능한 임계값<br>
  * - 스크롤 리스너의 자동 생명주기 관리<br>
  *
  * Usage example:<br>
  * ```kotlin
  * val recyclerView = RecyclerScrollStateView(context)
  * recyclerView.setOnScrollDirectionListener { direction ->
  *     when (direction) {
  *         ScrollDirection.UP -> // Handle scroll up
  *         ScrollDirection.DOWN -> // Handle scroll down
  *         else -> // Handle other directions
  *     }
  * }
  * recyclerView.setOnReachEdgeListener { edge, isReached ->
  *     if (edge == ScrollEdge.BOTTOM && isReached) {
  *         // Load more items
  *     }
  * }
  * ```
  * <br>
  *
  * @see ScrollDirection For scroll direction types.<br><br>
  *      스크롤 방향 타입은 ScrollDirection을 참조하세요.<br>
  *
  * @see ScrollEdge For edge position types.<br><br>
  *      가장자리 위치 타입은 ScrollEdge를 참조하세요.<br>
  *
  * @see RecyclerScrollStateCalculator For the scroll state calculation logic.<br><br>
  *      스크롤 상태 계산 로직은 RecyclerScrollStateCalculator를 참조하세요.<br>
  */
 public open class RecyclerScrollStateView : RecyclerView {
     private companion object {
         /** Default threshold in pixels for edge reach detection. */
         private const val DEFAULT_EDGE_REACH_THRESHOLD = 10
 
         /** Default threshold in pixels for scroll direction change detection. */
         private const val DEFAULT_SCROLL_DIRECTION_THRESHOLD = 20
     }
 
     /**
      * Calculator object responsible for scroll state calculation logic.<br>
      * Accessible for testing purposes.<br><br>
      * 스크롤 상태 계산 로직을 담당하는 계산기 객체입니다.<br>
      * 테스트 목적으로 접근 가능합니다.<br>
      */
     @VisibleForTesting
     internal val scrollStateCalculator = RecyclerScrollStateCalculator(
         edgeReachThreshold = DEFAULT_EDGE_REACH_THRESHOLD,
         scrollDirectionThreshold = DEFAULT_SCROLL_DIRECTION_THRESHOLD,
     )
 
     /**
      * Listener for edge reach events.<br><br>
      * 가장자리 도달 이벤트 리스너입니다.<br>
      */
     private var onEdgeReachedListener: OnEdgeReachedListener? = null
 
     /**
      * Listener for scroll direction change events.<br><br>
      * 스크롤 방향 변경 이벤트 리스너입니다.<br>
      */
     private var onScrollDirectionChangedListener: OnScrollDirectionChangedListener? = null
 
     /**
      * MutableSharedFlow for scroll direction events.<br>
      * Replays the last emitted value to new collectors.<br><br>
      * 스크롤 방향 이벤트를 위한 MutableSharedFlow입니다.<br>
      * 새로운 컬렉터에게 마지막으로 발행된 값을 다시 전달합니다.<br>
      */
     private val msfScrollDirectionFlow = MutableSharedFlow<ScrollDirection>(
         replay = 1,
         onBufferOverflow = BufferOverflow.DROP_OLDEST,
     )
 
     /**
      * Public SharedFlow for observing scroll direction changes.<br><br>
      * 스크롤 방향 변경을 관찰하기 위한 공개 SharedFlow입니다.<br>
      */
     public val sfScrollDirectionFlow: SharedFlow<ScrollDirection> = msfScrollDirectionFlow.asSharedFlow()
 
     /**
      * MutableSharedFlow for edge reach events.<br>
      * Emits Pair of ScrollEdge and whether the edge is reached.<br><br>
      * 가장자리 도달 이벤트를 위한 MutableSharedFlow입니다.<br>
      * ScrollEdge와 가장자리 도달 여부의 Pair를 발행합니다.<br>
      */
     private val msfEdgeReachedFlow = MutableSharedFlow<Pair<ScrollEdge, Boolean>>(
         replay = 1,
         onBufferOverflow = BufferOverflow.DROP_OLDEST,
     )
 
     /**
      * Public SharedFlow for observing edge reach events.<br><br>
      * 가장자리 도달 이벤트를 관찰하기 위한 공개 SharedFlow입니다.<br>
      */
     public val sfEdgeReachedFlow: SharedFlow<Pair<ScrollEdge, Boolean>> = msfEdgeReachedFlow.asSharedFlow()
 
     public constructor(context: Context) : super(context)
     public constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
         initTypeArray(attrs)
     }
     public constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
         super(context, attrs, defStyleAttr) {
         initTypeArray(attrs)
     }
 
     /**
      * Initializes custom attributes from XML.<br><br>
      * XML에서 커스텀 속성을 초기화합니다.<br>
      *
      * @param attrs The attribute set from XML.<br><br>
      *              XML의 속성 집합.<br>
      */
     private fun initTypeArray(attrs: AttributeSet?) {
         attrs?.let {
             context.obtainStyledAttributes(it, R.styleable.RecyclerScrollStateView).apply {
                 setScrollDirectionThreshold(
                     getInt(R.styleable.RecyclerScrollStateView_scrollDirectionThreshold, DEFAULT_SCROLL_DIRECTION_THRESHOLD),
                 )
 
                 setEdgeReachThreshold(
                     getInt(R.styleable.RecyclerScrollStateView_edgeReachThreshold, DEFAULT_EDGE_REACH_THRESHOLD),
                 )
                 recycle()
             }
         }
     }
 
     /**
      * Internal scroll listener for monitoring scroll events.<br><br>
      * 스크롤 이벤트를 모니터링하기 위한 내부 스크롤 리스너입니다.<br>
      */
     private val scrollListener = object : OnScrollListener() {
         override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
             super.onScrollStateChanged(recyclerView, newState)
             if (newState == SCROLL_STATE_IDLE) {
                 val result = scrollStateCalculator.resetScrollAccumulation()
                 if (result.directionChanged) {
                     notifyScrollDirectionChanged(result.newDirection)
                 }
             }
         }
 
         override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
             super.onScrolled(recyclerView, dx, dy)
             checkEdgeReach()
             updateScrollDirection(dx, dy)
         }
     }
 
     /**
      * Notifies listeners of scroll direction changes.<br><br>
      * 스크롤 방향 변경을 리스너에게 알립니다.<br>
      *
      * @param direction The new scroll direction.<br><br>
      *                  새로운 스크롤 방향.<br>
      */
     private fun notifyScrollDirectionChanged(direction: ScrollDirection) {
         onScrollDirectionChangedListener?.onScrollDirectionChanged(direction)
         msfScrollDirectionFlow.safeEmit(direction) {
             Logx.w("Fail emit data $direction")
         }
     }
 
     /**
      * Checks for edge reach based on scroll orientation.<br><br>
      * 스크롤 방향에 따라 가장자리 도달을 확인합니다.<br>
      */
     private fun checkEdgeReach() {
         when {
             isScrollVertical() -> checkVerticalEdges()
             isScrollHorizontal() -> checkHorizontalEdges()
         }
     }
 
     /**
      * Checks top and bottom edge reach detection.<br><br>
      * 상단 및 하단 가장자리 도달을 감지합니다.<br>
      */
     private fun checkVerticalEdges() {
         val result = scrollStateCalculator.checkVerticalEdges(
             verticalScrollOffset = computeVerticalScrollOffset(),
             canScrollDown = canScrollVertically(1),
             verticalScrollExtent = computeVerticalScrollExtent(),
             verticalScrollRange = computeVerticalScrollRange(),
         )
 
         if (result.topChanged) {
             onEdgeReachedListener?.onEdgeReached(ScrollEdge.TOP, result.isAtTop)
             msfEdgeReachedFlow.safeEmit(ScrollEdge.TOP to result.isAtTop) {
                 Logx.w("Failure emit Edge ${ScrollEdge.TOP}, ${result.isAtTop}")
             }
         }
 
         if (result.bottomChanged) {
             onEdgeReachedListener?.onEdgeReached(ScrollEdge.BOTTOM, result.isAtBottom)
             msfEdgeReachedFlow.safeEmit(ScrollEdge.BOTTOM to result.isAtBottom) {
                 Logx.w("Failure emit Edge ${ScrollEdge.BOTTOM}, ${result.isAtBottom}")
             }
         }
     }
 
     /**
      * Checks left and right edge reach detection.<br><br>
      * 좌측 및 우측 가장자리 도달을 감지합니다.<br>
      */
     private fun checkHorizontalEdges() {
         val result = scrollStateCalculator.checkHorizontalEdges(
             horizontalScrollOffset = computeHorizontalScrollOffset(),
             canScrollRight = canScrollHorizontally(1),
             horizontalScrollExtent = computeHorizontalScrollExtent(),
             horizontalScrollRange = computeHorizontalScrollRange(),
         )
 
         if (result.leftChanged) {
             onEdgeReachedListener?.onEdgeReached(ScrollEdge.LEFT, result.isAtLeft)
             msfEdgeReachedFlow.safeEmit(ScrollEdge.LEFT to result.isAtLeft) {
                 Logx.w("Failure emit Edge ${ScrollEdge.LEFT}, ${result.isAtLeft}")
             }
         }
 
         if (result.rightChanged) {
             onEdgeReachedListener?.onEdgeReached(ScrollEdge.RIGHT, result.isAtRight)
             msfEdgeReachedFlow.safeEmit(ScrollEdge.RIGHT to result.isAtRight) {
                 Logx.w("Failure emit Edge ${ScrollEdge.RIGHT}, ${result.isAtRight}")
             }
         }
     }
 
     /**
      * Checks if the LayoutManager supports vertical scrolling.<br><br>
      * LayoutManager가 수직 스크롤을 지원하는지 확인합니다.<br>
      */
     private fun isScrollVertical() = layoutManager?.canScrollVertically() ?: false
 
     /**
      * Checks if the LayoutManager supports horizontal scrolling.<br><br>
      * LayoutManager가 수평 스크롤을 지원하는지 확인합니다.<br>
      */
     private fun isScrollHorizontal() = layoutManager?.canScrollHorizontally() ?: false
 
     /**
      * Updates scroll direction based on scroll deltas.<br><br>
      * 스크롤 델타값을 기반으로 스크롤 방향을 업데이트합니다.<br>
      *
      * @param dx Horizontal scroll delta.<br><br>
      *           수평 스크롤 델타.<br>
      *
      * @param dy Vertical scroll delta.<br><br>
      *           수직 스크롤 델타.<br>
      */
     private fun updateScrollDirection(dx: Int, dy: Int) {
         when {
             isScrollVertical() -> updateVerticalScrollDirection(dy)
             isScrollHorizontal() -> updateHorizontalScrollDirection(dx)
         }
     }
 
     /**
      * Updates vertical scroll direction.<br><br>
      * 수직 스크롤 방향을 업데이트합니다.<br>
      *
      * @param dy Vertical scroll delta.<br><br>
      *           수직 스크롤 델타.<br>
      */
     private fun updateVerticalScrollDirection(dy: Int) {
         val result = scrollStateCalculator.updateVerticalScrollDirection(dy)
         if (result.directionChanged) {
             notifyScrollDirectionChanged(result.newDirection)
         }
     }
 
     /**
      * Updates horizontal scroll direction.<br><br>
      * 수평 스크롤 방향을 업데이트합니다.<br>
      *
      * @param dx Horizontal scroll delta.<br><br>
      *           수평 스크롤 델타.<br>
      */
     private fun updateHorizontalScrollDirection(dx: Int) {
         val result = scrollStateCalculator.updateHorizontalScrollDirection(dx)
         if (result.directionChanged) {
             notifyScrollDirectionChanged(result.newDirection)
         }
     }
 
     /**
      * Called when the view is attached to a window.<br>
      * Registers the scroll listener.<br><br>
      * 뷰가 윈도우에 연결될 때 호출됩니다.<br>
      * 스크롤 리스너를 등록합니다.<br>
      */
     override fun onAttachedToWindow() {
         super.onAttachedToWindow()
         addOnScrollListener(scrollListener)
     }
 
     /**
      * Called when the view is detached from a window.<br>
      * Unregisters the scroll listener.<br><br>
      * 뷰가 윈도우에서 분리될 때 호출됩니다.<br>
      * 스크롤 리스너를 해제합니다.<br>
      */
     override fun onDetachedFromWindow() {
         super.onDetachedFromWindow()
         removeOnScrollListener(scrollListener)
     }
 
     /**
      * Sets a listener to be notified of scroll direction changes.<br>
      * This method allows you to register a callback that will be invoked
      * whenever the scroll direction of the `RecyclerScrollStateView` changes.<br>
      * The scroll direction is determined based on the accumulated scroll distance
      * and the configured [scrollDirectionThreshold].<br><br>
      * 스크롤 방향 변경 알림을 받을 리스너를 설정합니다.<br>
      * 이 메서드를 사용하면 `RecyclerScrollStateView`의 스크롤 방향이 변경될 때마다
      * 호출될 콜백을 등록할 수 있습니다.<br>
      * 스크롤 방향은 축적된 스크롤 거리와 구성된 [scrollDirectionThreshold]를 기반으로 결정됩니다.<br>
      *
      * The listener will receive a [ScrollDirection] value indicating the
      * current scroll direction:<br>
      * - [ScrollDirection.UP], [ScrollDirection.DOWN], [ScrollDirection.LEFT], [ScrollDirection.RIGHT], [ScrollDirection.IDLE]<br><br>
      * 리스너는 현재 스크롤 방향을 나타내는 [ScrollDirection] 값을 수신합니다:<br>
      * - [ScrollDirection.UP], [ScrollDirection.DOWN], [ScrollDirection.LEFT], [ScrollDirection.RIGHT], [ScrollDirection.IDLE]<br>
      *
      * @param listener The listener to be notified of scroll direction changes.<br><br>
      *                 스크롤 방향 변경 알림을 받을 리스너. null을 전달하면 리스너를 해제합니다.<br>
      */
     public fun setOnScrollDirectionListener(listener: OnScrollDirectionChangedListener?) {
         this.onScrollDirectionChangedListener = listener
     }
 
     /**
      * Sets a lambda listener to be notified of scroll direction changes.<br><br>
      * 스크롤 방향 변경 알림을 받을 람다 리스너를 설정합니다.<br>
      *
      * @param listener The lambda to be invoked on scroll direction changes.<br><br>
      *                 스크롤 방향 변경 시 호출될 람다.<br>
      */
     public fun setOnScrollDirectionListener(listener: (scrollDirection: ScrollDirection) -> Unit) {
         setOnScrollDirectionListener(
             object : OnScrollDirectionChangedListener {
                 override fun onScrollDirectionChanged(scrollDirection: ScrollDirection) {
                     listener(scrollDirection)
                 }
             },
         )
     }
 
     /**
      * Sets a listener to be notified when the RecyclerView reaches an edge.<br>
      * This method allows you to register a callback that will be invoked
      * whenever the `RecyclerScrollStateView` reaches one of its edges: top, bottom, left, or right.<br>
      * The edge reach detection is determined based on the configured [edgeReachThreshold].<br><br>
      * RecyclerView가 가장자리에 도달했을 때 알림을 받을 리스너를 설정합니다.<br>
      * 이 메서드를 사용하면 `RecyclerScrollStateView`가 상단, 하단, 좌측, 우측 가장자리 중
      * 하나에 도달할 때마다 호출될 콜백을 등록할 수 있습니다.<br>
      * 가장자리 도달 감지는 구성된 [edgeReachThreshold]를 기반으로 결정됩니다.<br>
      *
      * The listener will receive a [ScrollEdge] value indicating which edge was reached,
      * and a boolean value indicating whether the edge is currently reached (`true`) or not (`false`).<br><br>
      * 리스너는 어떤 가장자리에 도달했는지 나타내는 [ScrollEdge] 값과
      * 현재 가장자리에 도달했는지(`true`) 아닌지(`false`)를 나타내는 boolean 값을 수신합니다.<br>
      *
      * @param listener The listener to be notified of edge reach events.<br><br>
      *                 가장자리 도달 이벤트 알림을 받을 리스너. null을 전달하면 리스너를 해제합니다.<br>
      */
     public fun setOnReachEdgeListener(listener: OnEdgeReachedListener?) {
         this.onEdgeReachedListener = listener
     }
 
     /**
      * Sets a lambda listener to be notified when the RecyclerView reaches an edge.<br><br>
      * RecyclerView가 가장자리에 도달했을 때 알림을 받을 람다 리스너를 설정합니다.<br>
      *
      * @param listener The lambda to be invoked on edge reach events.<br><br>
      *                 가장자리 도달 이벤트 시 호출될 람다.<br>
      */
     public fun setOnReachEdgeListener(listener: (edge: ScrollEdge, isReached: Boolean) -> Unit) {
         setOnReachEdgeListener(
             object : OnEdgeReachedListener {
                 override fun onEdgeReached(edge: ScrollEdge, isReached: Boolean) {
                     listener(edge, isReached)
                 }
             },
         )
     }
 
     /**
      * Sets the minimum scroll movement range required to trigger a scroll direction change event.<br>
      * This method allows you to define the minimum distance (in pixels) that the
      * `RecyclerScrollStateView` must be scrolled in a particular direction
      * before a scroll direction change event is triggered.<br>
      * By default, the minimum scroll movement range is set to 20 pixels.<br>
      * You can adjust this value to control the sensitivity of scroll direction
      * detection. A higher value will make the detection less sensitive, while a
      * lower value will make it more sensitive.<br><br>
      * 스크롤 방향 변경 이벤트를 트리거하는 데 필요한 최소 스크롤 이동 범위(px)를 설정합니다.<br>
      * 이 메서드를 사용하면 스크롤 방향 변경 이벤트가 트리거되기 전에
      * `RecyclerScrollStateView`가 특정 방향으로 스크롤되어야 하는 최소 거리(픽셀 단위)를 정의할 수 있습니다.<br>
      * 기본값으로 최소 스크롤 이동 범위는 20픽셀로 설정됩니다.<br>
      * 이 값을 조정하여 스크롤 방향 감지의 민감도를 제어할 수 있습니다.
      * 값이 높을수록 감지가 덜 민감해지고, 낮을수록 더 민감해집니다.<br>
      *
      * @param minimumScrollMovementRange The minimum scroll movement range in pixels.
      *                                   This value should be >= 0.<br><br>
      *                                   최소 스크롤 이동 범위(픽셀 단위).
      *                                   이 값은 0 이상이어야 합니다.<br>
      */
     public fun setScrollDirectionThreshold(minimumScrollMovementRange: Int) {
         require(minimumScrollMovementRange >= 0) {
             "minimumScrollMovementRange must be >= 0, but input value is $minimumScrollMovementRange"
         }
         scrollStateCalculator.updateThresholds(scrollDirectionThreshold = minimumScrollMovementRange)
     }
 
     /**
      * Sets the threshold distance (in pixels) from an edge that is considered as reaching the edge.<br>
      * This method allows you to define the distance from an edge (top, bottom, left, or right)
      * within which the `RecyclerScrollStateView` is considered to have reached that edge.<br>
      * By default, the edge reach threshold is set to 10 pixels.<br>
      * You can adjust this value to control the sensitivity of edge reach detection.
      * A higher value will make the detection less sensitive, while a lower value will
      * make it more sensitive.<br><br>
      * 가장자리에 도달한 것으로 간주되는 가장자리로부터의 임계 거리(픽셀 단위)를 설정합니다.<br>
      * 이 메서드를 사용하면 `RecyclerScrollStateView`가 해당 가장자리에 도달한 것으로 간주되는
      * 가장자리(상단, 하단, 좌측 또는 우측)로부터의 거리를 정의할 수 있습니다.<br>
      * 기본값으로 가장자리 도달 임계값은 10픽셀로 설정됩니다.<br>
      * 이 값을 조정하여 가장자리 도달 감지의 민감도를 제어할 수 있습니다.
      * 값이 높을수록 감지가 덜 민감해지고, 낮을수록 더 민감해집니다.<br>
      *
      * @param edgeReachThreshold The edge reach threshold distance in pixels.
      *                           This value should be >= 0.<br><br>
      *                           가장자리 도달 임계 거리(픽셀 단위).
      *                           이 값은 0 이상이어야 합니다.<br>
      */
     public fun setEdgeReachThreshold(edgeReachThreshold: Int) {
         require(edgeReachThreshold >= 0) {
             "edgeReachThreshold must be >= 0, but input value is $edgeReachThreshold"
         }
         scrollStateCalculator.updateThresholds(edgeReachThreshold = edgeReachThreshold)
     }
 }