Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Route.Onboarding> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 12 additions & 2 deletions docs/qa/predictive-back.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 을 명시하지 않지만
Expand All @@ -39,7 +48,8 @@ Nav2 백스택 회귀 기준(#1601)의 다섯 축 중 **predictive back** 만

1. 로그인 → 타임레터 탭 → 임시저장 목록으로 들어간다.
2. 화면 폭의 30% 쯤까지 끌고 **멈춘다**.
- [ ] 임시저장 화면이 축소되고 모서리가 둥글어지며, 뒤로 타임레터 홈이 비친다.
- [ ] 임시저장 화면이 줄어들어 뒤로 물러나고, 그 뒤로 타임레터 홈이 비친다.
두 화면이 같은 크기로 겹쳐 글자가 서로 비치면 실패다(#1869).
3. 손가락을 **왼쪽으로 되돌려 놓고 뗀다**(취소).
- [ ] 임시저장 화면이 원래 크기로 되돌아가고 그 화면에 남는다.
4. 다시 끌어서 화면 밖까지 완료한다.
Expand Down
Original file line number Diff line number Diff line change
@@ -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("""(?<![A-Za-z0-9_])${Regex.escape(name)}(?![A-Za-z0-9_])""").containsMatchIn(this)

// 다른 가드와 같은 관례 — konsist 가 주는 projectPath 를 OS 구분자·선행 슬래시 없이 맞춘다.
private fun KoFileDeclaration.normalizedProjectPath(): String = projectPath.replace('\\', '/').trimStart('/')

private companion object {
const val APP_NAVIGATION_PATH = "app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt"
const val TRANSITIONS_PATH =
"core/ui/src/main/kotlin/com/afternote/core/ui/navigation/PredictiveBackTransitions.kt"
const val CONSTANT_PACKAGE = "com.afternote.core.ui.navigation"

/** 루트 `NavHost(` — `AfternoteNavHost(` 처럼 끝만 겹치는 호출을 배제한다. */
val NAV_HOST_CALL = Regex("""(?<![A-Za-z0-9_])NavHost\(""")

/** 루트 `NavHost` 인자 → 넘어가야 하는 core/ui 상수. */
val EXPECTED_BINDINGS =
mapOf(
"popEnterTransition" to "PredictiveBackPopEnter",
"popExitTransition" to "PredictiveBackPopExit",
)
}
}
Loading