diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt index 8048096c65..34797099e1 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt @@ -27,6 +27,8 @@ import com.afternote.afternote_fe.notification.NotificationPermissionEffect import com.afternote.core.ui.Route import com.afternote.core.ui.bottombar.BottomBar import com.afternote.core.ui.navigation.FeatureStackBoundary +import com.afternote.core.ui.navigation.PredictiveBackPopEnter +import com.afternote.core.ui.navigation.PredictiveBackPopExit import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.afternote.presentation.navigation.AfternoteNavHost import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteNavHost @@ -101,6 +103,10 @@ fun AppNavigation( modifier = Modifier.padding(innerPadding), navController = appState.navController, startDestination = startDestination, + // pop 은 기본값이 fade 라 predictive back 진행 중 두 화면이 같은 크기로 겹쳐 보였다 (#1869). + // 값은 Nav3 로컬 스택이 이미 쓰는 것과 같은 것을 core/ui 에서 받아 두 엔진을 맞춘다. + popEnterTransition = { PredictiveBackPopEnter }, + popExitTransition = { PredictiveBackPopExit }, ) { // ── Navigation 3 로컬 스택을 가진 그래프 (#1698) — 루트엔 host destination 하나씩만 둔다. composable { diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/PredictiveBackTransitions.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/PredictiveBackTransitions.kt new file mode 100644 index 0000000000..1adf08d5d4 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/PredictiveBackTransitions.kt @@ -0,0 +1,44 @@ +package com.afternote.core.ui.navigation + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleOut + +/** + * predictive back 진행 중 두 화면을 그리는 전환 한 쌍 (#1869). + * + * 이 저장소는 네비게이션 엔진을 둘 쓴다 — 루트는 Navigation 2 `NavHost`, 각 피처의 로컬 스택은 + * Navigation 3 `NavDisplay` 다(#1698). **두 엔진의 기본값이 서로 다르다.** + * + * - Nav3 `NavDisplay` 는 `predictivePopTransitionSpec` 을 안 넘기면 + * `defaultPredictivePopTransitionSpec()` 을 쓴다 — 아래 두 값과 같은 모양이다 + * (navigation3-ui 1.1.6 `NavDisplay.android.kt:50`). 그래서 로컬 스택은 이미 앞 화면이 줄어든다. + * - Nav2 `NavHost` 의 `popEnterTransition`·`popExitTransition` 기본값은 `enterTransition`· + * `exitTransition` 을 그대로 받아 `fadeIn`/`fadeOut(tween(700))` 이다 + * (navigation-compose 2.9.8 `NavHost.kt:132`). 앞 화면이 줄지 않고 제자리에서 흐려지기만 해, + * 진행 중에 두 화면이 같은 크기로 겹쳐 보인다 — #1869 가 실기기에서 잡은 증상이다. + * + * 그래서 **새 값을 정하지 않고 Nav3 기본값을 그대로 복제해** 두 엔진의 눈에 보이는 동작을 맞춘다. + * 루트가 `NavDisplay` 로 바뀌면(#1702) 이 두 상수를 그대로 + * `predictivePopTransitionSpec = { ContentTransform(PredictiveBackPopEnter, PredictiveBackPopExit) }` + * 에 넘겨, 이관 전후로 화면이 달라지지 않게 한다. + * + * ### 모서리 라운딩은 여기서 못 준다 + * `ExitTransition` 은 fade·scale·slide·shrink 만 표현한다 — clip 이나 shape 는 담을 수 없다. + * Nav3 기본값도 `scaleOut` 하나뿐이라 라운딩은 어느 쪽에도 없다. 축소만으로 겹침이 풀린다는 것이 + * #1869 의 완료 조건이고, 라운딩이 따로 필요하면 별도 축으로 받는다. + */ +public val PredictiveBackPopEnter: EnterTransition = + fadeIn( + // material3 motionScheme.defaultEffectsSpec() 을 반영한 값 그대로다. + animationSpec = spring(dampingRatio = 1.0f, stiffness = 1600.0f), + ) + +/** + * 뒤로 밀려나는 앞 화면. `targetScale = 0.7f` 로 줄어들어 뒤 화면 위에 떠 보인다. + * + * 배경은 [PredictiveBackPopEnter] 의 KDoc 에 함께 적었다. + */ +public val PredictiveBackPopExit: ExitTransition = scaleOut(targetScale = 0.7f) diff --git a/docs/qa/predictive-back.md b/docs/qa/predictive-back.md index 0d07b278e6..900cf85c25 100644 --- a/docs/qa/predictive-back.md +++ b/docs/qa/predictive-back.md @@ -21,11 +21,20 @@ Nav2 백스택 회귀 기준(#1601)의 다섯 축 중 **predictive back** 만 | 항목 | 왜 자동이 안 되나 | | --- | --- | -| 진행 중 앞 화면의 축소·모서리 곡률·가장자리 여백 | 픽셀 판정이고, Robolectric 은 프레임을 돌리지 않는다 | +| 진행 중 앞 화면의 축소 비율·가장자리 여백 | 픽셀 판정이고, Robolectric 은 프레임을 돌리지 않는다 | | 진행률에 따라 미리보기가 따라오는지(스크럽 감각) | 실제 터치 홀드가 필요하다 — `adb`·계측 모두 «진행 중» 상태를 유지하지 못한다 | | 취소 시 앞 화면이 제자리로 되돌아가는 애니메이션 | 위와 같다. JVM 테스트에서는 미리보기가 끝내 사라지지 않는데, 이는 프레임이 없기 때문이지 기기 동작이 아니다 | | 시스템 back-to-home 전환(앱 밖으로) | 앱 밖 영역이라 앱 테스트의 관측 범위 밖이다 | +## 모서리 라운딩은 기대에 넣지 않는다 + +pop 전환이 주는 것은 축소(`scaleOut(targetScale = 0.7f)`)까지다. Compose 의 `ExitTransition` 은 +fade, scale, slide, shrink 만 담을 수 있어 clip 이나 shape 를 표현하지 못하고, +Nav3 로컬 스택이 쓰는 라이브러리 기본값(`defaultPredictivePopTransitionSpec()`)도 축소 하나뿐이다. + +이 문서가 처음 적어 둔 "모서리가 둥글어지며" 는 시스템 기본 동작을 보고 쓴 기대였지, +앱이 그리는 화면의 기준이 아니었다. #1869 에서 축소를 붙이며 기준 쪽을 고쳤다. + ## 절차 기기 조건 — **API 35 이상**. 앱이 `enableOnBackInvokedCallback` 을 명시하지 않지만 @@ -39,7 +48,8 @@ Nav2 백스택 회귀 기준(#1601)의 다섯 축 중 **predictive back** 만 1. 로그인 → 타임레터 탭 → 임시저장 목록으로 들어간다. 2. 화면 폭의 30% 쯤까지 끌고 **멈춘다**. - - [ ] 임시저장 화면이 축소되고 모서리가 둥글어지며, 뒤로 타임레터 홈이 비친다. + - [ ] 임시저장 화면이 줄어들어 뒤로 물러나고, 그 뒤로 타임레터 홈이 비친다. + 두 화면이 같은 크기로 겹쳐 글자가 서로 비치면 실패다(#1869). 3. 손가락을 **왼쪽으로 되돌려 놓고 뗀다**(취소). - [ ] 임시저장 화면이 원래 크기로 되돌아가고 그 화면에 남는다. 4. 다시 끌어서 화면 밖까지 완료한다. diff --git a/konsist/src/test/kotlin/com/afternote/konsist/RootNavHostPredictiveBackKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/RootNavHostPredictiveBackKonsistTest.kt new file mode 100644 index 0000000000..d8d0885bee --- /dev/null +++ b/konsist/src/test/kotlin/com/afternote/konsist/RootNavHostPredictiveBackKonsistTest.kt @@ -0,0 +1,135 @@ +package com.afternote.konsist + +import com.lemonappdev.konsist.api.declaration.KoFileDeclaration +import org.junit.Test + +/** + * 루트 `NavHost` 의 predictive back pop 전환 결선 가드 (#1869). + * + * 이 저장소는 네비게이션 엔진을 둘 쓴다. Nav3 로컬 스택은 `predictivePopTransitionSpec` 을 안 넘겨도 + * 라이브러리 기본값이 앞 화면을 줄여 주지만, **Nav2 루트 `NavHost` 의 pop 기본값은 fade 라 앞 화면이 + * 제자리에서 흐려지기만 한다** — 그래서 진행 중 두 화면이 같은 크기로 겹쳐 보였다(#1869 실기기 재현). + * + * 어긋난 축이 「화면이 어떻게 보이는가」 하나라서 기존 자동 검사가 전부 지나간다. + * `PredictiveBackNavigationTest`(5건)는 백스택 상태만 보고, Robolectric 은 프레임을 돌리지 않는다. + * `popEnterTransition`·`popExitTransition` 두 인자는 **기본값이 있어 지워도 컴파일이 통과하고**, + * 그대로 #1869 이전 상태로 되돌아간다. 그 되돌림을 소스에서 본다. + * + * ### 이 가드가 잡는 것과 못 잡는 것 + * + * 잡는 것 — 두 인자 중 하나라도 사라지는 것, 다른 값으로 갈아 끼우는 것, core/ui 의 공용 상수를 + * 두고 app 안에서 따로 정의한 값을 넘기는 것(import 까지 본다), 그리고 core/ui 쪽 상수가 + * 이름을 바꾸거나 사라지는 것. + * + * 못 잡는 것 — 상수의 **값**이 Nav3 기본값에서 벗어나는 것. 픽셀 판정이라 텍스트로는 셀 수 없고, + * 그 몫은 `docs/qa/predictive-back.md` 의 수동 절차가 받는다. + * + * 전진(push) 쪽 `enterTransition`·`exitTransition` 은 보지 않는다 — #1869 의 완료 조건 밖이라 + * 손대지 않았고, 없는 결선을 요구하면 이 가드가 그 자체로 오탐이 된다. + */ +class RootNavHostPredictiveBackKonsistTest { + @Test + fun `루트 NavHost 는 core ui 의 predictive back pop 전환을 넘긴다`() { + // 공유 진입점을 쓴다 — 워크트리 사본이 섞이면 남의 브랜치 소스로 헛짚는다 (#1659, ScanScopeKonsistTest). + val source = singleFile(APP_NAVIGATION_PATH).text + val arguments = rootNavHostArguments(source) + + check(arguments != null) { + "$APP_NAVIGATION_PATH 에서 루트 «NavHost(» 호출을 찾지 못했다 — 루트가 NavDisplay 로 바뀌었다면(#1702) " + + "이 가드를 predictivePopTransitionSpec 기준으로 옮긴다." + } + + EXPECTED_BINDINGS.forEach { (parameter, constant) -> + val binding = + Regex("""$parameter\s*=\s*([^,\n]+)""") + .find(arguments) + ?.groupValues + ?.get(1) + ?.trim() + + check(binding != null) { + "루트 NavHost 에 $parameter 인자가 없다 (#1869).\n" + + "기본값은 fade 라, 지우면 predictive back 진행 중 앞 화면이 줄지 않고 뒤 화면과 겹쳐 그려진다.\n" + + " $parameter = { $constant }," + } + check(binding.references(constant)) { + "루트 NavHost 의 $parameter 가 «$constant» 이 아니라 «$binding» 을 넘긴다 (#1869)." + } + // 같은 이름을 app 안에서 새로 정의해도 위 단언은 통과한다 — 출처가 core/ui 인지 import 로 본다. + check(source.contains("import $CONSTANT_PACKAGE.$constant")) { + "$APP_NAVIGATION_PATH 가 $CONSTANT_PACKAGE.$constant 을 import 하지 않는다 — " + + "두 엔진이 같은 값을 쓰도록 상수는 core/ui 한 곳에 둔다 (#1869)." + } + } + } + + @Test + fun `predictive back pop 전환은 core ui 가 소유한다`() { + val declared = singleFile(TRANSITIONS_PATH).properties().map { it.name }.toSet() + val missing = EXPECTED_BINDINGS.values.filterNot(declared::contains) + + check(missing.isEmpty()) { + "$TRANSITIONS_PATH 에 ${missing.joinToString()} 이 없다 — 루트 NavHost 와 #1702 의 NavDisplay 가 " + + "같은 값을 넘기려면 이 파일이 단일 출처여야 한다 (#1869)." + } + } + + private fun singleFile(projectPath: String): KoFileDeclaration { + val candidates = AfternoteKonsistScope.files.filter { it.normalizedProjectPath() == projectPath } + + check(candidates.size == 1) { + "$projectPath 가 ${candidates.size}건 잡혔다 — 파일이 옮겨졌거나 사본이 섞였다.\n" + + candidates.joinToString("\n") { " " + it.path } + } + return candidates.single() + } + + /** + * 루트 `NavHost(` 의 여는 괄호부터 짝이 맞는 닫는 괄호까지를 돌려준다. 후행 람다(destination 등록부 + * 40여 개)는 괄호 밖이라 들어오지 않는다. + * + * 이름은 식별자 경계로 찾는다 — 같은 파일에 `AfternoteNavHost(`·`ReceiverNavHost(` 처럼 끝이 + * 겹치는 호출이 여럿이라, 단순 `indexOf` 는 그중 무엇이 먼저 오느냐에 결과가 달라진다. + */ + private fun rootNavHostArguments(source: String): String? { + val open = NAV_HOST_CALL.find(source)?.range?.last ?: return null + var depth = 0 + for (index in open until source.length) { + when (source[index]) { + '(' -> { + depth++ + } + + ')' -> { + depth-- + if (depth == 0) return source.substring(open + 1, index) + } + } + } + return null + } + + /** 이름을 «식별자로» 참조하는지 — 부분 일치(`PredictiveBackPopEnterOverride`)를 배제한다. */ + private fun String.references(name: String): Boolean = + Regex("""(?