@momo-kits/native-kits 0.162.34-debug → 0.162.35-debug

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CLAUDE.md +2 -0
  2. package/compose/build.gradle.kts +1 -1
  3. package/compose/compose.podspec +1 -1
  4. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +12 -0
  5. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -1
  6. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +18 -0
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +6 -1
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +30 -4
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +1 -1
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +3 -1
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +8 -1
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +9 -4
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +4 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +3 -0
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +104 -22
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +3 -9
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +8 -4
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +2 -1
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +18 -0
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +2 -2
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +38 -5
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +58 -0
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +3 -0
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +62 -33
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +22 -1
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +25 -5
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +148 -0
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +35 -0
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +19 -0
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +13 -2
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +106 -0
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +75 -0
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +3 -0
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +5 -1
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +12 -0
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -1
  37. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +45 -0
  38. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +38 -0
  39. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +10 -4
  40. package/gradle/libs.versions.toml +1 -1
  41. package/gradle.properties +1 -1
  42. package/ios/Application/Navigation/NavigatorManager.swift +0 -27
  43. package/ios/Colors+Radius+Spacing/Theme.swift +2 -2
  44. package/ios/Typography/KitDesignSystem.swift +17 -0
  45. package/ios/Typography/Text.swift +25 -1
  46. package/ios/native-kits.podspec +1 -1
  47. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  48. package/package.json +1 -1
  49. package/publish.sh +25 -0
