@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.
- package/CLAUDE.md +2 -0
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +12 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +18 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +6 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +30 -4
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +3 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +8 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +9 -4
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +4 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +3 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +104 -22
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +3 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +8 -4
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +18 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +38 -5
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +58 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +3 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +62 -33
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +22 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +25 -5
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +148 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +35 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +19 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +13 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +106 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +75 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +3 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +5 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +12 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -1
- package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +45 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +38 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +10 -4
- package/gradle/libs.versions.toml +1 -1
- package/gradle.properties +1 -1
- package/ios/Application/Navigation/NavigatorManager.swift +0 -27
- package/ios/Colors+Radius+Spacing/Theme.swift +2 -2
- package/ios/Typography/KitDesignSystem.swift +17 -0
- package/ios/Typography/Text.swift +25 -1
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/package.json +1 -1
- 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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|