@momo-kits/native-kits 0.164.1-trustsans-12-debug → 0.165.1-beta.10-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/.codegraph/codegraph.db +0 -0
- package/.codegraph/daemon.log +100 -0
- package/CLAUDE.md +1 -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/commonMain/kotlin/vn/momo/kits/application/Context.kt +18 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +30 -4
- 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/navigation/Navigation.kt +3 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +66 -31
- 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/platform/BackGesture.kt +12 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +98 -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 +13 -3
- package/gradle.properties +1 -1
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/package.json +1 -1
- package/.codegraph/.gitignore +0 -5
|
@@ -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
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
package vn.momo.kits.navigation.component
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.layout.Arrangement
|
|
4
|
+
import androidx.compose.foundation.layout.Box
|
|
5
|
+
import androidx.compose.foundation.layout.Column
|
|
6
|
+
import androidx.compose.foundation.layout.Row
|
|
7
|
+
import androidx.compose.foundation.layout.Spacer
|
|
8
|
+
import androidx.compose.foundation.layout.fillMaxWidth
|
|
9
|
+
import androidx.compose.foundation.layout.height
|
|
10
|
+
import androidx.compose.foundation.layout.width
|
|
11
|
+
import androidx.compose.foundation.layout.widthIn
|
|
12
|
+
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
13
|
+
import androidx.compose.runtime.Composable
|
|
14
|
+
import androidx.compose.ui.Alignment
|
|
15
|
+
import androidx.compose.ui.Modifier
|
|
16
|
+
import androidx.compose.ui.draw.clip
|
|
17
|
+
import androidx.compose.ui.graphics.Color
|
|
18
|
+
import androidx.compose.ui.text.style.TextOverflow
|
|
19
|
+
import androidx.compose.ui.unit.dp
|
|
20
|
+
import vn.momo.kits.components.Icon
|
|
21
|
+
import vn.momo.kits.components.Skeleton
|
|
22
|
+
import vn.momo.kits.components.Text
|
|
23
|
+
import vn.momo.kits.const.Spacing
|
|
24
|
+
import vn.momo.kits.const.Typography
|
|
25
|
+
import vn.momo.kits.modifier.activeOpacityClickable
|
|
26
|
+
import vn.momo.kits.platform.getScreenDimensions
|
|
27
|
+
|
|
28
|
+
@Composable
|
|
29
|
+
internal fun HeaderJourney(
|
|
30
|
+
data: HeaderTitle,
|
|
31
|
+
tintIconColor: Color,
|
|
32
|
+
) {
|
|
33
|
+
if (data !is HeaderTitle.Journey) return
|
|
34
|
+
if (data.isLoading) return TitleJourneyShimmer()
|
|
35
|
+
|
|
36
|
+
val maxWidth = getScreenDimensions().width.dp - 172.dp
|
|
37
|
+
val tint = parseHeaderTint(data.tintColor) ?: tintIconColor
|
|
38
|
+
val iconTint = parseHeaderTint(data.iconColor) ?: tint
|
|
39
|
+
|
|
40
|
+
Column(
|
|
41
|
+
modifier = Modifier
|
|
42
|
+
.widthIn(max = maxWidth)
|
|
43
|
+
.activeOpacityClickable(enabled = data.onPress != null, onClick = { data.onPress?.invoke() }),
|
|
44
|
+
verticalArrangement = Arrangement.Center,
|
|
45
|
+
) {
|
|
46
|
+
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
47
|
+
Text(
|
|
48
|
+
text = data.start,
|
|
49
|
+
color = tint,
|
|
50
|
+
style = Typography.actionXsBold,
|
|
51
|
+
maxLines = 1,
|
|
52
|
+
overflow = TextOverflow.Ellipsis,
|
|
53
|
+
modifier = Modifier.weight(1f, fill = false),
|
|
54
|
+
)
|
|
55
|
+
Spacer(Modifier.width(Spacing.S))
|
|
56
|
+
if (data.icon.isNotBlank()) {
|
|
57
|
+
Icon(source = data.icon, size = 16.dp, color = iconTint)
|
|
58
|
+
} else {
|
|
59
|
+
Spacer(Modifier.width(16.dp))
|
|
60
|
+
}
|
|
61
|
+
Spacer(Modifier.width(Spacing.S))
|
|
62
|
+
if (!data.end.isNullOrEmpty()) {
|
|
63
|
+
Text(
|
|
64
|
+
text = data.end,
|
|
65
|
+
color = tint,
|
|
66
|
+
style = Typography.actionXsBold,
|
|
67
|
+
maxLines = 1,
|
|
68
|
+
overflow = TextOverflow.Ellipsis,
|
|
69
|
+
modifier = Modifier.weight(1f, fill = false),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
Spacer(Modifier.width(Spacing.XS))
|
|
73
|
+
Icon(source = "16_arrow_chevron_down_small", size = 16.dp, color = tint)
|
|
74
|
+
}
|
|
75
|
+
if (!data.description.isNullOrEmpty()) {
|
|
76
|
+
Text(
|
|
77
|
+
text = data.description,
|
|
78
|
+
color = tint,
|
|
79
|
+
style = Typography.descriptionXsRegular,
|
|
80
|
+
maxLines = 1,
|
|
81
|
+
overflow = TextOverflow.Ellipsis,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@Composable
|
|
88
|
+
private fun TitleJourneyShimmer() {
|
|
89
|
+
Column(modifier = Modifier.fillMaxWidth()) {
|
|
90
|
+
Box(modifier = Modifier.height(18.dp).width(120.dp).clip(RoundedCornerShape(8.dp))) { Skeleton() }
|
|
91
|
+
Spacer(Modifier.height(Spacing.XXS))
|
|
92
|
+
Box(modifier = Modifier.height(12.dp).width(120.dp).clip(RoundedCornerShape(8.dp))) { Skeleton() }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
internal fun parseHeaderTint(value: String?): Color? {
|
|
97
|
+
val raw = value?.trim()?.removePrefix("#") ?: return null
|
|
98
|
+
val hex = when (raw.length) {
|
|
99
|
+
3 -> "FF" + raw.map { "$it$it" }.joinToString("")
|
|
100
|
+
6 -> "FF$raw"
|
|
101
|
+
8 -> raw
|
|
102
|
+
else -> return null
|
|
103
|
+
}
|
|
104
|
+
val v = hex.toLongOrNull(16) ?: return null
|
|
105
|
+
return Color(v)
|
|
106
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
package vn.momo.kits.navigation.component
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.layout.Box
|
|
4
|
+
import androidx.compose.foundation.layout.Column
|
|
5
|
+
import androidx.compose.foundation.layout.Row
|
|
6
|
+
import androidx.compose.foundation.layout.Spacer
|
|
7
|
+
import androidx.compose.foundation.layout.fillMaxWidth
|
|
8
|
+
import androidx.compose.foundation.layout.height
|
|
9
|
+
import androidx.compose.foundation.layout.width
|
|
10
|
+
import androidx.compose.foundation.layout.widthIn
|
|
11
|
+
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
12
|
+
import androidx.compose.runtime.Composable
|
|
13
|
+
import androidx.compose.ui.Alignment
|
|
14
|
+
import androidx.compose.ui.Modifier
|
|
15
|
+
import androidx.compose.ui.draw.clip
|
|
16
|
+
import androidx.compose.ui.graphics.Color
|
|
17
|
+
import androidx.compose.ui.text.style.TextOverflow
|
|
18
|
+
import androidx.compose.ui.unit.dp
|
|
19
|
+
import vn.momo.kits.components.Icon
|
|
20
|
+
import vn.momo.kits.components.Skeleton
|
|
21
|
+
import vn.momo.kits.components.Text
|
|
22
|
+
import vn.momo.kits.const.Spacing
|
|
23
|
+
import vn.momo.kits.const.Typography
|
|
24
|
+
import vn.momo.kits.modifier.activeOpacityClickable
|
|
25
|
+
import vn.momo.kits.platform.getScreenDimensions
|
|
26
|
+
|
|
27
|
+
@Composable
|
|
28
|
+
internal fun HeaderLocation(
|
|
29
|
+
data: HeaderTitle,
|
|
30
|
+
tintIconColor: Color,
|
|
31
|
+
) {
|
|
32
|
+
if (data !is HeaderTitle.Location) return
|
|
33
|
+
if (data.isLoading) return TitleLocationShimmer()
|
|
34
|
+
|
|
35
|
+
val maxWidth = getScreenDimensions().width.dp - 172.dp
|
|
36
|
+
val tint = parseHeaderTint(data.tintColor) ?: tintIconColor
|
|
37
|
+
|
|
38
|
+
Column(
|
|
39
|
+
modifier = Modifier
|
|
40
|
+
.widthIn(max = maxWidth)
|
|
41
|
+
.activeOpacityClickable(enabled = data.onPress != null, onClick = { data.onPress?.invoke() }),
|
|
42
|
+
horizontalAlignment = Alignment.Start,
|
|
43
|
+
) {
|
|
44
|
+
if (!data.description.isNullOrEmpty()) {
|
|
45
|
+
Text(
|
|
46
|
+
text = data.description,
|
|
47
|
+
color = tint,
|
|
48
|
+
style = Typography.descriptionXsRegular,
|
|
49
|
+
maxLines = 1,
|
|
50
|
+
overflow = TextOverflow.Ellipsis,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
54
|
+
Text(
|
|
55
|
+
text = data.location,
|
|
56
|
+
color = tint,
|
|
57
|
+
style = Typography.actionXsBold,
|
|
58
|
+
maxLines = 1,
|
|
59
|
+
overflow = TextOverflow.Ellipsis,
|
|
60
|
+
modifier = Modifier.weight(1f, fill = false),
|
|
61
|
+
)
|
|
62
|
+
Spacer(Modifier.width(Spacing.XS))
|
|
63
|
+
Icon(source = "16_arrow_chevron_down_small", size = 16.dp, color = tint)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Composable
|
|
69
|
+
private fun TitleLocationShimmer() {
|
|
70
|
+
Column(modifier = Modifier.fillMaxWidth()) {
|
|
71
|
+
Box(modifier = Modifier.height(12.dp).width(120.dp).clip(RoundedCornerShape(8.dp))) { Skeleton() }
|
|
72
|
+
Spacer(Modifier.height(Spacing.XXS))
|
|
73
|
+
Box(modifier = Modifier.height(18.dp).width(120.dp).clip(RoundedCornerShape(8.dp))) { Skeleton() }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
package vn.momo.kits.platform
|
|
2
|
+
|
|
3
|
+
import androidx.compose.runtime.Composable
|
|
4
|
+
import androidx.compose.runtime.State
|
|
5
|
+
|
|
6
|
+
/** Fraction of container width, `null` when idle. Observe only — consuming would steal the
|
|
7
|
+
* gesture from NavHost. Always `null` on Android. */
|
|
8
|
+
@Composable
|
|
9
|
+
expect fun rememberBackGestureProgress(): State<Float?>
|
|
10
|
+
|
|
11
|
+
/** Android is a no-op: no host-owned recogniser to hand the gesture to. */
|
|
12
|
+
expect fun reportFloatingArrowBack(shouldUse: Boolean)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
package vn.momo.kits.navigation
|
|
2
|
+
|
|
3
|
+
import androidx.compose.runtime.Composable
|
|
4
|
+
import androidx.compose.runtime.DisposableEffect
|
|
5
|
+
import androidx.compose.runtime.remember
|
|
6
|
+
import androidx.navigationevent.NavigationEvent
|
|
7
|
+
import androidx.navigationevent.NavigationEventDispatcher
|
|
8
|
+
import androidx.navigationevent.NavigationEventInput
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Kit end of the host's edge-pan recogniser. The host owns the only recogniser on iOS and replays
|
|
12
|
+
* its drag here; events reach NavHost's `PredictiveBackHandler` and seek the stack transition.
|
|
13
|
+
*
|
|
14
|
+
* **Call every method from the main thread** — nothing here is synchronised, and [attachedInputs]
|
|
15
|
+
* is also written from composition.
|
|
16
|
+
*/
|
|
17
|
+
object HostBackGestureBridge {
|
|
18
|
+
private val attachedInputs = mutableListOf<HostNavigationEventInput>()
|
|
19
|
+
private var gestureInput: HostNavigationEventInput? = null
|
|
20
|
+
|
|
21
|
+
/** `false` means no container is mounted, or it opted out — host falls back to its own nav. */
|
|
22
|
+
val hasEnabledHandlers: Boolean
|
|
23
|
+
get() = attachedInputs.lastOrNull()?.hasEnabledHandlers == true
|
|
24
|
+
|
|
25
|
+
/** Set on tabs other than the first; host pulls it when its recogniser begins. */
|
|
26
|
+
var shouldUseFloatingArrowBack: Boolean = false
|
|
27
|
+
internal set
|
|
28
|
+
|
|
29
|
+
/** Begins a drag. Pairs with exactly one [completed] or [cancelled]. */
|
|
30
|
+
fun started() {
|
|
31
|
+
val input = attachedInputs.lastOrNull() ?: return
|
|
32
|
+
gestureInput = input
|
|
33
|
+
input.backStarted()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Drag progress in `0f..1f` — the fraction of the screen width travelled. */
|
|
37
|
+
fun progressed(progress: Float) {
|
|
38
|
+
gestureInput?.backProgressed(progress.coerceIn(0f, 1f))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Falls back to the attached input when no drag was started: a host that commits without
|
|
42
|
+
* ever seeking still needs its back. */
|
|
43
|
+
fun completed() {
|
|
44
|
+
(gestureInput ?: attachedInputs.lastOrNull())?.backCompleted()
|
|
45
|
+
gestureInput = null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Released below the commit threshold: the screen springs back. */
|
|
49
|
+
fun cancelled() {
|
|
50
|
+
gestureInput?.backCancelled()
|
|
51
|
+
gestureInput = null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
internal fun attach(input: HostNavigationEventInput) {
|
|
55
|
+
attachedInputs += input
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
internal fun detach(input: HostNavigationEventInput) {
|
|
59
|
+
attachedInputs -= input
|
|
60
|
+
// else a container unmounted mid-drag hands its terminal event to whoever is on top now
|
|
61
|
+
if (gestureInput === input) gestureInput = null
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Wires one [NavigationContainer]'s dispatcher to [HostBackGestureBridge] while it is composed. */
|
|
66
|
+
@Composable
|
|
67
|
+
internal fun AttachHostBackGesture(dispatcher: NavigationEventDispatcher) {
|
|
68
|
+
val input = remember(dispatcher) { HostNavigationEventInput() }
|
|
69
|
+
|
|
70
|
+
DisposableEffect(dispatcher, input) {
|
|
71
|
+
dispatcher.addInput(input)
|
|
72
|
+
HostBackGestureBridge.attach(input)
|
|
73
|
+
onDispose {
|
|
74
|
+
HostBackGestureBridge.detach(input)
|
|
75
|
+
dispatcher.removeInput(input)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
internal class HostNavigationEventInput : NavigationEventInput() {
|
|
81
|
+
var hasEnabledHandlers: Boolean = false
|
|
82
|
+
private set
|
|
83
|
+
|
|
84
|
+
override fun onHasEnabledHandlersChanged(hasEnabledHandlers: Boolean) {
|
|
85
|
+
this.hasEnabledHandlers = hasEnabledHandlers
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Touch coordinates are not forwarded: progress alone seeks the transition.
|
|
89
|
+
fun backStarted() = dispatchOnBackStarted(NavigationEvent(swipeEdge = NavigationEvent.EDGE_LEFT))
|
|
90
|
+
|
|
91
|
+
fun backProgressed(progress: Float) = dispatchOnBackProgressed(
|
|
92
|
+
NavigationEvent(swipeEdge = NavigationEvent.EDGE_LEFT, progress = progress)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
fun backCancelled() = dispatchOnBackCancelled()
|
|
96
|
+
|
|
97
|
+
fun backCompleted() = dispatchOnBackCompleted()
|
|
98
|
+
}
|