@@ -0,0 +1,58 @@
1
+ package vn.momo.kits.navigation
2
+
3
+ interface HostBackTarget {
4
+ fun canPop(): Boolean
5
+
6
+ fun onBack()
7
+
8
+ fun backStarted()
9
+ fun backProgressed(progress: Float)
10
+ fun backCompleted()
11
+ fun backCancelled()
12
+ }
13
+
14
+ object HostBackGestureBridge {
15
+ private val targets = mutableListOf<HostBackTarget>()
16
+ private var gestureTarget: HostBackTarget? = null
17
+
18
+ var shouldUseFloatingArrowBack: Boolean = false
19
+
20
+ fun checkCanPop(): Boolean = targets.isNotEmpty()
21
+
22
+ fun canPopInner(): Boolean = top()?.canPop() == true
23
+
24
+ fun pop() {
25
+ top()?.onBack()
26
+ }
27
+
28
+ fun started() {
29
+ val target = top() ?: return
30
+ gestureTarget = target
31
+ target.backStarted()
32
+ }
33
+
34
+ fun progressed(progress: Float) {
35
+ gestureTarget?.backProgressed(progress.coerceIn(0f, 1f))
36
+ }
37
+
38
+ fun completed() {
39
+ (gestureTarget ?: top())?.backCompleted()
40
+ gestureTarget = null
41
+ }
42
+
43
+ fun cancelled() {
44
+ gestureTarget?.backCancelled()
45
+ gestureTarget = null
46
+ }
47
+
48
+ fun attach(target: HostBackTarget) {
49
+ targets += target
50
+ }
51
+
52
+ fun detach(target: HostBackTarget) {
53
+ targets -= target
54
+ if (gestureTarget === target) gestureTarget = null
55
+ }
56
+
57
+ private fun top(): HostBackTarget? = targets.lastOrNull()
58
+ }
@@ -33,6 +33,7 @@ class Navigation(
33
33
  tintColor: Color? = null,
34
34
  footerComponent: (@Composable () -> Unit)? = null,
35
35
  footerTransparent: Boolean? = null,
36
+ footerUseMargin: Boolean? = null,
36
37
  floatingButtonProps: FloatingButtonProps? = null,
37
38
  keyboardOptions: KeyboardOptions? = null
38
39
  ) {
@@ -50,6 +51,7 @@ class Navigation(
50
51
  tintColor = tintColor ?: options.tintColor,
51
52
  footerComponent = footerComponent ?: options.footerComponent,
52
53
  footerTransparent = footerTransparent ?: options.footerTransparent,
54
+ footerUseMargin = footerUseMargin ?: options.footerUseMargin,
53
55
  floatingButtonProps = floatingButtonProps ?: options.floatingButtonProps,
54
56
  keyboardOptions = keyboardOptions ?: options.keyboardOptions,
55
57
  )
@@ -86,6 +88,7 @@ data class NavigationOptions(
86
88
  val tintColor: Color? = null,
87
89
  val footerComponent: @Composable (() -> Unit)? = null,
88
90
  val footerTransparent: Boolean = false,
91
+ val footerUseMargin: Boolean = true,
89
92
  val floatingButtonProps: FloatingButtonProps? = null,
90
93
  val keyboardOptions: KeyboardOptions = KeyboardOptions()
91
94
  ) {
@@ -2,9 +2,7 @@ package vn.momo.kits.navigation
2
2
 
3
3
  import androidx.compose.animation.core.tween
4
4
  import androidx.compose.animation.fadeIn
5
- import androidx.compose.animation.slideInHorizontally
6
5
  import androidx.compose.animation.slideInVertically
7
- import androidx.compose.animation.slideOutHorizontally
8
6
  import androidx.compose.animation.slideOutVertically
9
7
  import androidx.compose.runtime.Composable
10
8
  import androidx.compose.runtime.CompositionLocalProvider
@@ -19,7 +17,12 @@ import androidx.navigation.compose.NavHost
19
17
  import androidx.navigation.compose.composable
20
18
  import androidx.navigation.compose.rememberNavController
21
19
  import androidx.navigation.toRoute
20
+ import kotlinx.coroutines.delay
21
+ import kotlinx.coroutines.flow.collectLatest
22
+ import vn.momo.kits.application.DesignSystemConfig
23
+ import vn.momo.kits.application.KitDesignSystemConfig
22
24
  import vn.momo.kits.application.LocalContext
25
+ import vn.momo.kits.application.LocalDesignSystemConfig
23
26
  import vn.momo.kits.application.LocalLocalize
24
27
  import vn.momo.kits.application.Localize
25
28
  import vn.momo.kits.application.MiniAppContext
@@ -33,18 +36,20 @@ import vn.momo.kits.platform.ProvideOsFontScale
33
36
  import vn.momo.kits.utils.getAppStatusBarHeight
34
37
  import vn.momo.kits.utils.getNavigationBarHeight
35
38
  import vn.momo.maxapi.IMaxApi
39
+ import kotlin.time.Duration.Companion.milliseconds
36
40
 
37
41
  @Composable
38
42
  fun NavigationContainer(
39
43
  initialScreenName: String,
40
44
  initialScreen: @Composable () -> Unit,
45
+ maxApi: IMaxApi,
41
46
  options: NavigationOptions? = null,
42
47
  initialTheme: Theme = defaultTheme,
43
48
  miniAppContext: MiniAppContext? = null,
44
- maxApi: IMaxApi? = null,
45
49
  setNavigator: ((Navigator) -> Unit)? = null,
46
50
  statusBarHeight: Dp? = null,
47
51
  localize: Localize? = null,
52
+ designSystemConfig: DesignSystemConfig? = null,
48
53
  ) {
49
54
  val navController = rememberNavController()
50
55
  val registry = remember { DynamicScreenRegistry() }
@@ -55,7 +60,19 @@ fun NavigationContainer(
55
60
  val parentContext = LocalContext.current
56
61
  val mergedContext = MiniAppContext.merge(parentContext, miniAppContext)
57
62
 
63
+ val stackTransition = remember { StackTransitionCoordinator() }
58
64
  val theme = remember { mutableStateOf(initialTheme) }
65
+ val resolvedDesignSystemConfig =
66
+ designSystemConfig ?: LocalDesignSystemConfig.current ?: KitDesignSystemConfig.config
67
+ val headerBar = resolvedDesignSystemConfig?.headerBar
68
+ // RN parity: the theme's own header image wins; config headerBar is only the fallback.
69
+ val resolvedTheme = remember(theme.value, headerBar) {
70
+ if (theme.value.isHeaderImage() || headerBar.isNullOrEmpty()) {
71
+ theme.value
72
+ } else {
73
+ theme.value.copy(assets = theme.value.assets.copy(headerBackground = headerBar))
74
+ }
75
+ }
59
76
  val fallbackLocalize = remember { Localize() }
60
77
 
61
78
  val resolvedLocalize = localize ?: fallbackLocalize
@@ -76,48 +93,58 @@ fun NavigationContainer(
76
93
  val startDestination = remember(screenId) { DynamicScreenRoute(screenId) }
77
94
 
78
95
  ProvideOsFontScale {
79
- ProvideNavigationEventDispatcherOwner {
96
+ ProvideNavigationEventDispatcherOwner(navigator) {
80
97
  CompositionLocalProvider(
81
98
  LocalNavigator provides navigator,
82
99
  LocalDynamicScreenRegistry provides registry,
83
100
  LocalMaxApi provides maxApi,
84
- AppTheme provides theme.value,
101
+ AppTheme provides resolvedTheme,
85
102
  AppStatusBar provides statusBarHeight,
86
103
  AppNavigationBar provides navigationBarHeight,
87
104
  LocalContext provides mergedContext,
105
+ LocalDesignSystemConfig provides resolvedDesignSystemConfig,
88
106
  LocalLocalize provides resolvedLocalize,
107
+ LocalStackTransition provides stackTransition,
89
108
  ) {
90
109
  LaunchedEffect(Unit) {
91
110
  setNavigator?.invoke(navigator)
92
111
  }
93
112
 
94
- NavHost(navController, startDestination = startDestination) {
95
- composable<DynamicScreenRoute>(
96
- enterTransition = {
97
- slideInHorizontally(
98
- animationSpec = tween(300),
99
- initialOffsetX = { it }
100
- )
101
- },
102
- exitTransition = null,
103
- popEnterTransition = { fadeIn(animationSpec = tween(0)) },
104
- popExitTransition = {
105
- slideOutHorizontally(
106
- animationSpec = tween(300),
107
- targetOffsetX = { it }
108
- )
113
+ SwipeBackHaptic(maxApi)
114
+
115
+ LaunchedEffect(navController, registry) {
116
+ navController.currentBackStack.collectLatest { entries ->
117
+ delay(STACK_TRANSITION_DURATION.milliseconds)
118
+ val aliveIds = entries.mapNotNullTo(mutableSetOf()) { entry ->
119
+ runCatching { entry.toRoute<DynamicScreenRoute>().id }.getOrNull()
120
+ ?: runCatching { entry.toRoute<DynamicDialogRoute>().id }.getOrNull()
109
121
  }
110
- ) { backStackEntry ->
122
+ aliveIds.add(screenId)
123
+ registry.retainOnly(aliveIds)
124
+ }
125
+ }
126
+
127
+ NavHost(
128
+ navController,
129
+ startDestination = startDestination,
130
+ enterTransition = { stackEnter() },
131
+ exitTransition = { stackExit() },
132
+ popEnterTransition = { stackPopEnter() },
133
+ popExitTransition = { stackPopExit() },
134
+ ) {
135
+ composable<DynamicScreenRoute> { backStackEntry ->
111
136
  val route = backStackEntry.toRoute<DynamicScreenRoute>()
112
137
  val screen = registry.getScreen(route.id)
113
138
 
114
139
  if (screen != null) {
115
- StackScreen(
116
- id = route.id,
117
- name = screen.name,
118
- content = screen.content,
119
- navigationOptions = screen.options
120
- )
140
+ StackTransitionDecor(id = route.id, isStackRoute = true) {
141
+ StackScreen(
142
+ id = route.id,
143
+ name = screen.name,
144
+ content = screen.content,
145
+ navigationOptions = screen.options
146
+ )
147
+ }
121
148
  }
122
149
  }
123
150
 
@@ -141,12 +168,14 @@ fun NavigationContainer(
141
168
  val screen = registry.getScreen(route.id)
142
169
 
143
170
  if (screen != null) {
144
- StackScreen(
145
- id = route.id,
146
- name = screen.name,
147
- content = screen.content,
148
- navigationOptions = screen.options
149
- )
171
+ StackTransitionDecor(id = route.id, isStackRoute = false) {
172
+ StackScreen(
173
+ id = route.id,
174
+ name = screen.name,
175
+ content = screen.content,
176
+ navigationOptions = screen.options
177
+ )
178
+ }
150
179
  }
151
180
  }
152
181
  }
@@ -31,6 +31,9 @@ class Navigator(
31
31
  ) {
32
32
  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
33
33
 
34
+ // currentScreen() reads the root back stack, so it never sees the nested tab NavHost.
35
+ internal var tabBackHandler: (() -> Unit)? = null
36
+
34
37
  private fun currentScreen(): DynamicScreen? {
35
38
  val entry = navController.currentBackStackEntry ?: return null
36
39
  val id = runCatching { entry.toRoute<DynamicScreenRoute>().id }.getOrNull()
@@ -52,6 +55,10 @@ class Navigator(
52
55
  }
53
56
 
54
57
  fun onBackSafe(callBack: (() -> Unit)? = null) {
58
+ tabBackHandler?.let {
59
+ it()
60
+ return
61
+ }
55
62
  val currentScreen = currentScreen()
56
63
  val options = currentScreen?.options
57
64
  if (options?.onBackHandler != null) {
@@ -78,6 +85,11 @@ class Navigator(
78
85
  }
79
86
  }
80
87
 
88
+ // The swipe-back opt-out predicate in StackScreen needs this: the root screen must keep custom
89
+ // back handling so it still reaches maxApi.dismiss.
90
+ // Public: the iOS host asks this to decide whether the kit owns the swipe on this screen.
91
+ fun canPop(): Boolean = navController.previousBackStackEntry != null
92
+
81
93
  fun pop(count: Int = 1, callBack: (() -> Unit)? = null) {
82
94
  scope.launch {
83
95
  repeat(count) {
@@ -104,7 +116,7 @@ class Navigator(
104
116
  if (navController.previousBackStackEntry != null){
105
117
  val dismissing = currentScreen()
106
118
  navController.popBackStack()
107
- delay(300L.milliseconds)
119
+ delay(STACK_TRANSITION_DURATION.milliseconds)
108
120
  dismissing?.let { registry.unregisterScreen(it.id) }
109
121
  } else {
110
122
  maxApi?.dismiss { }
@@ -222,6 +234,15 @@ class DynamicScreenRegistry {
222
234
  screens.remove(id)
223
235
  }
224
236
 
237
+ // Gesture pops go through composeNavigator.popBackStack() inside NavHost and never reach
238
+ // Navigator.dismissScreen(), so screens would leak without a stack-driven prune.
239
+ fun retainOnly(ids: Set<Int>) {
240
+ val stale = screens.keys.filter { it !in ids }
241
+ for (id in stale) {
242
+ screens.remove(id)
243
+ }
244
+ }
245
+
225
246
  fun unregisterAll(){
226
247
  val ids = screens.keys.toList()
227
248
  for (id in ids) {
@@ -169,7 +169,13 @@ internal fun StackScreen(
169
169
  RegisterScrollToTop(navigation.options.scrollData.scrollToTopCallback)
170
170
  }
171
171
 
172
- BackHandler(true) { navigator.onBackSafe() }
172
+ val canPop = navigator.canPop()
173
+
174
+ val hasCustomBackHandling = !SwipeBackConfig.enabled ||
175
+ OverplayComponentRegistry.isOverplayShowing() ||
176
+ !canPop
177
+
178
+ BackHandler(hasCustomBackHandling) { navigator.onBackSafe() }
173
179
 
174
180
  CompositionLocalProvider(
175
181
  StackScreenScrollableState provides options.scrollData.scrollState,
@@ -298,7 +304,10 @@ internal fun ScreenContent(content: @Composable () -> Unit) {
298
304
  if (options.headerType is HeaderType.Animated) {
299
305
  val animatedHeader = options.headerType
300
306
  Box {
301
- Box(Modifier.fillMaxWidth().aspectRatio(animatedHeader.aspectRatio.value)) {
307
+ val bannerModifier = animatedHeader.height
308
+ ?.let { Modifier.fillMaxWidth().height(it) }
309
+ ?: Modifier.fillMaxWidth().aspectRatio(animatedHeader.aspectRatio.value)
310
+ Box(bannerModifier) {
302
311
  animatedHeader.composable.invoke(scrollState.value)
303
312
  }
304
313
  Box(Modifier.padding(top = AppStatusBar.current + HEADER_HEIGHT.dp + animatedHeader.layoutOffSet)) {
@@ -319,7 +328,8 @@ internal fun FooterContent() {
319
328
  Footer(
320
329
  footerComponent = options.footerComponent,
321
330
  bottomPadding = bottomPadding,
322
- transparent = options.footerTransparent
331
+ transparent = options.footerTransparent,
332
+ useMargin = options.footerUseMargin
323
333
  )
324
334
  }
325
335
  }
@@ -344,7 +354,12 @@ internal fun OverplayView(isBottomTabChild: Boolean, id: Int) {
344
354
  }
345
355
 
346
356
  @Composable
347
- fun Footer(footerComponent: @Composable (() -> Unit)?, bottomPadding: Dp, transparent: Boolean = false) {
357
+ fun Footer(
358
+ footerComponent: @Composable (() -> Unit)?,
359
+ bottomPadding: Dp,
360
+ transparent: Boolean = false,
361
+ useMargin: Boolean = true,
362
+ ) {
348
363
  if (footerComponent == null) return
349
364
 
350
365
  val footerHeightPx = LocalFooterHeightPx.current
@@ -367,7 +382,12 @@ fun Footer(footerComponent: @Composable (() -> Unit)?, bottomPadding: Dp, transp
367
382
  .conditional(IsShowBaseLineDebug) {
368
383
  border(1.dp, Colors.blue_03)
369
384
  }
370
- .padding(top = Spacing.S, start = Spacing.M, end = Spacing.M, bottom = bottomPadding + Spacing.S)
385
+ .padding(
386
+ top = if (useMargin) Spacing.S else 0.dp,
387
+ start = if (useMargin) Spacing.M else 0.dp,
388
+ end = if (useMargin) Spacing.M else 0.dp,
389
+ bottom = bottomPadding + if (useMargin) Spacing.S else 0.dp,
390
+ )
371
391
  ) {
372
392
  footerComponent.invoke()
373
393
  }
@@ -0,0 +1,148 @@
1
+ package vn.momo.kits.navigation
2
+
3
+ import androidx.compose.animation.AnimatedContentTransitionScope
4
+ import androidx.compose.animation.AnimatedVisibilityScope
5
+ import androidx.compose.animation.EnterExitState
6
+ import androidx.compose.animation.EnterTransition
7
+ import androidx.compose.animation.ExitTransition
8
+ import androidx.compose.animation.core.LinearEasing
9
+ import androidx.compose.animation.core.animateFloat
10
+ import androidx.compose.animation.core.tween
11
+ import androidx.compose.animation.slideInHorizontally
12
+ import androidx.compose.animation.slideOutHorizontally
13
+ import androidx.compose.foundation.layout.Box
14
+ import androidx.compose.foundation.layout.fillMaxSize
15
+ import androidx.compose.runtime.Composable
16
+ import androidx.compose.runtime.DisposableEffect
17
+ import androidx.compose.runtime.Stable
18
+ import androidx.compose.runtime.derivedStateOf
19
+ import androidx.compose.runtime.getValue
20
+ import androidx.compose.runtime.mutableStateMapOf
21
+ import androidx.compose.runtime.remember
22
+ import androidx.compose.runtime.staticCompositionLocalOf
23
+ import androidx.compose.ui.Modifier
24
+ import androidx.compose.ui.draw.drawBehind
25
+ import androidx.compose.ui.geometry.Offset
26
+ import androidx.compose.ui.geometry.Size
27
+ import androidx.compose.ui.graphics.Brush
28
+ import androidx.compose.ui.graphics.Color
29
+ import androidx.compose.ui.platform.LocalDensity
30
+ import androidx.compose.ui.unit.IntOffset
31
+ import androidx.compose.ui.unit.dp
32
+ import androidx.navigation.NavBackStackEntry
33
+ import androidx.navigation.NavDestination.Companion.hasRoute
34
+ import vn.momo.kits.const.Colors
35
+ import kotlin.math.roundToInt
36
+
37
+ internal const val STACK_TRANSITION_DURATION = 300
38
+
39
+ // Ported 1:1 from the RN kit's `forStackTransitionWithOverlay` so both kits look identical.
40
+ private const val PARALLAX_RATIO = 0.3f
41
+ private const val SCRIM_MAX_ALPHA = 0.6f
42
+ private const val EDGE_SHADOW_MAX_ALPHA = 0.3f
43
+ private val EDGE_SHADOW_WIDTH = 8.dp
44
+
45
+ /** Kill switch: `false` restores pre-DS-760 back handling without a kit release. Transitions
46
+ * stay on the new curve either way. */
47
+ object SwipeBackConfig {
48
+ var enabled: Boolean = true
49
+ }
50
+
51
+ // LinearEasing is mandatory: seekTo() maps drag fraction onto the timeline, so a curve would
52
+ // decouple the card from the finger.
53
+ private val stackSpec = tween<IntOffset>(STACK_TRANSITION_DURATION, easing = LinearEasing)
54
+
55
+ internal fun AnimatedContentTransitionScope<NavBackStackEntry>.stackEnter(): EnterTransition =
56
+ slideInHorizontally(stackSpec) { it }
57
+
58
+ internal fun AnimatedContentTransitionScope<NavBackStackEntry>.stackExit(): ExitTransition =
59
+ if (targetState.destination.hasRoute<DynamicDialogRoute>()) ExitTransition.None
60
+ else slideOutHorizontally(stackSpec) { -(it * PARALLAX_RATIO).roundToInt() }
61
+
62
+ internal fun AnimatedContentTransitionScope<NavBackStackEntry>.stackPopEnter(): EnterTransition =
63
+ if (initialState.destination.hasRoute<DynamicDialogRoute>()) EnterTransition.None
64
+ else slideInHorizontally(stackSpec) { -(it * PARALLAX_RATIO).roundToInt() }
65
+
66
+ internal fun AnimatedContentTransitionScope<NavBackStackEntry>.stackPopExit(): ExitTransition =
67
+ slideOutHorizontally(stackSpec) { it }
68
+
69
+ /**
70
+ * Which composed screen is the top card. Ids are monotonic and NavHost keeps both entries composed
71
+ * for the whole segment, so "highest id" is stable both ways. `currentBackStackEntry` is not: it
72
+ * flips at frame 0 of a pop and the scrim would vanish instead of fading.
73
+ */
74
+ @Stable
75
+ internal class StackTransitionCoordinator {
76
+ private val composedRoutes = mutableStateMapOf<Int, Boolean>()
77
+
78
+ val topStackId: Int by derivedStateOf {
79
+ composedRoutes.entries.filter { it.value }.maxOfOrNull { it.key } ?: -1
80
+ }
81
+
82
+ fun attach(id: Int, isStackRoute: Boolean) {
83
+ composedRoutes[id] = isStackRoute
84
+ }
85
+
86
+ fun detach(id: Int) {
87
+ composedRoutes.remove(id)
88
+ }
89
+ }
90
+
91
+ internal val LocalStackTransition = staticCompositionLocalOf { StackTransitionCoordinator() }
92
+
93
+ /** Draws the covered-screen scrim and the top card's left-edge shadow around a stack destination. */
94
+ @Composable
95
+ internal fun AnimatedVisibilityScope.StackTransitionDecor(
96
+ id: Int,
97
+ isStackRoute: Boolean,
98
+ content: @Composable () -> Unit,
99
+ ) {
100
+ val coordinator = LocalStackTransition.current
101
+ coordinator.attach(id, isStackRoute)
102
+ DisposableEffect(coordinator, id) {
103
+ onDispose { coordinator.detach(id) }
104
+ }
105
+
106
+ val isTopCard = isStackRoute && id == coordinator.topStackId
107
+ val isCovered = isStackRoute && !isTopCard
108
+
109
+ val scrimAlpha = transition.animateFloat(
110
+ transitionSpec = { tween(STACK_TRANSITION_DURATION, easing = LinearEasing) },
111
+ label = "stackScrimAlpha",
112
+ ) { state -> if (isCovered && state != EnterExitState.Visible) SCRIM_MAX_ALPHA else 0f }
113
+
114
+ val shadowAlpha = transition.animateFloat(
115
+ transitionSpec = { tween(STACK_TRANSITION_DURATION, easing = LinearEasing) },
116
+ label = "stackEdgeShadowAlpha",
117
+ ) { state -> if (isTopCard && state == EnterExitState.Visible) EDGE_SHADOW_MAX_ALPHA else 0f }
118
+
119
+ val shadowWidthPx = with(LocalDensity.current) { EDGE_SHADOW_WIDTH.toPx() }
120
+ val shadowBrush = remember(shadowWidthPx) {
121
+ Brush.horizontalGradient(
122
+ colors = listOf(Color.Transparent, Colors.black_20),
123
+ startX = -shadowWidthPx,
124
+ endX = 0f,
125
+ )
126
+ }
127
+
128
+ Box(Modifier.fillMaxSize()) {
129
+ content()
130
+
131
+ if (isStackRoute) {
132
+ Box(
133
+ Modifier
134
+ .matchParentSize()
135
+ // read inside drawBehind: in the body this would recompose every gesture frame
136
+ .drawBehind {
137
+ drawRect(
138
+ brush = shadowBrush,
139
+ topLeft = Offset(-shadowWidthPx, 0f),
140
+ size = Size(shadowWidthPx, size.height),
141
+ alpha = shadowAlpha.value,
142
+ )
143
+ drawRect(Colors.pink_09, alpha = scrimAlpha.value)
144
+ }
145
+ )
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,35 @@
1
+ package vn.momo.kits.navigation
2
+
3
+ import androidx.compose.runtime.Composable
4
+ import androidx.compose.runtime.LaunchedEffect
5
+ import androidx.compose.runtime.snapshotFlow
6
+ import vn.momo.kits.platform.getScreenDimensions
7
+ import vn.momo.kits.platform.rememberBackGestureProgress
8
+ import vn.momo.maxapi.IMaxApi
9
+
10
+ // Unscaled, so the tick lands at the same physical distance on every device.
11
+ private const val SWIPE_BACK_HAPTIC_THRESHOLD_DP = 50f
12
+
13
+ /** One light tick per crossing, re-armed below the threshold. iOS only. */
14
+ @Composable
15
+ internal fun SwipeBackHaptic(maxApi: IMaxApi?) {
16
+ if (maxApi == null) return
17
+
18
+ val progress = rememberBackGestureProgress()
19
+ val screenWidth = getScreenDimensions().width.toFloat()
20
+
21
+ LaunchedEffect(maxApi, screenWidth) {
22
+ if (screenWidth <= 0f) return@LaunchedEffect
23
+ val threshold = SWIPE_BACK_HAPTIC_THRESHOLD_DP / screenWidth
24
+ var isSendHaptic = false
25
+ snapshotFlow { progress.value }.collect { fraction ->
26
+ val travelled = fraction ?: 0f
27
+ if (travelled >= threshold && !isSendHaptic) {
28
+ runCatching { maxApi.triggerEventVibration("light", callback = null) }
29
+ isSendHaptic = true
30
+ } else if (travelled < threshold && isSendHaptic) {
31
+ isSendHaptic = false
32
+ }
33
+ }
34
+ }
35
+ }
@@ -8,12 +8,14 @@ import androidx.compose.animation.scaleOut
8
8
  import androidx.compose.foundation.background
9
9
  import androidx.compose.foundation.layout.*
10
10
  import androidx.compose.runtime.Composable
11
+ import androidx.compose.runtime.DisposableEffect
11
12
  import androidx.compose.runtime.LaunchedEffect
12
13
  import androidx.compose.ui.Alignment
13
14
  import androidx.compose.ui.Modifier
14
15
  import androidx.compose.ui.unit.dp
15
16
  import androidx.navigation.compose.NavHost
16
17
  import androidx.navigation.compose.composable
18
+ import androidx.navigation.compose.currentBackStackEntryAsState
17
19
  import androidx.navigation.compose.rememberNavController
18
20
  import vn.momo.kits.const.AppNavigationBar
19
21
  import vn.momo.kits.const.AppTheme
@@ -24,6 +26,7 @@ import vn.momo.kits.navigation.LocalNavigator
24
26
  import vn.momo.kits.navigation.NavigationOptions
25
27
  import vn.momo.kits.navigation.StackScreen
26
28
  import vn.momo.kits.navigation.component.HeaderType
29
+ import vn.momo.kits.platform.reportFloatingArrowBack
27
30
 
28
31
  private var bottomTabOptionItems : MutableList<NavigationOptions?> = mutableListOf()
29
32
  internal fun setBottomTabOption(index: Int, options: NavigationOptions){
@@ -59,6 +62,22 @@ fun BottomTab(
59
62
  )
60
63
  }
61
64
 
65
+ // Tab 0 keeps the seeked transition; other tabs go to the host's floating arrow. Matches RN.
66
+ val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
67
+ val isFirstTab = currentRoute == null || currentRoute == "option0"
68
+
69
+ DisposableEffect(isFirstTab) {
70
+ reportFloatingArrowBack(!isFirstTab)
71
+ navigator.tabBackHandler = if (isFirstTab) null else ({
72
+ navController.popBackStack(navController.graph.startDestinationRoute!!, false)
73
+ })
74
+ // both live outside this composition
75
+ onDispose {
76
+ reportFloatingArrowBack(false)
77
+ navigator.tabBackHandler = null
78
+ }
79
+ }
80
+
62
81
  Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
63
82
  Box(modifier = Modifier
64
83
  .fillMaxSize()
@@ -191,8 +191,18 @@ internal fun HeaderContent(
191
191
  textAlign = if (titlePosition == TitlePosition.CENTER) TextAlign.Center else TextAlign.Start
192
192
  )
193
193
  }
194
- is HeaderTitle.Journey -> {}
195
- is HeaderTitle.Location -> {}
194
+ is HeaderTitle.Journey -> {
195
+ HeaderJourney(
196
+ data = headerTitle,
197
+ tintIconColor = tintIconColor
198
+ )
199
+ }
200
+ is HeaderTitle.Location -> {
201
+ HeaderLocation(
202
+ data = headerTitle,
203
+ tintIconColor = tintIconColor
204
+ )
205
+ }
196
206
  is HeaderTitle.User -> {
197
207
  HeaderUser(
198
208
  data = headerTitle
@@ -274,6 +284,7 @@ sealed class HeaderType {
274
284
  val aspectRatio: AnimatedHeaderRatio = AnimatedHeaderRatio.RATIO_16_9,
275
285
  val isSurface: Boolean = true,
276
286
  val layoutOffSet: Dp = 56.dp,
287
+ val height: Dp? = null,
277
288
  val composable: @Composable (scrollState: Int) -> Unit = {}
278
289
  ) : HeaderType()
279
290