@momo-kits/native-kits 0.165.1-beta.10 → 0.165.1-beta.12-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 (44) hide show
  1. package/.claude/settings.local.json +14 -0
  2. package/CLAUDE.md +1 -0
  3. package/compose/build.gradle.kts +1 -1
  4. package/compose/compose.podspec +1 -1
  5. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -1
  6. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +1 -1
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +57 -2
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +84 -34
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +58 -0
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +2 -8
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +33 -47
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +28 -1
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +3 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -1
  15. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +15 -68
  16. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +3 -7
  17. package/gradle/libs.versions.toml +1 -1
  18. package/gradle.properties +1 -1
  19. package/ios/Application/Navigation/NavigatorManager.swift +0 -27
  20. package/ios/Information/Information.swift +5 -2
  21. package/ios/InputMoney/InputMoney.swift +76 -21
  22. package/ios/Typography/Text.swift +41 -1
  23. package/ios/native-kits.podspec +1 -1
  24. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  25. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InformationDemo.swift +9 -1
  26. package/local.properties +2 -2
  27. package/package.json +1 -1
  28. package/publish.sh +25 -0
  29. package/.codegraph/codegraph.db +0 -0
  30. package/.codegraph/daemon.log +0 -100
  31. package/example/ios/Example.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/Example.xcscheme +0 -32
  32. package/example/ios/Example.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +0 -14
  33. package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
  34. package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/WorkspaceSettings.xcsettings +0 -16
  35. package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +0 -6
  36. package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +0 -5
  37. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/MoMoUIKits.xcscheme +0 -58
  38. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/Pods-Example.xcscheme +0 -58
  39. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SDWebImage.xcscheme +0 -58
  40. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SDWebImageSwiftUI.xcscheme +0 -58
  41. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SkeletonUI.xcscheme +0 -58
  42. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/lottie-ios-LottiePrivacyInfo.xcscheme +0 -58
  43. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/lottie-ios.xcscheme +0 -58
  44. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +0 -60
@@ -0,0 +1,14 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebSearch",
5
+ "Bash(./gradlew assembleDebug 2>&1)",
6
+ "Bash(./gradlew :sample:androidApp:installDebug 2>&1)",
7
+ "Bash(./gradlew :compose:compileCommonMainKotlinMetadata -q)",
8
+ "Bash(mkdir -p compose/src/commonMain/composeResources/files)",
9
+ "Bash(cp sample/shared/src/commonMain/composeResources/files/anim_processing_transaction.json sample/shared/src/commonMain/composeResources/files/dot_loading.json sample/shared/src/commonMain/composeResources/files/face_loading.json sample/shared/src/commonMain/composeResources/files/lottie_circle_loader.json compose/src/commonMain/composeResources/files/)",
10
+ "Bash(git rm *)",
11
+ "Bash(./gradlew :sample:androidApp:compileDebugKotlin -q)"
12
+ ]
13
+ }
14
+ }
package/CLAUDE.md CHANGED
@@ -45,6 +45,7 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
45
45
  - **Platform code** goes through `expect`/`actual` in `platform/` — never reference Android/iOS APIs from `commonMain` directly.
46
46
  - **Native bridge:** `IMaxApi` (`vn.momo.maxapi`, exposed as `LocalMaxApi`) is the host-app bridge (dismiss, tracking, device info, language).
47
47
  - Navigation uses a runtime `DynamicScreenRegistry`; overlays (modal/bottom-sheet/snackbar) go through `Navigator` + `OverplayComponentRegistry`.
48
+ - **iOS swipe-back:** the host owns the only edge-pan recogniser and replays it through `HostBackGestureBridge` (`navigation/HostBackGestureBridge.kt`; the kit framework is never exported, so the host reaches it through its own Koin-provided facade): `checkCanPop` / `canPopInner` / `pop` route the back into the kit stack, `started` / `progressed` / `completed` / `cancelled` seek the NavHost transition, `shouldUseFloatingArrowBack` is set by bottom tabs. `NavigationContainer` attaches itself automatically (`AttachHostBackGesture`, iOS only, detached by identity on dispose) — hosts need no wrapper and no `setNavigator`. `maxApi` is a required parameter of `NavigationContainer` — every container, nested or root, is handed its host bridge explicitly.
48
49
 
