#1 이전 게시글
[Android] Pointer input - Scroll
#1 개요 스크롤 | Jetpack Compose | Android Developers이 페이지는 Cloud Translation API를 통해 번역되었습니다. 스크롤 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세
kenel.tistory.com
스크롤에 대해 다룬 위 게시글에서부터 이어진다.
#2 중첩(Nested) 스크롤
#2-1 개요
중첩(Nested) 스크롤이란, 말 그대로 같은 방향(수직 또는 수평)으로 스크롤 하는 컨테이너 2개가 부모-자식 관계를 형성한 것을 뜻한다. 즉 verticalScroll()이 적용된 Column() 속에 다시 verticalScroll()이 적용된 Column()을 넣으면, 밖에 있는 부모 Column()과 안에 있는 자식 Column()이 중첩 스크롤을 이루게 되는 것이다.
#2-2 암시적 중첩 스크롤
코드
@Composable
private fun AutomaticNestedScroll() {
val gradient = Brush.verticalGradient(0f to Color.Gray, 1000f to Color.White)
Box(
modifier = Modifier
.background(Color.LightGray)
.height(480.dp)
.verticalScroll(rememberScrollState())
.padding(32.dp)
) {
Column {
repeat(6) {
Box(
modifier = Modifier
.height(128.dp)
.verticalScroll(rememberScrollState())
) {
Text(
"Scroll here",
modifier = Modifier
.border(12.dp, Color.DarkGray)
.background(brush = gradient)
.padding(24.dp)
.height(150.dp)
)
}
}
}
}
}
verticalScroll()이 적용된 Column() 속에 다시 verticalScroll()이 적용된 Column()을 넣은 코드다. 이렇게만 해도 암시적으로(= 알아서) 중첩 스크롤이 수행된다. Jetpack Compose에서 이런 암시적으로 중첩 스크롤이 되는 Modifier의 확장함수는 verticalScroll(), horizontalScroll(), scrollable()이 있고, 컴포저블로는 Lazy lists, TextField()가 있다.
결과
부모 영역을 스크롤 할 때는 평범하게 스크롤 된다. 자식 영역을 스크롤 할 때는 자식 영역이 스크롤 되되, 자식 영역에서 더 이상 스크롤 할 영역이 남아있지 않은 경우에만 부모 영역이 대신 스크롤 된다. 이는 우리가 일상에서 인터넷 웹서핑 혹은 스마트폰을 사용할 때 기대하는 자연스러운 스크롤 아닌가?
#2-3 명시적 중첩 스크롤 사용 시기
암시적 중첩 스크롤에서 보았듯 자식 컨테이너 → 부모 컨테이너의 방향으로 이벤트가 전파되기를 바라는 게 일반적이며 자연스러운 경우다. 그렇다면 명시적 중첩 스크롤은 언제 쓰는가?
첫째로 자연스럽지 않은 경우, 바로 부모 컨테이너 → 자식 컨테이너의 방향으로 이벤트를 전파시키고 싶을 때다. 암시적 중첩 스크롤이 자식 컨테이너 → 부모 컨테이너의 방향으로 고정되어 있으므로, 명시적 중첩 스크롤을 통해 이 방향에서 벗어나려는 것이다.
둘째로는 암시적 중첩 스크롤과 마찬가지로 자식 컨테이너 → 부모 컨테이너의 방향은 유지하지만, 그 이벤트 전파 과정 중간에 프로그래머가 특정한 로직을 넣고 싶은 경우다.
#3 명시적 중첩 스크롤 Modifier
#3-1 전제 조건
암시적 중첩 스크롤이 되는 구조여야 한다. 즉, #2에 있는 구조여야 후술할 Modifier.nestedScroll()을 적용할 수 있다는 말이다. 암시적 중첩 스크롤이 되지 않는 구조인데, Modifier.nestedScroll()만 적용하면 스크롤 자체가 되지 않는다.
#3-2 Modifier.nestedScroll()
fun Modifier.nestedScroll(
connection: NestedScrollConnection,
dispatcher: NestedScrollDispatcher? = null
): Modifier
Modifier.nestedScroll()은 명시적 중첩 스크롤을 사용하고 싶을 때 사용하는 도구다. Modifier.nestedScroll()의 코드 자체는 별 볼일이 없다. 명시적 중첩 스크롤 구현의 본 게임은 NestedScrollConnection에서 일어난다.
#3-3 NestedScrollConnection
/**
* 이 인터페이스를 Modifier.nestedScroll()에 인수로 전달하여
* 중첩 스크롤 계층 구조에 '참여'할 수 있다. '참여'한 이후부터는
* '스크롤 자식'의 스크롤 혹은 NestedScrollDispatcher를 통해 트리거된
* 스크롤 이벤트를 '부모'로서 수신할 수 있다.
*/
@JvmDefaultWithCompatibility
interface NestedScrollConnection {
/**
* 자식에게 전달된 드래그 이벤트의 일부 드래그량(delta)을
* 자신(부모)이 미리 '소비(consume)'할 수 있도록 호출되는 함수
* 매개변수 available: 얼마나 미리 '소비(consume)'할 수 있는지의 총량
* 매개변수 source: 스크롤 이벤트가 손가락인지 마우스휠에 의한 것인지 등 구분
* 반환값: 본 함수에서 '소비(consume)'한 양
*/
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero
/**
* 사후 드래그. 스크롤이 남는 부분을 자식으로부터 전달받아
* 자신(부모)이 '소비(consume)'할 수 있도록 호출되는 함수
* 매개변수 consumed: 본 함수까지 오는 과정에서 합산된
* (복수의 onPreScroll() 반환값) + (실제 화면상의 스크롤) + (복수의 onPostScroll() 반환값)
* 매개변수 available: 얼마나 더 '소비(consume)'할 수 있는지의 총량
* 매개변수 source: 스크롤 이벤트가 손가락인지 마우스휠에 의한 것인지 등 구분
* 반환값: 본 함수에서 '소비(consume)'한 양
*/
fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset =
Offset.Zero
/**
* 자식에게 전달된 플링 이벤트의 일부 속력(velocity)을
* 자신(부모)이 미리 '소비(consume)'할 수 있도록 호출되는 함수
* 매개변수 available: 얼마나 미리 '소비(consume)'할 수 있는지의 총량
* 반환값: 본 함수에서 '소비(consume)'한 양
*/
suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero
/**
* 사후 플링. 플링 속력(velocity)이 남는 정도를 자식으로부터 전달받아
* 자신(부모)이 '소비(consume)'할 수 있도록 호출되는 함수
* 매개변수 consumed: 본 함수까지 오는 과정에서 합산된
* (복수의 onPreFling() 반환값) + (실제 화면상의 플링) + (복수의 onPreFling() 반환값)
* 매개변수 available: 얼마나 더 '소비(consume)'할 수 있는지의 총량
* 반환값: 본 함수에서 '소비(consume)'한 양
*/
suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return Velocity.Zero
}
}
이 NestedScrollConnection의 4가지 함수를 통해, 명시적 중첩 스크롤이 구현된다. 다만, 처음에는 꽤 복잡하기 때문에 명시적 중첩 스크롤의 기제를 선제적으로 알아둘 필요가 있다. 주석은 공식 코드에 붙어있던 걸 번역한 것이다. 여기서 꼼꼼히 읽으며 이해할 필요는 없다. 어차피 아래에서부터 설명한다.
#3-4 NestedScrollConnection의 기제
NestedScrollConnection의 기제를 표현하는 도식도로, 마치 액티비티의 생명주기처럼 중첩 스크롤이 어떤 과정으로 처리되는지를 표현한 것이다. Modifier에 verticalScroll() 및 nestedScroll()이 붙은 컴포저블 Parent2, Parent1, Child가 존재한다고 해보자. Parent2 내부에는 Parent1이 있고, Parent1 내부에는 Child가 있는 상황이다. 이 구조에서 스마트폰 사용자가 Child에 스크롤을 가한 상황이라 가정한다.
onPreScroll() 실행만을 위한 특별한 버블링
Jetpack Compose에서 컴포저블 간 이벤트 전파의 순서 그리고 메소드 체이닝 된 Modifier 간 이벤트 전파의 순서는 각각 설계의 역방향, 그러니까 자식 → 부모 그리고 (후에 체이닝된 메소드) → (전에 체이닝된 메소드)다. 이는 #2-2에서 말한, 우리가 일상적으로 웹브라우저나 스마트폰을 사용하며 기대하는 자연스러운 방향이다. 또, 이를 이벤트 버블링(Event bubbling)이라고도 부른다.
그러나 #2-3에서 말했듯, 명시적 중첩 스크롤은 (프로그래머에 의해 의도적으로) 자연스럽지 않아야 한다. 즉 부모가 자식보다 먼저 사용자의 스크롤에 반응해야 한다. 그 '반응'을 대변하는 함수가 바로 onPreScroll()이다. 그리고 onPreScroll()의 실행을 부모 → 자식의 방향으로 하기 위해선 가장 극단에 위치한 한 부모를 찾아야 할 것이다. 그래서 '특별한' 이벤트 버블링을 통해 가장 끝에 있는 부모까지 간 후, 다시 돌아오며 역순으로 onPreScroll()을 수행한다. '특별'하다고 한 이유는, 이 이벤트 버블링이 Modifier.verticalScroll()이나 Modifier.scrollable() 등의 다른 메소드들은 무시하고 오직 Modifier.nestedScroll()만을 추적해 올라가기 때문이다. 즉 이 과정에서 nestedScroll()이 아닌 다른 스크롤 관련 메소드들은 실행되지 않는다.
onPreScroll() 실행
앞서 수행한 '특별' 버블링은 onPreScroll()의 수행 순서를 알아내기 위한 작업이었다. 버블링은 자식 → 부모 방향으로 전파되는데, Jetpack Compose 런타임은 이 순서를 뒤집어 부모 → 자식 방향으로 onPreScroll()을 실행해 나간다. onPreScoll()은 암시적 중첩 스크롤에서라면 자식이 했을 스크롤을, 부모에게 "혹시 이 스크롤량 중 일부를 엄마ㆍ아빠가 가져갈래('소비'할래)?"라고 묻게 만든다 (참고로 "소비된다"는 말이 "부모가 스크롤된다"는 뜻은 아니다. 부모를 스크롤시키려면, onPreScroll() 내부에서 별도로 scrollState.scrollBy()를 호출하도록 프로그래머가 구현하면 된다).
실제 스크롤
P2, P1, C의 onPreScroll()들을 순서대로 거치고 남은 스크롤량을 실제로 스크롤하는 부분이다 (또, 실제로 스크롤된만큼 스크롤량이 '소비'된다).
onPostScroll() 실행
실제 스크롤을 하고도 남은 부분을 처리하기 위한 화살표다. '스크롤을 하고 남은 부분'이라는 말은 직관적으로 납득되지 않는 말이다. 마치 '소리없는 아우성' 따위의 시적 표현처럼 느껴지지 않는가? 하지만 스크롤을 하고도 정말로 '남는' 경우가 있다. 어떤 컨테이너를 끝까지 스크롤 했음에도 사용자가 손가락을 여전히 움직이고 있는 경우를 떠올려보자. 그런 때 '스크롤이 남는다'라고 표현한다.
#4 순서 확인용 샘플 앱
#4-1 개요
위에서 설명한 내용이 정말로 맞는지 확인할 수 있는 샘플 앱을 만들어봤다.
#4-2 UI 부분
// in MainActivity.kt
val p2Count = 1
val p1Count = 5
val cCount = 10
repeat(p2Count) { p2Index ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.background(Color.White)
.nestedScrollWithLogger("1")
.scrollableWithLogger("2")
.nestedScrollWithLogger("3")
.scrollableWithLogger("4")
.verticalScrollWithLogger("4.5")
.nestedScrollWithLogger("5")
.scrollableWithLogger("6")
.nestedScrollWithLogger("7")
.scrollableWithLogger("8")
) {
repeat(p1Count) { p1Index ->
Column(
modifier = Modifier
.padding(
start = 24.dp,
top = 24.dp,
bottom = 24.dp,
end = 96.dp
)
.fillMaxWidth()
.height(500.dp)
.background(Color.LightGray)
.nestedScrollWithLogger("9")
.scrollableWithLogger("10")
.nestedScrollWithLogger("11")
.scrollableWithLogger("12")
.verticalScrollWithLogger("12.5")
.nestedScrollWithLogger("13")
.scrollableWithLogger("14")
.nestedScrollWithLogger("15")
.scrollableWithLogger("16")
) {
repeat(cCount) { cIndex ->
Column(
modifier = Modifier
.padding(
start = 24.dp,
top = 24.dp,
bottom = 24.dp,
end = 96.dp
)
.height(300.dp)
.background(Color.Gray)
.nestedScrollWithLogger("17")
.scrollableWithLogger("18")
.nestedScrollWithLogger("19")
.scrollableWithLogger("20")
.verticalScrollWithLogger("20.5")
.nestedScrollWithLogger("21")
.scrollableWithLogger("22")
.nestedScrollWithLogger("23")
.scrollableWithLogger("24")
) {
SampleTexts()
}
}
}
}
}
}
위 코드와 같이 한 컴포저블에 nestedScroll()을 달 수도 있다. 이 경우, 먼저 메소드 체이닝된 nestedScroll()이 부모고 뒤쪽에 체이닝된 nestedScroll()을 자식이라고 생각하면 된다.
#4-3 scrollableWithLogger()
@Composable
@SuppressLint("ModifierFactoryUnreferencedReceiver")
private fun Modifier.scrollableWithLogger(id: String): Modifier {
return this.scrollable(
state = ScrollableState {
println("$id scrollable() available delta: $it")
0F
},
orientation = Orientation.Vertical
)
}
로그를 출력하는 scrollable() 함수.
#4-4 verticalScrollWithLogger()
/* println()이 좀 늦게 출력된다
* 실제로 verticalScroll()이 늦게 수행된 것은 아니다
* ScrollState.value의 변화를 LaunchedEffect로 추적하고
* LaunchedEffect 내에서 비동기적으로 로그 메시지를 출력하기에 시차가 있는 것이다
* 로그 메시지 자체가, 실제 스크롤이 되는 순간 출력되게 만들어지지 않았다는 얘기다
*/
@Composable
@SuppressLint("ModifierFactoryUnreferencedReceiver")
private fun Modifier.verticalScrollWithLogger(id: String): Modifier {
val scrollState = remember {
ScrollState(0)
}
LaunchedEffect(scrollState.value) {
println("$id verticalScroll()")
}
return this.verticalScroll(
state = scrollState
)
}
로그를 출력하는 verticalScroll() 함수.
#4-5 nestedScrollWithLogger()
@Composable
@SuppressLint("ModifierFactoryUnreferencedReceiver")
fun Modifier.nestedScrollWithLogger(id: String): Modifier {
return this.nestedScroll(NestedScrollConnectionWithSimpleLogger(id))
}
class NestedScrollConnectionWithSimpleLogger(private val id: String) : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
println("$id onPreScroll available delta: $available")
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset, available: Offset, source: NestedScrollSource
): Offset {
println("$id onPostScroll available delta: $available")
return Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
//println("$id onPreFling available velocity: $available")
return Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
//println("$id onPostFling available velocity: $available")
return Velocity.Zero
}
}
로그를 출력하는 nestedScroll() 함수. 각 함수의 반환값은 NestedScrollConnection 인터페이스의 기본값이다.
#4-6 스크린샷
#4-7 출력 결과
1 onPreScroll available delta: Offset(0.0, -13.5)
3 onPreScroll available delta: Offset(0.0, -13.5)
5 onPreScroll available delta: Offset(0.0, -13.5)
7 onPreScroll available delta: Offset(0.0, -13.5)
9 onPreScroll available delta: Offset(0.0, -13.5)
11 onPreScroll available delta: Offset(0.0, -13.5)
13 onPreScroll available delta: Offset(0.0, -13.5)
15 onPreScroll available delta: Offset(0.0, -13.5)
17 onPreScroll available delta: Offset(0.0, -13.5)
19 onPreScroll available delta: Offset(0.0, -13.5)
21 onPreScroll available delta: Offset(0.0, -13.5)
23 onPreScroll available delta: Offset(0.0, -13.5)
24 scrollable() available delta: -13.575195
23 onPostScroll available delta: Offset(0.0, -13.5)
22 scrollable() available delta: -13.575195
21 onPostScroll available delta: Offset(0.0, -13.5)
20 scrollable() available delta: 0.0
19 onPostScroll available delta: Offset(0.0, 0.0)
18 scrollable() available delta: 0.0
17 onPostScroll available delta: Offset(0.0, 0.0)
16 scrollable() available delta: 0.0
15 onPostScroll available delta: Offset(0.0, 0.0)
14 scrollable() available delta: 0.0
13 onPostScroll available delta: Offset(0.0, 0.0)
12 scrollable() available delta: 0.0
11 onPostScroll available delta: Offset(0.0, 0.0)
10 scrollable() available delta: 0.0
9 onPostScroll available delta: Offset(0.0, 0.0)
8 scrollable() available delta: 0.0
7 onPostScroll available delta: Offset(0.0, 0.0)
6 scrollable() available delta: 0.0
5 onPostScroll available delta: Offset(0.0, 0.0)
4 scrollable() available delta: 0.0
3 onPostScroll available delta: Offset(0.0, 0.0)
2 scrollable() available delta: 0.0
1 onPostScroll available delta: Offset(0.0, 0.0)
20.5 verticalScroll()
Child를 아주 살짝만 스크롤했을 때 출력되는 로그 메시지다. verticalScroll의 로그 메시지가 좀 늦게 뜬다. 실제로 verticalScroll()이 늦게 수행된 것은 아니다. ScrollState.value의 변화를 LaunchedEffect로 추적하고, LaunchedEffect 내에서 비동기적으로 로그 메시지를 출력하기에 시차가 있는 것이다. 로그 메시지 자체가, 실제 스크롤이 되는 순간 출력되게 만들어지지 않았다는 얘기다.
#4-8 전체 소스코드
android-practice/pointer-input/ScrollModifierCallOrder at master · Kanmanemone/android-practice
Contribute to Kanmanemone/android-practice development by creating an account on GitHub.
github.com
'깨알 개념 > Android' 카테고리의 다른 글
[Android] Pointer input - Scroll (0) | 2025.02.17 |
---|---|
[Android] Pointer input - Gesture (0) | 2025.02.08 |
[Android] Pointer input - AwaitPointerEventScope()의 메소드들 (0) | 2025.02.08 |
[Android] Pointer input - PointerInputChange, PointerEvent (0) | 2025.02.07 |
[Android] Jetpack Compose - Navigation의 Destination 간 데이터 전달 (NavBackStackEntry.arguments) (0) | 2024.09.13 |