fix: 동시 터치로 스택에 화면 여러개가 쌓이는 문제 해결#166
Conversation
Walkthrough동시 터치 차단용 Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant U as 사용자
participant C as Composable
participant M as Modifier.preventMultiTouch
participant Child as 하위 컴포저블
U->>C: pointer1 down
C->>M: pointerInput 시작
M-->>Child: pointer1 전달
U->>C: pointer2 down (동시)
C->>M: pointer2 (Initial)
M--xChild: pointer2 소비(차단)
U->>C: pointer1 up
M-->>Child: 세션 종료
sequenceDiagram
autonumber
participant P as BookDetailPresenter
participant Repo as Repository
participant UI as UiState
P->>Repo: 초기 데이터 로드 (코루틴)
alt 정상
Repo-->>P: 데이터 반환
P->>UI: UiState.Success
else CancellationException
Repo-->>P: CancellationException 발생
P-->>Repo: 재던짐 (propagate)
Note right of P: 취소는 오류 상태로 전환되지 않음
else 기타 예외
Repo-->>P: Throwable 발생
P->>UI: UiState.Error
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Assessment against linked issues
Assessment against linked issues: Out-of-scope changes
Poem
Tip 🔌 Remote MCP (Model Context Protocol) integration is now available!Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats. ✨ Finishing Touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt (1)
124-126: 취소는 에러가 아니다: CancellationException 재던지기 올바름CancellationException을 일반 Throwable 처리로 흘리지 않고 즉시 재던지도록 한 점이 적절합니다. 네비게이션 전환·구성 종료 시 로딩/토스트가 잘못 표시되는 문제를 방지할 수 있습니다.
부가 제안:
- initialLoad 내부에서 rememberCoroutineScope.launch를 다시 사용하지 않고, initialLoad를 suspend 함수로 바꿔 LaunchedEffect가 제공하는 코루틴 스코프에서 직접 실행하면 스코프 중복을 줄이고 취소 전파가 더 일관됩니다.
예시(참고용, 파일 전반 수정 필요):
// suspend fun으로 변경 suspend fun initialLoad() { uiState = UiState.Loading try { coroutineScope { // async 동시 호출 동일 } uiState = UiState.Success } catch (ce: CancellationException) { throw ce } catch (e: Throwable) { uiState = UiState.Error(e) // handleException 동일 } } // 사용처 LaunchedEffect(Unit) { initialLoad() } // OnRetryClick 에서도 scope.launch 없이 initialLoad() 호출 가능core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/ReedScaffold.kt (1)
26-28: Modifier 체인 순서 적절하며, opt-out 가능성 고려 제안
- 현재
.keyboardHide().preventMultiTouch()순서는 preventMultiTouch가 보다 바깥쪽에서 초기 패스에 이벤트를 선점하도록 구성되어 바람직합니다.- 향후 특정 화면(예: 이미지 확대/핀치 제스처 필요)에서 멀티 터치가 필요할 수 있으므로 ReedScaffold에 opt-out 파라미터를 제공하는 것을 권장합니다.
부분 diff(해당 블록 내에서의 변경 예):
- modifier = modifier - .keyboardHide() - .preventMultiTouch(), + modifier = modifier + .keyboardHide() + .let { if (blockMultiTouch) it.preventMultiTouch() else it },추가 변경(시그니처에 파라미터 추가):
@Composable fun ReedScaffold( modifier: Modifier = Modifier, topBar: @Composable () -> Unit = {}, bottomBar: @Composable () -> Unit = {}, snackbarHost: @Composable () -> Unit = {}, floatingActionButton: @Composable () -> Unit = {}, containerColor: Color = White, contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, blockMultiTouch: Boolean = true, // ← 추가 content: @Composable (PaddingValues) -> Unit, )
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (4)
core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Modifier.kt(3 hunks)core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/ReedScaffold.kt(2 hunks)feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt(2 hunks)feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUi.kt(2 hunks)
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: seoyoon513
PR: YAPP-Github/Reed-Android#46
File: feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/InfiniteLazyColumn.kt:83-95
Timestamp: 2025-07-14T00:46:03.843Z
Learning: seoyoon513과 팀은 한국어 주석을 선호하며, 한국어 주석을 영어로 번역하라는 제안을 하지 않아야 함
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: ci-build
🔇 Additional comments (4)
feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt (1)
41-41: CancellationException import 추가 적절취소를 일반 오류로 처리하지 않기 위한 분기 도입에 필요한 의존성입니다.
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUi.kt (2)
20-20: preventMultiTouch import 추가 OK해당 화면에만 국소적으로 멀티 터치 차단을 적용하려는 의도가 명확합니다.
50-52: Scaffold에 멀티 터치 차단 적용 적절기록 등록 플로우에서 중복 입력/중복 네비게이션을 원천 차단하는 데 유효합니다.
확인 요청:
- 이 화면이 상위에서 ReedScaffold를 사용하지 않는지(=중복 적용되지 않는지) 확인 부탁드립니다. 만약 상위에서도 preventMultiTouch가 적용된다면 중복 효과 자체는 문제 없지만, 불필요한 pointerInput 오버헤드를 줄이려면 한 군데만 유지하는 편이 좋습니다.
core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/ReedScaffold.kt (1)
10-10: 전역 적용 import 적절공통 Scaffold에서 멀티 터치 차단을 일괄 적용하려는 목적과 부합합니다.
| /** | ||
| * 부모 영역에서 동시 터치(두 손가락 이상)를 차단하는 Modifier | ||
| */ | ||
| fun Modifier.preventMultiTouch() = pointerInput(Unit) { | ||
| // awaitEachGesture: 한 번의 제스쳐 세션을 추상화 | ||
| awaitEachGesture { | ||
| val first = awaitFirstDown(requireUnconsumed = false) | ||
| do { | ||
| // 이벤트 전파 초기 단계(PointerEventPass.Initial)에서 하위 컴포저블로 이벤트가 내려가기 전에 가로채 소비한다 | ||
| val event = awaitPointerEvent(pass = PointerEventPass.Initial) | ||
| event.changes.forEach { change -> | ||
| if (change.id != first.id && change.pressed) change.consume() | ||
| } | ||
| // 루프 조건: 첫 포인터가 pressed 상태일 동안만 유지한다 (up이거나 cancel되면 pressed=false로 루프 종료) | ||
| } while (event.changes.any { it.id == first.id && it.pressed }) | ||
| } | ||
| } |
There was a problem hiding this comment.
💡 Verification agent
❓ Verification inconclusive
컴파일 불가: do-while 범위에서 event 참조 불가 및 awaitPointerEvent import 누락
- do { val event = ... } while (event ...) 패턴은 Kotlin에서 event가 while 조건의 스코프에 존재하지 않아 컴파일 에러가 발생합니다.
- awaitPointerEvent를 사용하지만 import가 없습니다(Compose 버전에 따라 필요). 일반적으로
import androidx.compose.ui.input.pointer.awaitPointerEvent가 요구됩니다. - 동작 측면에서도 루프 종료 조건을 첫 포인터의 pressed 상태로 명시적으로 판단하는 편이 가독성과 안전성 측면에서 낫습니다.
아래 diff로 수정 제안합니다.
@@
-import androidx.compose.foundation.gestures.awaitEachGesture
-import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
@@
-import androidx.compose.ui.input.pointer.PointerEventPass
-import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.awaitPointerEvent
+import androidx.compose.ui.input.pointer.pointerInput
@@
fun Modifier.preventMultiTouch() = pointerInput(Unit) {
// awaitEachGesture: 한 번의 제스쳐 세션을 추상화
awaitEachGesture {
val first = awaitFirstDown(requireUnconsumed = false)
- do {
- // 이벤트 전파 초기 단계(PointerEventPass.Initial)에서 하위 컴포저블로 이벤트가 내려가기 전에 가로채 소비한다
- val event = awaitPointerEvent(pass = PointerEventPass.Initial)
- event.changes.forEach { change ->
- if (change.id != first.id && change.pressed) change.consume()
- }
- // 루프 조건: 첫 포인터가 pressed 상태일 동안만 유지한다 (up이거나 cancel되면 pressed=false로 루프 종료)
- } while (event.changes.any { it.id == first.id && it.pressed })
+ while (true) {
+ // 이벤트 전파 초기 단계(PointerEventPass.Initial)에서 하위로 내려가기 전에 가로채 소비
+ val event = awaitPointerEvent(pass = PointerEventPass.Initial)
+ // 첫 포인터를 제외한 모든 pressed 포인터를 소비
+ event.changes.forEach { change ->
+ if (change.id != first.id && change.pressed) change.consume()
+ }
+ // 첫 포인터가 더 이상 눌려있지 않다면 제스처 세션 종료
+ val firstStillPressed = event.changes.any { it.id == first.id && it.pressed }
+ if (!firstStillPressed) break
+ }
}
}추가로, 멀티 터치 차단이 전역(Scaffold 상위)에서 이뤄지므로 향후 핀치 줌 등 멀티 터치가 필요한 화면이 생길 경우 opt-out 전략이 필요합니다(아래 ReedScaffold 코멘트 참고).
컴파일 오류: do-while 스코프 및 import 누락 확인
다음 사항이 확인되어 수정이 필요합니다.
• 위치: core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Modifier.kt 62–78행
- do-while 블록에서
val event를 선언한 스코프가 while 조건문(while (event…))에 닿지 않아
Unresolved reference: event컴파일 에러 발생 awaitPointerEvent호출 시 해당 함수에 대한import androidx.compose.ui.input.pointer.awaitPointerEvent가 없어
Unresolved reference: awaitPointerEvent컴파일 에러 발생
• 제안된 수정 (예시 diff)
@@ -1,10 +1,12 @@
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.awaitPointerEvent
import androidx.compose.ui.input.pointer.pointerInput
fun Modifier.preventMultiTouch() = pointerInput(Unit) {
awaitEachGesture {
val first = awaitFirstDown(requireUnconsumed = false)
- do {
- val event = awaitPointerEvent(pass = PointerEventPass.Initial)
- event.changes.forEach { change ->
- if (change.id != first.id && change.pressed) change.consume()
- }
- } while (event.changes.any { it.id == first.id && it.pressed })
+ while (true) {
+ val event = awaitPointerEvent(pass = PointerEventPass.Initial)
+ event.changes.forEach { change ->
+ if (change.id != first.id && change.pressed) change.consume()
+ }
+ if (event.changes.none { it.id == first.id && it.pressed }) break
+ }
}
}추가로, 이 Modifier가 전역에서 멀티 터치를 차단하므로 추후 핀치 줌 등 멀티 터치가 필요한 화면이 도입될 경우 해당 Composable에서 preventMultiTouch()를 제외하거나 opt-out 전략을 적용할 수 있도록 검토해 주세요.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /** | |
| * 부모 영역에서 동시 터치(두 손가락 이상)를 차단하는 Modifier | |
| */ | |
| fun Modifier.preventMultiTouch() = pointerInput(Unit) { | |
| // awaitEachGesture: 한 번의 제스쳐 세션을 추상화 | |
| awaitEachGesture { | |
| val first = awaitFirstDown(requireUnconsumed = false) | |
| do { | |
| // 이벤트 전파 초기 단계(PointerEventPass.Initial)에서 하위 컴포저블로 이벤트가 내려가기 전에 가로채 소비한다 | |
| val event = awaitPointerEvent(pass = PointerEventPass.Initial) | |
| event.changes.forEach { change -> | |
| if (change.id != first.id && change.pressed) change.consume() | |
| } | |
| // 루프 조건: 첫 포인터가 pressed 상태일 동안만 유지한다 (up이거나 cancel되면 pressed=false로 루프 종료) | |
| } while (event.changes.any { it.id == first.id && it.pressed }) | |
| } | |
| } | |
| // File: core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Modifier.kt | |
| import androidx.compose.foundation.gestures.awaitEachGesture | |
| import androidx.compose.foundation.gestures.awaitFirstDown | |
| import androidx.compose.ui.input.pointer.PointerEventPass | |
| import androidx.compose.ui.input.pointer.awaitPointerEvent | |
| import androidx.compose.ui.input.pointer.pointerInput | |
| /** | |
| * 부모 영역에서 동시 터치(두 손가락 이상)를 차단하는 Modifier | |
| */ | |
| fun Modifier.preventMultiTouch() = pointerInput(Unit) { | |
| // awaitEachGesture: 한 번의 제스쳐 세션을 추상화 | |
| awaitEachGesture { | |
| val first = awaitFirstDown(requireUnconsumed = false) | |
| - do { | |
| - // 이벤트 전파 초기 단계(PointerEventPass.Initial)에서 하위 컴포저블로 이벤트가 내려가기 전에 가로채 소비한다 | |
| - val event = awaitPointerEvent(pass = PointerEventPass.Initial) | |
| - event.changes.forEach { change -> | |
| - if (change.id != first.id && change.pressed) change.consume() | |
| - } | |
| - // 루프 조건: 첫 포인터가 pressed 상태일 동안만 유지한다 (up이거나 cancel되면 pressed=false로 루프 종료) | |
| while (true) { | |
| // 이벤트 전파 초기 단계(PointerEventPass.Initial)에서 하위 컴포저블로 이벤트가 내려가기 전에 가로채 소비한다 | |
| val event = awaitPointerEvent(pass = PointerEventPass.Initial) | |
| event.changes.forEach { change -> | |
| if (change.id != first.id && change.pressed) change.consume() | |
| } | |
| // 첫 포인터가 더 이상 pressed 상태가 아니면 루프 종료 | |
| if (event.changes.none { it.id == first.id && it.pressed }) break | |
| } | |
| } | |
| } |
There was a problem hiding this comment.
@easyhooon 네, 맞습니다! 현재 코드는 컴파일 에러가 발생하므로 반드시 수정이 필요합니다.
주요 수정 사항:
awaitPointerEventimport 추가- do-while 스코프 문제 해결 (while(true) + break 패턴으로 변경)
이 수정들이 적용되어야 preventMultiTouch() 함수가 정상적으로 작동하고 동시 터치 차단 기능이 제대로 동작할 것입니다.
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (1)
core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Modifier.kt (1)
70-73: 컴파일 에러: awaitPointerEvent import 누락
awaitPointerEvent가 호출되지만 import가 없어Unresolved reference가 발생합니다. 지난 코멘트에서도 동일 지적이 있었으니 이번에 같이 반영 부탁드립니다.아래 diff로 import 추가:
@@ import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.awaitPointerEvent import androidx.compose.ui.input.pointer.pointerInput
🧹 Nitpick comments (2)
core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Modifier.kt (2)
73-77: 보조 포인터 소비를 Down 중심으로 한정(Optional)현재는 보조 포인터의 모든 변경을
change.consume()로 소비합니다. 클릭/네비게이션 중복 방지가 목적이라면 Down 변경만 확실히 막아도 충분한 경우가 많습니다. Compose 버전에 따라 가능하다면 Down 소비를 우선 적용하는 편이 명시적입니다. (버전에 따라 API 유무가 다를 수 있으므로 미지원이면 현행 유지 OK)- event.changes.forEach { change -> - if (change.id != first.id && change.pressed) { - change.consume() - } - } + event.changes.forEach { change -> + if (change.id != first.id && change.pressed) { + // 보조 포인터의 Down/Move를 명시적으로 소비 + change.consumeDownChange() + change.consumePositionChange() + } + }
62-65: 전역 적용 시 주의사항 KDoc에 보강 제안Scaffold 상위에 전역 적용되면 핀치줌(지도/이미지 뷰어), 멀티터치 회전 등도 차단됩니다. KDoc에 “멀티터치가 필요한 화면은 적용하지 말 것(또는 opt-out 제공)” 경고를 추가해 두면 재사용성이 좋아집니다.
예시 KDoc 보강:
/** - * 부모 영역에서 동시 터치(두 손가락 이상)를 차단하는 Modifier + * 부모 영역에서 동시 터치(두 손가락 이상)를 차단하는 Modifier. + * + * 주의: + * - 상위(예: Scaffold)에 전역 적용 시 핀치줌/회전 등 멀티터치 제스처가 필요한 화면에서도 차단됩니다. + * - 멀티터치가 필요한 화면은 이 Modifier를 적용하지 않거나, 상위 컴포넌트에서 opt-out 플래그를 제공하세요. */또한 ReedScaffold에 기본 적용하셨다면
allowMultiTouch: Boolean = false같은 플래그로 조건부 적용하는 구조를 권장합니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Modifier.kt(3 hunks)
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: seoyoon513
PR: YAPP-Github/Reed-Android#46
File: feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/InfiniteLazyColumn.kt:83-95
Timestamp: 2025-07-14T00:46:03.843Z
Learning: seoyoon513과 팀은 한국어 주석을 선호하며, 한국어 주석을 영어로 번역하라는 제안을 하지 않아야 함
🔇 Additional comments (4)
core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Modifier.kt (4)
4-5: 제스처 유틸 import 추가 적절동시 터치 제어에 필요한
awaitEachGesture,awaitFirstDown도입 방향 타당합니다.
14-15: Pointer 입력 관련 import 추가 OK
PointerEventPass,pointerInput추가 적절합니다. 이후 코멘트대로awaitPointerEventimport도 함께 보강해 주세요.
70-80: do-while 스코프 이슈 해결 확인
while (true) { … break }패턴으로 변경되어 스코프 문제 및 구성 불가 이슈가 해소되었습니다. 루프 종료 조건도 명확해졌습니다.
70-80: 접근성(Accessibility) 동작 검증 권장TalkBack 등 보조 기술 사용 시 멀티 핑거 제스처와의 상호작용을 수동 검증해 주세요. 전역 차단이 특정 화면의 접근성 제스처를 간섭할 가능성을 배제할 수 없습니다. 최소 체크리스트:
- TalkBack 활성화 후 주요 화면(홈, 내서재, 상세)에서 단일 터치·두 손가락 스크롤·제스처 뒤로가기 동작 확인
- 확대/축소(접근성 확대 제스처) 충돌 여부 확인
🔗 관련 이슈
📙 작업 설명
Summary by CodeRabbit