49
50
  ## i18n / Localize (port of RN `Localize`)
50
51
 
@@ -40,7 +40,7 @@ kotlin {
40
40
  }
41
41
 
42
42
  cocoapods {
43
- version = "0.165.1-beta.10-debug"
43
+ version = "0.165.1-beta.12-debug"
44
44
  summary = "IOS Shared module"
45
45
  homepage = "https://momo.vn"
46
46
  ios.deploymentTarget = "15.0"
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |spec|
2
2
  spec.name = 'compose'
3
- spec.version = '0.165.1-beta.10-debug'
3
+ spec.version = '0.165.1-beta.12-debug'
4
4
  spec.homepage = 'https://momo.vn'
5
5
  spec.source = { :http=> ''}
6
6
  spec.authors = ''
@@ -30,6 +30,7 @@ import com.airbnb.lottie.compose.rememberLottieDynamicProperties
30
30
  import com.airbnb.lottie.compose.rememberLottieDynamicProperty
31
31
  import vn.momo.kits.const.AppNavigationBar
32
32
  import vn.momo.kits.const.AppStatusBar
33
+ import vn.momo.kits.navigation.Navigator
33
34
  import vn.momo.kits.utils.readJson
34
35
  import androidx.activity.compose.BackHandler as AndroidBackHandler
35
36
 
@@ -64,7 +65,7 @@ actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
64
65
  }
65
66
 
66
67
  @Composable
