1. nestedScroll이란?

- 여러개의 스크롤 가능한 요소가 겹쳐 있을 때 스크롤 이벤트를 어떻게 나누어 가질지 협력하는 매커니즘

- 겹쳐 있다는 것은 스크롤 가능한 부모컴포저블의 content에 스크롤 가능한 자식컴포저블이 있는 경우를 말함

- 대표적인 예시로 유투브 댓글창을 계속 위로 스크롤하다가 첫번째 댓글까지 간 후 또 스크롤하면 댓글창이 축소되는 경우가 있는데 이런 모습을 구현하고 싶을 때(BottomSheet + CoordinatorLayout을 쓰는 다른 방법도 있다고 하는데 직관적인 예시라 적어둠)

 


2. nestedScroll이 없다면?

1) 같은 방향의 스크롤이 겹친 경우

- 예를들어 LazyColumn 안에 또다른 LazyColumn이 있는 경우

  - 예시 케이스로 작성하긴 하는데 애초에 이런식으로 구현할일은 없을 듯..?

- 자식 컴포저블이 터치 이벤트를 완전히 독점하게 됨

- 자식 컴포저블을 끝까지 스크롤한 후 더 스크롤해도 부모 컴포저블은 스크롤되지 않음

 

2) 다른 방향의 스크롤이 겹친 경우

- 예를들어 LazyColumn 안에 LazyRow가 있는 경우

- 터치 방향에 따라 이벤트를 누가 가져갈지 결정됨

- 대각선으로 스크롤하는 경우 처음에 어느 방향으로 더 많이 움직였는지에 따라 결정

 


3. nestedScroll을 사용한다면?

- 자식 컴포저블을 터치했을 때 부모 컴포저블에게 먼저 스크롤하게 넘길수도 있고, 자식 컴포저블을 스크롤한 후 남은만큼 부모 컴포저블이 스크롤되게 할 수도 있음

 


4. 사용 방법

1) 부모 입장에서의 조율

- NestedScrollConnection 사용

- 부모 컴포저블이 자식의 스크롤 신호를 어떻게 처리할지 정의

- 4가지 주요 메서드 사용

  - onPreScroll : 자식이 스크롤하기 전에 호출됨, 부모가 먼저 스크롤의 일부를 소모할 수 있음

  - onPostScroll : 자식이 스크롤을 마친 후 남은 양이 있을 때 호출됨

  - onPreFling : 사용자가 스크롤 후 손을 뗐을 때 발생하는 관성(Fling) 스크롤 직전에 호출됨

  - onPostFling : 관성 스크롤이 끝난 후 호출됨

// 1. NestedScrollConnection 정의
val nestedScrollConnection = remember {
        object : NestedScrollConnection {

            // 1. onPreScroll: 자식(리스트)이 스크롤되기 '전'에 호출됨
            // 부모가 먼저 스크롤 소모하려면 여기서 처리
            override fun onPreScroll(
            	available: Offset,          // 사용자가 움직인 거리(음수 : 위로 스크롤, 양수 : 아래로  스크롤)
                source: NestedScrollSource  // 스크롤의 출처(Drag, Fling, Relocate, Wheel) 
            ): Offset {
                val delta = available.y
                
                return if (delta < 0) { // 위로 스크롤했다면 모두 소모하고 자식에게 전달하지 않음
                    Offset(x = 0f, y = delta)
                } else {
                    Offset.Zero // 아래로 스크롤할 때는 아무것도 소모하지 않음
                }
            }


            // 2. onPostScroll: 자식이 스크롤을 '마친 후'에 호출됨
            // 자식이  쓰고 남은 스크롤이 부모에게 전달됨
            override fun onPostScroll(
                consumed: Offset,            // 이미 사용된 스크롤 양
                available: Offset,           // 남아서 사용 가능한 스크롤 양
                source: NestedScrollSource   // 스크롤의 출처
            ): Offset {
            	val delta = available.y
            
                return Offset(x = 0f, y = delta)
            }


            // 3. onPreFling: 사용자가 손을 뗐을 때 발생하는 관성(Fling) 시작 '전'에 호출됨
            // 특정 조건에서 관성 스크롤을 부모가 가로채서 중단시키거나 직접 처리할때 사용
            override suspend fun onPreFling(
            	available: Velocity          // 관성 발생 초기의 속도(pixels per second)
            ): Velocity {
                return Velocity.Zero         // 본인이 소모할 관성 속도, 소모하지 않았으므로 자식에게 모두 전달됨
            }


            // 4. onPostFling: 자식의 관성 스크롤이 '끝난 후'에 호출됨
            // 관성 에너지가 아직 남아있다면 부모가 관성을 이어받을 수 있음
            override suspend fun onPostFling(
            	consumed: Velocity,          // 이미 사용된 관성 속도
                available: Velocity          // 남아서 사용 가능한 관성 속도
            ): Velocity {
                return available   // 본인이 소모할 관성 속도
            }
        }
    }


// 2. 부모 컨테이너에 nestedScroll Modifier 적용
Box(
    Modifier
        .fillMaxSize()
        .nestedScroll(nestedScrollConnection) // 이렇게 Modifier에 적용
) {
    // 자식 스크롤 요소 (LazyColumn 등)
    LazyColumn() {
        items(100) {
        	Text(
            	"Item $it", 
                modifier = Modifier.padding(16.dp)
            ) 
        }
	}    
}

 

2) 자식 입장에서의 전달

- NestedScrollDispatcher 사용

- 자식 컴포저블의 스크롤 이벤트를 부모에게 알리고 싶을 때 사용

- 대부분의 스크롤 컴포넌트에 이미 내장되어 있어서 직접 구현할일은 적음

@Composable
fun CustomScrollingChild(modifier: Modifier = Modifier) {
    val dispatcher = remember { NestedScrollDispatcher() } // 1. 디스패처 생성
    var offset by remember { mutableStateOf(0f) }

    Box(
        modifier = modifier
            // 2. 부모의 Connection과 나의 Dispatcher 연결
            // (자식 컴포넌트이지만 dispatcher를 넘겨줌으로써 부모에게 보고할 준비를 함)
            .nestedScroll(connection = object : NestedScrollConnection {}, dispatcher = dispatcher)
            .pointerInput(Unit) {
                detectDragGestures(
                    onDrag = { change, dragAmount ->
                        change.consume()
                        
                        val delta = Offset(0f, dragAmount.y)

                        // (A) 부모에게 먼저 물어봄 (PreScroll)
                        val parentsPreConsumed = dispatcher.dispatchPreScroll(
                            available = delta,
                            source = NestedScrollSource.UserInput
                        )

                        // (B) 부모가 쓰고 남은 양 계산
                        val leftForMe = delta - parentsPreConsumed

                        // (C) 자식(나)이 실제로 스크롤을 수행
                        val oldOffset = offset
                        offset += leftForMe.y
                        val myConsumed = Offset(0f, offset - oldOffset)

                        // (D) 내가 쓰고 남은 양 계산
                        val leftForParent = leftForMe - myConsumed

                        // (E) 부모에게 남은 양을 전달 (PostScroll)
                        dispatcher.dispatchPostScroll(
                            consumed = myConsumed + parentsPreConsumed,
                            available = leftForParent,
                            source = NestedScrollSource.UserInput
                        )
                    }
                )
            }
            .offset { IntOffset(0, offset.roundToInt()) }
    )
}