67
- actual fun ProvideNavigationEventDispatcherOwner(content: @Composable () -> Unit) {
68
+ actual fun ProvideNavigationEventDispatcherOwner(navigator: Navigator, content: @Composable () -> Unit) {
68
69
  content()
69
70
  }
70
71
 
@@ -18,7 +18,7 @@ import vn.momo.kits.const.Spacing
18
18
  enum class DividerType { DEFAULT, DASH }
19
19
 
20
20
  @Composable
21
- fun Divider(useMargin: Boolean = true, type: DividerType = DividerType.DEFAULT) {
21
+ fun Divider(useMargin: Boolean = false, type: DividerType = DividerType.DEFAULT) {
22
22
  val color = AppTheme.current.colors.border.default
23
23
  val margin = if (useMargin) Spacing.XS else 0.dp
24
24
  if (type == DividerType.DASH) {
@@ -9,13 +9,20 @@ import androidx.compose.runtime.Composable
9
9
  import androidx.compose.runtime.remember
10
10
  import androidx.compose.ui.Modifier
11
11
  import androidx.compose.ui.draw.clip
12
+ import androidx.compose.ui.text.AnnotatedString
13
+ import androidx.compose.ui.text.SpanStyle
14
+ import androidx.compose.ui.text.buildAnnotatedString
15
+ import androidx.compose.ui.text.font.FontSynthesis
12
16
  import androidx.compose.ui.text.style.TextAlign
17
+ import androidx.compose.ui.text.withStyle
13
18
  import androidx.compose.ui.unit.dp
14
19
  import vn.momo.kits.application.IsShowBaseLineDebug
20
+ import vn.momo.kits.const.AppTheme
15
21
  import vn.momo.kits.const.Colors
16
22
  import vn.momo.kits.const.Radius
17
23
  import vn.momo.kits.const.Spacing
18
24
  import vn.momo.kits.const.Typography
25
+ import vn.momo.kits.const.getFontFamily
19
26
  import vn.momo.kits.modifier.conditional
20
27
  import vn.momo.kits.modifier.setAutomationId
21
28
 
@@ -37,6 +44,7 @@ fun Information(
37
44
  showIconClose: Boolean = true,
38
45
  onPressClose: (() -> Unit)? = null,
39
46
  accessibilityLabel: String? = null,
47
+ highlightText: List<String> = emptyList(),
40
48
  ) {
41
49
  val automationId = remember(accessibilityLabel, title) {
42
50
  accessibilityLabel ?: "Information${if (title != null) "/$title" else ""}"
@@ -61,6 +69,22 @@ fun Information(
61
69
  iconSource = "24_notifications_alert_octagon"
62
70
  }
63
71
 
72
+ // Host fonts are one file per weight: swap fontFamily and disable synthesis so the SemiBold file isn't
73
+ // faux-bolded again; fontWeight still applies when no host font is bundled and the system font is used.
74
+ val highlightWeight = Typography.headerXsSemibold.fontWeight
75
+ val highlightFontFamily = getFontFamily(AppTheme.current.font, highlightWeight)
76
+ val annotatedDescription = remember(description, highlightText, highlightFontFamily) {
77
+ highlightDescription(
78
+ description,
79
+ highlightText,
80
+ SpanStyle(
81
+ fontFamily = highlightFontFamily,
82
+ fontWeight = highlightWeight,
83
+ fontSynthesis = FontSynthesis.None,
84
+ ),
85
+ )
86
+ }
87
+
64
88
  Column(
65
89
  modifier = Modifier.border(
66
90
  width = 0.5.dp,
@@ -100,7 +124,7 @@ fun Information(
100
124
  )
101
125
  }
102
126
  Text(
103
- description,
127
+ annotatedDescription,
104
128
  style = Typography.descriptionDefaultRegular,
105
129
  accessibilityId = "$automationId|description-text"
106
130
  )
@@ -131,4 +155,35 @@ fun Information(
131
155
  }
132
156
  }
133
157
 
134
- }
158
+ }
159
+
160
+ // Whole-word matches only; the longest keyword wins when keywords overlap (RN parity).
161
+ private fun highlightDescription(
162
+ description: String,
163
+ highlightText: List<String>,
164
+ style: SpanStyle,
165
+ ): AnnotatedString {
166
+ val keywords = highlightText.filter { it.isNotEmpty() }
167
+ if (keywords.isEmpty()) return AnnotatedString(description)
168
+ val regex = Regex(keywords.sortedByDescending { it.length }.joinToString("|") { Regex.escape(it) })
169
+ fun isWordChar(index: Int) = description.getOrNull(index)?.let { it.isLetterOrDigit() || it == '_' } == true
170
+
171
+ return buildAnnotatedString {
172
+ var lastIndex = 0
173
+ var searchFrom = 0
174
+ while (true) {
175
+ val match = regex.find(description, searchFrom) ?: break
176
+ val start = match.range.first
177
+ val end = match.range.last + 1
178
+ if (isWordChar(start - 1) || isWordChar(end)) {
179
+ searchFrom = start + 1
180
+ continue
181
+ }
182
+ append(description.substring(lastIndex, start))
183
+ withStyle(style) { append(match.value) }
184
+ lastIndex = end
185
+ searchFrom = end
186
+ }
187
+ append(description.substring(lastIndex))
188
+ }
189
+ }
@@ -30,12 +30,10 @@ import androidx.compose.ui.graphics.Color
30
30
  import androidx.compose.ui.semantics.contentDescription
31
31
  import androidx.compose.ui.semantics.semantics
32
32
  import androidx.compose.ui.semantics.testTag
33
- import androidx.compose.ui.text.AnnotatedString
33
+ import androidx.compose.ui.text.TextRange
34
34
  import androidx.compose.ui.text.TextStyle
35
35
  import androidx.compose.ui.text.input.KeyboardType
36
- import androidx.compose.ui.text.input.OffsetMapping
37
- import androidx.compose.ui.text.input.TransformedText
38
- import androidx.compose.ui.text.input.VisualTransformation
36
+ import androidx.compose.ui.text.input.TextFieldValue
39
37
  import androidx.compose.ui.unit.dp
40
38
  import androidx.compose.ui.unit.sp
41
39
  import androidx.compose.ui.zIndex
@@ -46,34 +44,63 @@ import vn.momo.kits.const.Colors
46
44
  import vn.momo.kits.const.Typography
47
45
  import vn.momo.kits.const.getFontFamily
48
46
  import vn.momo.kits.modifier.conditional
49
- import vn.momo.kits.utils.formatNumberToMoney
50
47
 
51
- class CustomConverter : VisualTransformation {
52
- override fun filter(text: AnnotatedString): TransformedText {
53
- if (text.text.isEmpty()) {
54
- return TransformedText(
55
- AnnotatedString("0"),
56
- object : OffsetMapping {
57
- override fun originalToTransformed(offset: Int): Int = 0
58
- override fun transformedToOriginal(offset: Int): Int = 0
59
- }
60
- )
61
- }
62
-
63
- if (text.text == "0") {
64
- return TransformedText(AnnotatedString("0"), OffsetMapping.Identity)
65
- }
66
-
67
- val formattedText = formatNumberToMoney(text.text.toLong())
48
+ private const val MONEY_CURRENCY = "đ"
68
49
 
69
- return TransformedText(
70
- AnnotatedString(formattedText),
71
- object : OffsetMapping {
72
- override fun originalToTransformed(offset: Int): Int = formattedText.length
73
- override fun transformedToOriginal(offset: Int): Int = text.length
74
- }
75
- )
50
+ private data class MoneyEdit(val start: Int, val end: Int, val inserted: String)
51
+
52
+ private fun digitsOf(text: String) = text.filter { it in '0'..'9' }
53
+
54
+ private fun normalizeDigits(text: String) = digitsOf(text).let { digits ->
55
+ digits.trimStart('0').ifEmpty { digits.take(1) }
56
+ }
57
+
58
+ private fun moneyDisplay(digits: String) =
59
+ if (digits.isEmpty()) "" else digits.reversed().chunked(3).joinToString(".").reversed() + MONEY_CURRENCY
60
+
61
+ // Caret position right after the `digitIndex`-th digit of `display`.
62
+ private fun caretFromDigitIndex(display: String, digitIndex: Int): Int {
63
+ var count = 0
64
+ display.forEachIndexed { index, char ->
65
+ if (char in '0'..'9' && ++count == digitIndex) return index + 1
76
66
  }
67
+ return 0
68
+ }
69
+
70
+ // Finds which range of `old` was replaced to produce `next`, given the post-edit caret.
71
+ private fun detectEdit(old: String, next: String, caret: Int): MoneyEdit {
72
+ val tail = next.length - caret
73
+ if (tail in 0..old.length && next.substring(caret) == old.substring(old.length - tail)) {
74
+ var prefix = 0
75
+ while (prefix < minOf(caret, old.length - tail) && old[prefix] == next[prefix]) prefix++
76
+ return MoneyEdit(prefix, old.length - tail, next.substring(prefix, caret))
77
+ }
78
+ var prefix = 0
79
+ while (prefix < minOf(old.length, next.length) && old[prefix] == next[prefix]) prefix++
80
+ var suffix = 0
81
+ while (suffix < minOf(old.length, next.length) - prefix &&
82
+ old[old.length - 1 - suffix] == next[next.length - 1 - suffix]
83
+ ) suffix++
84
+ return MoneyEdit(prefix, old.length - suffix, next.substring(prefix, next.length - suffix))
85
+ }
86
+
87
+ // Applies `edit` to the digits of `display`. Separators and currency are never
88
+ // deleted: removing a separator deletes the digit before it instead, removing
89
+ // only the currency leaves the digits untouched.
90
+ // Returns the new digits and the caret as a digit index.
91
+ private fun applyEdit(display: String, edit: MoneyEdit): Pair<String, Int> {
92
+ val digits = digitsOf(display)
93
+ var left = digitsOf(display.substring(0, edit.start)).length
94
+ var removed = digitsOf(display.substring(edit.start, edit.end)).length
95
+ val inserted = digitsOf(edit.inserted)
96
+ if (removed == 0 && inserted.isEmpty() && left > 0 && '.' in display.substring(edit.start, edit.end)) {
97
+ left -= 1
98
+ removed = 1
99
+ }
100
+ if (removed == 0 && inserted.isEmpty()) return digits to left
101
+ val next = digits.substring(0, left) + inserted + digits.substring(left + removed)
102
+ val trimmed = next.trimStart('0').ifEmpty { next.take(1) }
103
+ return trimmed to maxOf(0, left + inserted.length - (next.length - trimmed.length))
77
104
  }
78
105
 
79
106
  @Composable
@@ -102,6 +129,11 @@ fun InputMoney(
102
129
  keyboardType: KeyboardType = KeyboardType.Number,
103
130
  ) {
104
131
  var isFocused by remember { mutableStateOf(false) }
132
+ var field by remember { mutableStateOf(TextFieldValue()) }
133
+ val digits = normalizeDigits(text.value)
134
+ val current = if (digitsOf(field.text) == digits) field else moneyDisplay(digits).let {
135
+ TextFieldValue(it, TextRange(caretFromDigitIndex(it, digits.length)))
136
+ }
105
137
  var isBlurred = false
106
138
  val (textColor, placeholderColor, iconTintColor, floatingTitleColor, floatingIconTintColor) =
107
139
  getInputColors(disabled, iconColor, floatingValueColor, floatingIconColor)
@@ -118,7 +150,7 @@ fun InputMoney(
118
150
  BasicTextField(
119
151
  enabled = !disabled,
120
152
  singleLine = true,
121
- value = text.value,
153
+ value = current,
122
154
  textStyle = TextStyle(
123
155
  color = textColor,
124
156
  fontSize = 20.sp,
@@ -126,7 +158,6 @@ fun InputMoney(
126
158
  fontWeight = fontWeight.value,
127
159
  fontFamily = getFontFamily(AppTheme.current.font, fontWeight.value)
128
160
  ),
129
- visualTransformation = CustomConverter(),
130
161
  keyboardOptions = KeyboardOptions.Default.copy(keyboardType = keyboardType),
131
162
  modifier = Modifier.height(size.values.height).onFocusChanged {
132
163
  isFocused = it.isFocused
@@ -136,9 +167,28 @@ fun InputMoney(
136
167
  if (!it.isFocused && isBlurred) onBlur()
137
168
  if (it.isFocused && !isBlurred) isBlurred = true
138
169
  },
139
- onValueChange = {
140
- val numericValue = it.filter { char -> char.isDigit() }
141
- onChangeText(numericValue)
170
+ onValueChange = { value ->
171
+ if (value.text == current.text) {
172
+ // Keeps the caret out of the currency suffix.
173
+ val max = if (current.text.isEmpty()) 0 else current.text.length - MONEY_CURRENCY.length
174
+ field = value.copy(
175
+ selection = TextRange(
176
+ value.selection.start.coerceAtMost(max),
177
+ value.selection.end.coerceAtMost(max)
178
+ )
179
+ )
180
+ } else {
181
+ val (newDigits, caretDigit) = applyEdit(
182
+ current.text,
183
+ detectEdit(current.text, value.text, value.selection.min)
184
+ )
185
+ val display = moneyDisplay(newDigits)
186
+ field = TextFieldValue(display, TextRange(caretFromDigitIndex(display, caretDigit)))
187
+ if (newDigits != digits) {
188
+ text.value = newDigits
189
+ onChangeText(newDigits)
190
+ }
191
+ }
142
192
  },
143
193
  decorationBox = { innerTextField ->
144
194
  // Floating Icon
@@ -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
+ }
@@ -42,10 +42,10 @@ import kotlin.time.Duration.Companion.milliseconds
42
42
  fun NavigationContainer(
43
43
  initialScreenName: String,
44
44
  initialScreen: @Composable () -> Unit,
45
+ maxApi: IMaxApi,
45
46
  options: NavigationOptions? = null,
46
47
  initialTheme: Theme = defaultTheme,
47
48
  miniAppContext: MiniAppContext? = null,
48
- maxApi: IMaxApi? = null,
49
49
  setNavigator: ((Navigator) -> Unit)? = null,
50
50
  statusBarHeight: Dp? = null,
51
51
  localize: Localize? = null,
@@ -93,7 +93,7 @@ fun NavigationContainer(
93
93
  val startDestination = remember(screenId) { DynamicScreenRoute(screenId) }
94
94
 
95
95
  ProvideOsFontScale {
96
- ProvideNavigationEventDispatcherOwner {
96
+ ProvideNavigationEventDispatcherOwner(navigator) {
97
97
  CompositionLocalProvider(
98
98
  LocalNavigator provides navigator,
99
99
  LocalDynamicScreenRegistry provides registry,
@@ -112,8 +112,6 @@ fun NavigationContainer(
112
112
 
113
113
  SwipeBackHaptic(maxApi)
114
114
 
115
- // Gesture pops never reach Navigator.dismissScreen(), so prune the registry from
116
- // the back stack itself. collectLatest lets a rapid re-push cancel a pending prune.
117
115
  LaunchedEffect(navController, registry) {
118
116
  navController.currentBackStack.collectLatest { entries ->
119
117
  delay(STACK_TRANSITION_DURATION.milliseconds)
@@ -126,10 +124,6 @@ fun NavigationContainer(
126
124
  }
127
125
  }
128
126
 
129
- // The transitions must live on the NavHost call site, not on the destinations:
130
- // NavHost.uikit computes isDefaultTransition from these arguments alone, and while
131
- // it is true the library paints its own hard-coded black iOS blackout that would
132
- // sit over the pink scrim.
133
127
  NavHost(
134
128
  navController,
135
129
  startDestination = startDestination,
@@ -1,11 +1,11 @@
1
1
  package vn.momo.kits.navigation
2
2
 
3
3
  import androidx.compose.animation.animateColorAsState
4
- import androidx.compose.animation.core.animateDpAsState
5
4
  import androidx.compose.foundation.ScrollState
6
5
  import androidx.compose.foundation.background
7
6
  import androidx.compose.foundation.border
8
7
  import androidx.compose.foundation.gestures.ScrollableState
8
+ import androidx.compose.foundation.gestures.animateScrollBy
9
9
  import androidx.compose.foundation.layout.Box
10
10
  import androidx.compose.foundation.layout.Column
11
11
  import androidx.compose.foundation.layout.ColumnScope
@@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.imePadding
21
21
  import androidx.compose.foundation.layout.offset
22
22
  import androidx.compose.foundation.layout.padding
23
23
  import androidx.compose.foundation.lazy.LazyListState
24
- import androidx.compose.foundation.lazy.rememberLazyListState
25
24
  import androidx.compose.foundation.rememberScrollState
26
25
  import androidx.compose.foundation.verticalScroll
27
26
  import androidx.compose.material.ExperimentalMaterialApi
@@ -45,8 +44,11 @@ import androidx.compose.ui.layout.onGloballyPositioned
45
44
  import androidx.compose.ui.platform.LocalDensity
46
45
  import androidx.compose.ui.unit.Dp
47
46
  import androidx.compose.ui.unit.dp
47
+ import androidx.compose.ui.unit.lerp
48
48
  import androidx.compose.ui.zIndex
49
49
  import kotlinx.coroutines.flow.Flow
50
+ import kotlinx.coroutines.flow.filter
51
+ import kotlinx.coroutines.launch
50
52
  import vn.momo.kits.components.InputSearch
51
53
  import vn.momo.kits.const.AppNavigationBar
52
54
  import vn.momo.kits.const.AppStatusBar
@@ -154,13 +156,15 @@ internal fun StackScreen(
154
156
  }
155
157
 
156
158
  val limit = with(density) {
157
- (statusBar).toPx() + HEADER_HEIGHT
159
+ (statusBar + HEADER_HEIGHT.dp).toPx()
158
160
  }.toInt()
159
161
 
160
- val (scrollState, scrollInProcess) = if (options.scrollData.scrollState is LazyListState)
161
- (options.scrollData.scrollState as? LazyListState ?: rememberLazyListState()).proxyScrollState(limit, 15)
162
+ val lazyListState = options.scrollData.scrollState as? LazyListState
163
+ val (scrollState, _) = if (lazyListState != null)
164
+ lazyListState.proxyScrollState(limit, 1)
162
165
  else
163
- (options.scrollData.scrollState as? ScrollState ?: rememberScrollState()).proxyLimitedScrollState(limit, 15)
166
+ (options.scrollData.scrollState as? ScrollState ?: rememberScrollState()).proxyLimitedScrollState(limit, 1)
167
+ val snapState: ScrollableState = lazyListState ?: scrollState
164
168
 
165
169
  val footerHeightPx = remember { mutableIntStateOf(0) }
166
170
  val headerRightWidthPx = remember { mutableIntStateOf(0) }
@@ -205,7 +209,7 @@ internal fun StackScreen(
205
209
  }
206
210
 
207
211
  Column(Modifier.zIndex(5f)) {
208
- SearchAnimated(isScrollInProgress = scrollInProcess)
212
+ SearchAnimated(snapState = snapState)
209
213
  }
210
214
 
211
215
  Column(Modifier.zIndex(2f).fillMaxSize()) {
@@ -269,11 +273,7 @@ internal fun ColumnScope.MainContent(content: @Composable () -> Unit, isBottomTa
269
273
  if (inputSearchType == InputSearchType.Animated) {
270
274
  val scrollDp = with(density) { scrollState.value.toDp() }
271
275
 
272
- val animatedTopPadding by animateDpAsState(
273
- targetValue = (HEADER_HEIGHT.dp - scrollDp).coerceIn(0.dp, HEADER_HEIGHT.dp),
274
- label = "AnimatedTopPadding"
275
- )
276
- Spacer(Modifier.height(animatedTopPadding))
276
+ Spacer(Modifier.height((HEADER_HEIGHT.dp - scrollDp).coerceIn(0.dp, HEADER_HEIGHT.dp)))
277
277
  }
278
278
  Column(
279
279
  Modifier
@@ -413,7 +413,7 @@ data class InputSearchLayoutParams(
413
413
  @Suppress("FrequentlyChangingValue")
414
414
  @Composable
415
415
  internal fun SearchAnimated(
416
- isScrollInProgress: Boolean
416
+ snapState: ScrollableState
417
417
  ) {
418
418
  val scrollState = LocalScrollState.current
419
419
  val options = LocalOptions.current
@@ -453,44 +453,24 @@ internal fun SearchAnimated(
453
453
  }
454
454
 
455
455
  InputSearchType.Animated -> {
456
- val animatedTopPadding by animateDpAsState(
457
- targetValue = (maxTopPadding - scrollDp).coerceIn(minTopPadding, maxTopPadding),
458
- label = "AnimatedTopPadding"
459
- )
460
-
461
- val animatedStartPadding by animateDpAsState(
462
- targetValue = (minStartPadding + scrollDp).coerceIn(minStartPadding, maxStartPadding),
463
- label = "AnimatedStartPadding"
464
- )
465
-
466
- val animatedEndPadding by animateDpAsState(
467
- targetValue = (minEndPadding + scrollDp).coerceIn(minEndPadding, maxEndPadding),
468
- label = "AnimatedEndPadding"
469
- )
470
-
471
- val maxPadding = remember(maxTopPadding, maxStartPadding, maxEndPadding) {
472
- maxOf(maxEndPadding, maxStartPadding, maxTopPadding)
473
- }
474
- val snapScrollValue = with(density) { maxPadding.toPx().toInt() }
475
-
476
- LaunchedEffect(isScrollInProgress && scrollState.value != 0 && scrollState.value != snapScrollValue) {
477
- if (scrollDp < maxPadding) {
478
- if (!isScrollInProgress) {
479
- val midpoint = (maxTopPadding - minTopPadding) / 2
480
-
481
- if (scrollDp < midpoint) {
482
- scrollState.animateScrollTo(0)
483
- } else {
484
- scrollState.animateScrollTo(snapScrollValue)
485
- }
456
+ val collapsePx = with(density) { HEADER_HEIGHT.dp.roundToPx() }
457
+ val progress = (scrollState.value / collapsePx.toFloat()).coerceIn(0f, 1f)
458
+
459
+ LaunchedEffect(snapState, collapsePx) {
460
+ snapshotFlow { snapState.isScrollInProgress }
461
+ .filter { !it }
462
+ .collect {
463
+ val offset = snapState.headerOffsetPx() ?: return@collect
464
+ if (offset <= 0 || offset >= collapsePx) return@collect
465
+ val target = if (offset < collapsePx / 2) 0 else collapsePx
466
+ launch { snapState.animateScrollBy((target - offset).toFloat()) }
486
467
  }
487
- }
488
468
  }
489
469
 
490
470
  InputSearchLayoutParams(
491
- topPadding = animatedTopPadding,
492
- startPadding = animatedStartPadding,
493
- endPadding = animatedEndPadding
471
+ topPadding = lerp(maxTopPadding, minTopPadding, progress),
472
+ startPadding = lerp(minStartPadding, maxStartPadding, progress),
473
+ endPadding = lerp(minEndPadding, maxEndPadding, progress)
494
474
  )
495
475
  }
496
476
 
@@ -519,6 +499,12 @@ internal fun SearchAnimated(
519
499
  }
520
500
  }
521
501
 
502
+ private fun ScrollableState.headerOffsetPx(): Int? = when (this) {
503
+ is LazyListState -> if (firstVisibleItemIndex == 0) firstVisibleItemScrollOffset else null
504
+ is ScrollState -> value
505
+ else -> null
506
+ }
507
+
522
508
  private fun quantize(value: Int, stepPx: Int): Int {
523
509
  if (stepPx <= 1) return value
524
510
  return (value / stepPx) * stepPx
@@ -15,11 +15,14 @@ import androidx.compose.foundation.layout.offset
15
15
  import androidx.compose.foundation.layout.padding
16
16
  import androidx.compose.foundation.layout.size
17
17
  import androidx.compose.foundation.layout.width
18
+ import androidx.compose.foundation.layout.wrapContentHeight
18
19
  import androidx.compose.foundation.shape.RoundedCornerShape
19
20
  import androidx.compose.runtime.Composable
20
21
  import androidx.compose.runtime.getValue
21
22
  import androidx.compose.ui.Alignment
22
23
  import androidx.compose.ui.Modifier
24
+ import androidx.compose.ui.draw.clipToBounds
25
+ import androidx.compose.ui.graphics.graphicsLayer
23
26
  import androidx.compose.ui.graphics.Brush
24
27
  import androidx.compose.ui.graphics.Color
25
28
  import androidx.compose.ui.layout.onGloballyPositioned
@@ -28,6 +31,7 @@ import androidx.compose.ui.text.style.TextAlign
28
31
  import androidx.compose.ui.unit.Dp
29
32
  import androidx.compose.ui.unit.dp
30
33
  import vn.momo.kits.components.Icon
34
+ import vn.momo.kits.components.Image
31
35
  import vn.momo.kits.components.InputSearchProps
32
36
  import vn.momo.kits.const.AppStatusBar
33
37
  import vn.momo.kits.const.AppTheme
@@ -88,15 +92,38 @@ internal fun Header(onBackHandler: (() -> Unit)? = null) {
88
92
  // Centered title needs a full-width overlay to stay screen-centered, not boxed between back button and header right.
89
93
  val centerTitle = titlePosition == TitlePosition.CENTER && options.headerTitle is HeaderTitle.Default
90
94
 
95
+ val headerImage = AppTheme.current.assets.headerBackground
96
+ .takeUnless { options.headerType is HeaderType.Transparent }
97
+
91
98
  Box(
92
99
  Modifier.height(AppStatusBar.current + HEADER_HEIGHT.dp)
93
100
  .fillMaxWidth()
94
- .background(background.copy(alpha = animatedAlpha))
101
+ .conditional(headerImage.isNullOrEmpty()) {
102
+ background(background.copy(alpha = animatedAlpha))
103
+ }
95
104
  .conditional(IsShowBaseLineDebug) {
96
105
  border(1.dp, Colors.blue_03)
97
106
  },
98
107
  contentAlignment = Alignment.BottomCenter
99
108
  ) {
109
+ if (!headerImage.isNullOrEmpty()) {
110
+ Box(
111
+ Modifier
112
+ .matchParentSize()
113
+ .clipToBounds()
114
+ .graphicsLayer { alpha = animatedAlpha }
115
+ .background(background)
116
+ ) {
117
+ Image(
118
+ source = headerImage,
119
+ loading = false,
120
+ modifier = Modifier
121
+ .fillMaxWidth()
122
+ .wrapContentHeight(Alignment.Top, unbounded = true)
123
+ .height(HEADER_BACKGROUND_HEIGHT),
124
+ )
125
+ }
126
+ }
100
127
  Row(
101
128
  modifier = Modifier.height(HEADER_HEIGHT.dp)
102
129
  .fillMaxWidth()
@@ -27,6 +27,8 @@ enum class AnimatedHeaderRatio(val value: Float){
27
27
  RATIO_3_2(3f / 2f)
28
28
  }
29
29
 
30
+ internal val HEADER_BACKGROUND_HEIGHT = 154.dp
31
+
30
32
  @Suppress("FrequentlyChangingValue")
31
33
  @Composable
32
34
  internal fun HeaderBackground() {
@@ -37,7 +39,7 @@ internal fun HeaderBackground() {
37
39
  val backgroundColor = options.backgroundColor ?: AppTheme.current.colors.background.default
38
40
 
39
41
  val minHeight = AppStatusBar.current + HEADER_HEIGHT.dp
40
- val maxHeight = 154.dp
42
+ val maxHeight = HEADER_BACKGROUND_HEIGHT
41
43
  val opacity by animateFloatAsState(
42
44
  targetValue = (1 - (scrollState.value * 1f / HEADER_HEIGHT * 1f)).coerceIn(0f, 1f),
43
45
  )