@momo-kits/native-kits 0.167.1-duo.21 → 0.167.1-duo.22-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 (149) hide show
  1. package/build.gradle.kts +11 -0
  2. package/compose/build.gradle.kts +204 -0
  3. package/compose/build.gradle.kts.backup +204 -0
  4. package/compose/compose.podspec +47 -0
  5. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +19 -0
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/navigation/ScrollToTop.android.kt +6 -0
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +12 -0
  8. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +7 -0
  9. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +153 -0
  10. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +124 -0
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +951 -0
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +269 -0
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +56 -0
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +157 -0
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +81 -0
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeDot.kt +32 -0
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +338 -0
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +175 -0
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +357 -0
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +115 -0
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +89 -0
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +128 -0
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +209 -0
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +542 -0
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +49 -0
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +86 -0
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +125 -0
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +231 -0
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +189 -0
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +478 -0
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +114 -0
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +265 -0
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +235 -0
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +223 -0
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +265 -0
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +185 -0
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +343 -0
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +108 -0
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +56 -0
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationNumber.kt +41 -0
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +84 -0
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationWhiteDot.kt +40 -0
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +361 -0
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +108 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +308 -0
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +59 -0
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +87 -0
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +21 -0
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +90 -0
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +330 -0
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +220 -0
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +473 -0
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +122 -0
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +207 -0
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +92 -0
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +560 -0
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +86 -0
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +125 -0
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +207 -0
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +501 -0
  62. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +176 -0
  63. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +182 -0
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +193 -0
  65. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +29 -0
  66. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +236 -0
  67. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +180 -0
  68. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Colors.kt +325 -0
  69. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +20 -0
  70. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +18 -0
  71. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +22 -0
  72. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Radius.kt +12 -0
  73. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +16 -0
  74. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +194 -0
  75. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +283 -0
  76. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +54 -0
  77. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +50 -0
  78. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +309 -0
  79. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +41 -0
  80. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +52 -0
  81. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +66 -0
  82. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Conditional.kt +11 -0
  83. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +104 -0
  84. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +48 -0
  85. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Size.kt +51 -0
  86. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +338 -0
  87. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +58 -0
  88. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +137 -0
  89. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +116 -0
  90. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +205 -0
  91. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +399 -0
  92. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +8 -0
  93. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +727 -0
  94. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +148 -0
  95. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +35 -0
  96. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +218 -0
  97. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +231 -0
  98. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +86 -0
  99. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +98 -0
  100. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +312 -0
  101. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +169 -0
  102. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +455 -0
  103. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +103 -0
  104. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +106 -0
  105. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +74 -0
  106. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +399 -0
  107. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +39 -0
  108. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +363 -0
  109. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
  110. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +133 -0
  111. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +127 -0
  112. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +12 -0
  113. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +91 -0
  114. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +9 -0
  115. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +74 -0
  116. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
  117. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Icons.kt +1329 -0
  118. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +55 -0
  119. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +15 -0
  120. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +118 -0
  121. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +78 -0
  122. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +45 -0
  123. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +32 -0
  124. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +38 -0
  125. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +66 -0
  126. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +217 -0
  127. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +94 -0
  128. package/gradle/libs.versions.toml +69 -0
  129. package/gradle/wrapper/gradle-wrapper.jar +0 -0
  130. package/gradle/wrapper/gradle-wrapper.properties +8 -0
  131. package/gradle.properties +26 -0
  132. package/gradlew +252 -0
  133. package/gradlew.bat +94 -0
  134. package/ios/Application/MaxApi.swift +20 -2
  135. package/ios/Input/InputSearch.swift +5 -2
  136. package/ios/Navigation/BottomSheet.swift +58 -37
  137. package/ios/Navigation/BottomTab/BottomTab.swift +165 -18
  138. package/ios/Navigation/BottomTab/BottomTabBar.swift +3 -0
  139. package/ios/Navigation/Component/FloatingButton.swift +29 -51
  140. package/ios/Navigation/Component/Header.swift +19 -5
  141. package/ios/Navigation/Component/HeaderRight.swift +48 -19
  142. package/ios/Navigation/Component/SnackBar.swift +3 -2
  143. package/ios/Navigation/ModalScreen.swift +2 -1
  144. package/ios/Navigation/NavigationContainer.swift +3 -2
  145. package/ios/Navigation/Navigator.swift +30 -16
  146. package/ios/Navigation/StackScreen.swift +151 -37
  147. package/ios-demo/README.md +120 -0
  148. package/package.json +1 -1
  149. package/settings.gradle.kts +64 -0
@@ -191,38 +191,64 @@ struct Toolkit: View {
191
191
  return json["response"]
192
192
  }
193
193
 
194
+ private typealias MaxApiRequest = (MaxApi, [String: Any?], @escaping ([String: Any?]?) -> Void) -> Void
195
+
196
+ /// Through `composeApi` while the host still passes one, else `maxApi` (Compose `IMaxApi`).
197
+ /// Hosts may answer off the main thread, so the response is handed back on it.
198
+ private func request(
199
+ _ funcName: String,
200
+ _ params: [String: Any],
201
+ maxApi maxApiRequest: MaxApiRequest,
202
+ onResponse: @escaping (Any?) -> Void = { _ in }
203
+ ) {
204
+ if let composeApi = environment.composeApi {
205
+ _ = composeApi.request(funcName: funcName, params: params) { response in
206
+ let value = parseResponse(response)
207
+ DispatchQueue.main.async { onResponse(value) }
208
+ }
209
+ } else if let maxApi = environment.maxApi {
210
+ maxApiRequest(maxApi, params.mapValues { $0 as Any? }) { data in
211
+ let value = data?["response"] ?? nil
212
+ DispatchQueue.main.async { onResponse(value) }
213
+ }
214
+ }
215
+ }
216
+
194
217
  // MARK: Actions
195
218
 
196
219
  private func loadFavoriteState() {
197
- _ = environment.composeApi?.request(
198
- funcName: "isFavoriteApp",
199
- params: ["code": apiValue(context?.appCode)]
220
+ request(
221
+ "isFavoriteApp",
222
+ ["code": apiValue(context?.appCode)],
223
+ maxApi: { $0.isFavoriteApp($1, callback: $2) }
200
224
  ) { response in
201
- isFavorite = parseResponse(response) as? Bool == true
225
+ isFavorite = response as? Bool == true
202
226
  }
203
227
  }
204
228
 
205
229
  private func onPressShortcut() {
206
- _ = environment.composeApi?.request(
207
- funcName: "onToolAction",
208
- params: ["item": ["key": "onFavorite"], "context": contextMap]
230
+ request(
231
+ "onToolAction",
232
+ ["item": ["key": "onFavorite"], "context": contextMap],
233
+ maxApi: { $0.onToolAction($1, callback: $2) }
209
234
  ) { response in
210
- if let result = parseResponse(response) as? [String: Any], result["success"] as? Bool == true {
235
+ if let result = response as? [String: Any], result["success"] as? Bool == true {
211
236
  isFavorite.toggle()
212
237
  }
213
238
  }
214
239
  }
215
240
 
216
241
  private func onPressMore() {
217
- _ = environment.composeApi?.request(
218
- funcName: "showTools",
219
- params: [
242
+ request(
243
+ "showTools",
244
+ [
220
245
  "useSystemTools": headerRight.useSystemTools,
221
246
  "tools": headerRight.tools.map { $0.toMap() },
222
247
  "context": contextMap
223
- ]
248
+ ],
249
+ maxApi: { $0.showTools($1, callback: $2) }
224
250
  ) { response in
225
- if let tool = parseResponse(response) as? String {
251
+ if let tool = response as? String {
226
252
  headerRight.toolCallback?(tool)
227
253
  }
228
254
  }
@@ -230,23 +256,26 @@ struct Toolkit: View {
230
256
 
231
257
  private func onPressHelpCenter() {
232
258
  guard context != nil else { return }
233
- _ = environment.composeApi?.request(
234
- funcName: "showHelpCenter",
235
- params: [
259
+ request(
260
+ "showHelpCenter",
261
+ [
236
262
  "appId": apiValue(context?.appId),
237
263
  "code": apiValue(context?.appCode),
238
264
  "name": apiValue(context?.appName),
239
265
  "icon": apiValue(context?.appIcon),
240
266
  "description": apiValue(context?.description)
241
- ]
242
- ) { _ in }
267
+ ],
268
+ maxApi: { $0.showHelpCenter($1, callback: $2) }
269
+ )
243
270
  }
244
271
 
245
272
  private func onPressClose() {
246
273
  if headerRight.useCloseIcon {
247
274
  environment.requestDismiss()
275
+ } else if let composeApi = environment.composeApi {
276
+ _ = composeApi.request(funcName: "dismissAll", params: nil) { _ in }
248
277
  } else {
249
- _ = environment.composeApi?.request(funcName: "dismissAll", params: nil) { _ in }
278
+ environment.maxApi?.dismissAll(completion: nil)
250
279
  }
251
280
  }
252
281
 
@@ -50,6 +50,7 @@ public struct SnackBar {
50
50
  /// Self-animating container. Slides the content up on appear, schedules an
51
51
  /// auto-dismiss after `duration`, and animates out before removing the overplay.
52
52
  struct SnackBarView: View {
53
+ let overplayId: UUID
53
54
  let snackBar: SnackBar
54
55
  /// Distance from the window bottom to the top of the screen's footer, or the navigation bar
55
56
  /// inset without one (Compose's `footerHeight` offset).
@@ -121,7 +122,7 @@ struct SnackBarView: View {
121
122
  offsetY = max(contentHeight, 200) + bottomInset
122
123
  }
123
124
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
124
- navigator.finishOverplayDismiss()
125
+ navigator.finishOverplayDismiss(id: overplayId)
125
126
  }
126
127
  }
127
128
 
@@ -133,7 +134,7 @@ struct SnackBarView: View {
133
134
  guard let duration = snackBar.duration else { return }
134
135
  let workItem = DispatchWorkItem {
135
136
  // Only auto-dismiss if this snack bar is still the active overplay.
136
- if navigator.overplay?.type == .snackBar {
137
+ if navigator.overplay?.id == overplayId {
137
138
  navigator.hideSnackBar()
138
139
  }
139
140
  }
@@ -10,6 +10,7 @@
10
10
  import SwiftUI
11
11
 
12
12
  struct ModalScreen<Content: View>: View {
13
+ let overplayId: UUID
13
14
  let barrierDismissible: Bool
14
15
  let content: () -> Content
15
16
 
@@ -74,7 +75,7 @@ struct ModalScreen<Content: View>: View {
74
75
  contentOpacity = 0
75
76
  }
76
77
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
77
- navigator.finishOverplayDismiss()
78
+ navigator.finishOverplayDismiss(id: overplayId)
78
79
  }
79
80
  }
80
81
  }
@@ -168,12 +168,13 @@ private struct OverplayHost: View {
168
168
  // the Navigator before the item is removed (mirrors Compose's overplay).
169
169
  switch item.type {
170
170
  case .modal:
171
- ModalScreen(barrierDismissible: item.barrierDismissible) {
171
+ ModalScreen(overplayId: item.id, barrierDismissible: item.barrierDismissible) {
172
172
  item.content()
173
173
  }
174
174
  .id(item.id)
175
175
  case .bottomSheet:
176
176
  BottomSheetView(
177
+ overplayId: item.id,
177
178
  header: item.header ?? .title(),
178
179
  isSurface: item.isSurface,
179
180
  barrierDismissible: item.barrierDismissible,
@@ -186,7 +187,7 @@ private struct OverplayHost: View {
186
187
  .id(item.id)
187
188
  case .snackBar:
188
189
  if let snackBar = item.snackBar {
189
- SnackBarView(snackBar: snackBar, bottomInset: navigator.snackBarBottomInset())
190
+ SnackBarView(overplayId: item.id, snackBar: snackBar, bottomInset: navigator.snackBarBottomInset())
190
191
  .id(item.id)
191
192
  }
192
193
  }
@@ -238,20 +238,19 @@ public final class Navigator: ObservableObject {
238
238
  // Mirrors Kotlin: a snackbar doesn't block back-navigation, so popping
239
239
  // while one is showing pops the screen underneath and clears the
240
240
  // snackbar immediately, without playing its dismiss animation.
241
- overplayDismissHandler = nil
242
- overplay = nil
241
+ replaceOverplay(with: nil)
243
242
  guard popScreen() else {
244
243
  callback?()
245
244
  return
246
245
  }
247
246
  popStep(remaining: remaining - 1, callback: callback)
248
- } else if overplay != nil {
247
+ } else if let closing = overplay?.id {
249
248
  hideOverplay()
250
249
  DispatchQueue.main.asyncAfter(deadline: .now() + Self.overplayDismissDelay) { [weak self] in
251
250
  guard let self else { return }
252
251
  // The component reports back through `finishOverplayDismiss` once its exit
253
252
  // animation ends; clear it here if it never did, so the step is not wasted.
254
- if self.overplay != nil { self.finishOverplayDismiss() }
253
+ self.finishOverplayDismiss(id: closing)
255
254
  self.popStep(remaining: remaining - 1, callback: callback)
256
255
  }
257
256
  } else {
@@ -302,6 +301,11 @@ public final class Navigator: ObservableObject {
302
301
  }
303
302
 
304
303
  public func onBackSafe(callback: (() -> Void)? = nil) {
304
+ // An open modal or bottom sheet takes the back first (their BackHandler in Compose).
305
+ if let overplay, overplay.type != .snackBar {
306
+ pop(1, callback: callback)
307
+ return
308
+ }
305
309
  let latest = currentScreen()
306
310
  if let tabBackHandler, tabBackHandler.screenId == -1 || tabBackHandler.screenId == latest?.id {
307
311
  tabBackHandler.handler()
@@ -420,14 +424,13 @@ public final class Navigator: ObservableObject {
420
424
  barrierDismissible: Bool = true,
421
425
  onDismiss: (() -> Void)? = nil
422
426
  ) {
423
- overplayDismissHandler = nil
424
- overplay = OverplayItem(
427
+ replaceOverplay(with: OverplayItem(
425
428
  type: .modal,
426
429
  content: { AnyView(content()) },
427
430
  barrierDismissible: barrierDismissible,
428
431
  onDismiss: onDismiss,
429
432
  screenId: entries.last?.id
430
- )
433
+ ))
431
434
  }
432
435
 
433
436
  public func showBottomSheet<V: View>(
@@ -440,8 +443,7 @@ public final class Navigator: ObservableObject {
440
443
  useBottomInset: Bool = true,
441
444
  footer: (() -> AnyView)? = nil
442
445
  ) {
443
- overplayDismissHandler = nil
444
- overplay = OverplayItem(
446
+ replaceOverplay(with: OverplayItem(
445
447
  type: .bottomSheet,
446
448
  content: { AnyView(content()) },
447
449
  barrierDismissible: barrierDismissible,
@@ -452,20 +454,19 @@ public final class Navigator: ObservableObject {
452
454
  useBottomInset: useBottomInset,
453
455
  footer: footer,
454
456
  screenId: entries.last?.id
455
- )
457
+ ))
456
458
  }
457
459
 
458
460
  /// Mirrors Compose's `showSnackBar(snackBar:)`.
459
461
  public func showSnackBar(_ snackBar: SnackBar, onDismiss: (() -> Void)? = nil) {
460
- overplayDismissHandler = nil
461
- overplay = OverplayItem(
462
+ replaceOverplay(with: OverplayItem(
462
463
  type: .snackBar,
463
464
  content: { AnyView(EmptyView()) },
464
465
  barrierDismissible: false,
465
466
  onDismiss: onDismiss,
466
467
  snackBar: snackBar,
467
468
  screenId: entries.last?.id
468
- )
469
+ ))
469
470
  }
470
471
 
471
472
  // MARK: Overplay dismissal
@@ -480,10 +481,23 @@ public final class Navigator: ObservableObject {
480
481
  /// `hardClearAfterDismiss`). Called by a component once its exit animation
481
482
  /// has completed.
482
483
  public func finishOverplayDismiss() {
483
- let dismissCallback = overplay?.onDismiss
484
+ replaceOverplay(with: nil)
485
+ }
486
+
487
+ /// Same, but only while `id` is still the overplay: an exit that ends after a newer overplay
488
+ /// took its place must not take that one down.
489
+ public func finishOverplayDismiss(id: UUID) {
490
+ guard overplay?.id == id else { return }
491
+ replaceOverplay(with: nil)
492
+ }
493
+
494
+ /// A replaced overplay counts as dismissed (Compose disposes it), so its `onDismiss` fires once,
495
+ /// whether it was still closing or not.
496
+ private func replaceOverplay(with item: OverplayItem?) {
497
+ let replaced = overplay
484
498
  overplayDismissHandler = nil
485
- overplay = nil
486
- dismissCallback?()
499
+ overplay = item
500
+ replaced?.onDismiss?()
487
501
  }
488
502
 
489
503
  public func hideOverplay() {
@@ -116,7 +116,6 @@ public struct StackScreen<Content: View>: View {
116
116
  @State private var isBottomTabRoot: Bool = false
117
117
  @State private var headerHide: CGFloat = 0
118
118
  @State private var lastScrollOffset: CGFloat = 0
119
- @State private var headerSettle: Task<Void, Never>?
120
119
  @State private var footerHeight: CGFloat = 0
121
120
 
122
121
  @Environment(\.applicationEnvironment) private var appEnv
@@ -126,9 +125,7 @@ public struct StackScreen<Content: View>: View {
126
125
  @Environment(\.appTheme) private var theme
127
126
  @OptionalEnvironmentObject private var navigation: Navigation?
128
127
 
129
- // Auto-tracking state (Compose's `remember { ScreenTrackingState() }` + the once-computed action).
130
- @State private var trackingState = ScreenTrackingState()
131
- @State private var trackingAction: String = ScreenTracker.getLastScreenName() != nil ? "push" : "back"
128
+ @State private var tracking: Task<Void, Never>?
132
129
 
133
130
  /// Compose's `headerOpacity()`: 0 over a banner or transparent header, 1 once the bar is opaque.
134
131
  private var opacity: CGFloat {
@@ -189,25 +186,21 @@ public struct StackScreen<Content: View>: View {
189
186
  return
190
187
  }
191
188
  headerHide = min(max(headerHide + delta, 0), layout.headerHeight)
192
- settleHeaderWhenScrollStops()
193
- }
194
-
195
- /// A half-shown header settles fully in or out. SwiftUI before iOS 18 reports no scroll
196
- /// phase, so the scroll counts as ended once the offset stops changing.
197
- private func settleHeaderWhenScrollStops() {
198
- headerSettle?.cancel()
199
- headerSettle = Task { @MainActor in
200
- try? await Task.sleep(nanoseconds: 150_000_000)
201
- guard !Task.isCancelled else { return }
202
- let height = layout.headerHeight
203
- // within the first header height the header still sits on the content
204
- guard scrollOffset >= height, headerHide > 0, headerHide < height else { return }
205
- withAnimation(.easeOut(duration: 0.2)) {
206
- headerHide = headerHide > height / 2 ? height : 0
207
- }
189
+ }
190
+
191
+ /// A half-shown header settles fully in or out once the scroll comes to rest.
192
+ private func settleHeader() {
193
+ let height = layout.headerHeight
194
+ // within the first header height the header still sits on the content
195
+ guard useQuickReturn, scrollOffset >= height, headerHide > 0, headerHide < height else { return }
196
+ withAnimation(.easeOut(duration: 0.2)) {
197
+ headerHide = headerHide > height / 2 ? height : 0
208
198
  }
209
199
  }
210
200
 
201
+ /// Compose snaps the animated search fully in or out when the scroll stops.
202
+ private var searchSnapDistance: CGFloat? { inputSearchType == .animated ? HEADER_HEIGHT : nil }
203
+
211
204
  private var keyboardSize: CGFloat { useAvoidKeyboard ? max(keyboardHeight, 0) : 0 }
212
205
 
213
206
  /// What the footer, tab bar or home indicator takes from the bottom of the window, gap included.
@@ -338,35 +331,50 @@ public struct StackScreen<Content: View>: View {
338
331
  }
339
332
  .onAppear(perform: reportBottomObstruction)
340
333
  .onChange(of: bottomObstruction) { _ in reportBottomObstruction() }
341
- .task(id: screenName) {
334
+ .onAppear(perform: startTracking)
335
+ .onChange(of: screenName) { _ in startTracking() }
336
+ .onDisappear { tracking?.cancel() }
337
+ }
338
+
339
+ /// Compose tracks from a `LaunchedEffect` with `remember`ed state, so every visit — first show, a
340
+ /// pop back, a tab switch — starts over with fresh state. A covered hosting controller keeps its
341
+ /// `@State` and doesn't re-run `.task`, so the run hangs off appear / disappear instead.
342
+ private func startTracking() {
343
+ tracking?.cancel()
344
+ let state = ScreenTrackingState()
345
+ let action = ScreenTracker.getLastScreenName() != nil ? "push" : "back"
346
+ let maxApi = appEnv.maxApi
347
+ let context = appEnv.applicationContext
348
+ let screenName = screenName
349
+ tracking = Task { @MainActor in
342
350
  ScreenTracker.trackScreenNavigated(
343
- maxApi: appEnv.maxApi,
344
- context: appEnv.applicationContext,
351
+ maxApi: maxApi,
352
+ context: context,
345
353
  screenName: screenName,
346
- action: trackingAction,
347
- state: trackingState
354
+ action: action,
355
+ state: state
348
356
  )
349
357
  do {
350
358
  try await Task.sleep(nanoseconds: 2_000_000_000)
351
359
  } catch {
352
360
  return
353
361
  }
354
- let loadTime = ScreenTracker.now() - trackingState.time
362
+ let loadTime = ScreenTracker.now() - state.time
355
363
  ScreenTracker.trackScreenDisplayed(
356
- maxApi: appEnv.maxApi,
357
- context: appEnv.applicationContext,
364
+ maxApi: maxApi,
365
+ context: context,
358
366
  screenName: screenName,
359
367
  duration: loadTime,
360
- state: trackingState
368
+ state: state
361
369
  )
362
- let interactionTime = ScreenTracker.now() - trackingState.time
370
+ let interactionTime = ScreenTracker.now() - state.time
363
371
  ScreenTracker.trackScreenInteracted(
364
- maxApi: appEnv.maxApi,
365
- context: appEnv.applicationContext,
372
+ maxApi: maxApi,
373
+ context: context,
366
374
  screenName: screenName,
367
375
  duration: interactionTime - loadTime,
368
376
  totalDuration: interactionTime,
369
- state: trackingState
377
+ state: state
370
378
  )
371
379
  }
372
380
  }
@@ -385,6 +393,8 @@ public struct StackScreen<Content: View>: View {
385
393
  scrollToTopCallback: scrollToTopCallback,
386
394
  animatedSearchInset: animatedSearchInset,
387
395
  bottomInset: duoTabBarInsets.overlay,
396
+ snapDistance: searchSnapDistance,
397
+ onScrollIdle: settleHeader,
388
398
  content: { animatedHeroBody(animatedHeader) }
389
399
  )
390
400
  .edgesIgnoringSafeArea(.all)
@@ -419,6 +429,8 @@ public struct StackScreen<Content: View>: View {
419
429
  scrollToTopCallback: scrollToTopCallback,
420
430
  animatedSearchInset: animatedSearchInset,
421
431
  bottomInset: duoTabBarInsets.overlay,
432
+ snapDistance: searchSnapDistance,
433
+ onScrollIdle: settleHeader,
422
434
  content: { duoInsetContent }
423
435
  )
424
436
  .edgesIgnoringSafeArea(.all)
@@ -438,6 +450,8 @@ public struct StackScreen<Content: View>: View {
438
450
  scrollToTopCallback: scrollToTopCallback,
439
451
  topInset: layout.headerHeight - layout.insets.top,
440
452
  bottomInset: duoTabBarInsets.overlay,
453
+ snapDistance: searchSnapDistance,
454
+ onScrollIdle: settleHeader,
441
455
  content: content
442
456
  )
443
457
 
@@ -483,6 +497,8 @@ public struct StackScreen<Content: View>: View {
483
497
  scrollToTopCallback: scrollToTopCallback,
484
498
  animatedSearchInset: animatedSearchInset,
485
499
  bottomInset: duoTabBarInsets.overlay,
500
+ snapDistance: searchSnapDistance,
501
+ onScrollIdle: settleHeader,
486
502
  content: content
487
503
  )
488
504
  }
@@ -563,11 +579,30 @@ private struct StackScreenContent<Content: View>: View {
563
579
  var topInset: CGFloat = 0
564
580
  /// Room for a bar that overlays the content (iPhone Duo floating tab bar).
565
581
  var bottomInset: CGFloat = 0
582
+ /// Compose's animated-search snap: a scroll that comes to rest part way through this distance
583
+ /// finishes the move, up or back down.
584
+ var snapDistance: CGFloat? = nil
585
+ var onScrollIdle: (() -> Void)? = nil
566
586
  let content: () -> Content
567
587
 
568
588
  private let topAnchorID = "stack_screen_top_anchor"
569
589
  private let scrollSpace = "stack_screen_scroll_space"
570
590
 
591
+ @State private var scrollView = WeakScrollView()
592
+
593
+ private func scrollDidRest() {
594
+ onScrollIdle?()
595
+ guard let snapDistance, scrollOffset > 0, scrollOffset < snapDistance,
596
+ let scrollView = scrollView.value else { return }
597
+ // UIKit, not `ScrollViewProxy.scrollTo`: SwiftUI keeps re-applying a scrollTo target while the
598
+ // content above it resizes, which this content does as the search inset shrinks with the scroll
599
+ let target = scrollOffset < snapDistance / 2 ? 0 : snapDistance
600
+ scrollView.setContentOffset(
601
+ CGPoint(x: scrollView.contentOffset.x, y: target - scrollView.adjustedContentInset.top),
602
+ animated: true
603
+ )
604
+ }
605
+
571
606
  var body: some View {
572
607
  if scrollable {
573
608
  ScrollViewReader { proxy in
@@ -587,6 +622,7 @@ private struct StackScreenContent<Content: View>: View {
587
622
  scrollOffset = -minY
588
623
  }
589
624
  })
625
+ .background(ScrollViewProbe { scrollView.value = $0 })
590
626
 
591
627
  if animatedSearchInset + topInset > 0 {
592
628
  Spacer().frame(height: animatedSearchInset + topInset)
@@ -602,6 +638,7 @@ private struct StackScreenContent<Content: View>: View {
602
638
  }
603
639
  }
604
640
  .coordinateSpace(name: scrollSpace)
641
+ .modifier(ScrollRest(offset: scrollOffset, onRest: scrollDidRest))
605
642
  // Status-bar tap (MoMoStatusBarTap swizzle) scrolls to top and notifies the consumer.
606
643
  .onReceive(NotificationCenter.default.publisher(for: MoMoStatusBarTap.notificationName)) { _ in
607
644
  withAnimation {
@@ -618,6 +655,74 @@ private struct StackScreenContent<Content: View>: View {
618
655
  }
619
656
  }
620
657
 
658
+ private final class WeakScrollView {
659
+ weak var value: UIScrollView?
660
+ }
661
+
662
+ /// Finds the UIScrollView behind the enclosing SwiftUI `ScrollView` once placed in its content.
663
+ private struct ScrollViewProbe: UIViewRepresentable {
664
+ let found: (UIScrollView) -> Void
665
+
666
+ func makeUIView(context: Context) -> Probe { Probe(found: found) }
667
+
668
+ func updateUIView(_ uiView: Probe, context: Context) {}
669
+
670
+ final class Probe: UIView {
671
+ let found: (UIScrollView) -> Void
672
+
673
+ init(found: @escaping (UIScrollView) -> Void) {
674
+ self.found = found
675
+ super.init(frame: .zero)
676
+ isUserInteractionEnabled = false
677
+ }
678
+
679
+ required init?(coder: NSCoder) { nil }
680
+
681
+ override func didMoveToWindow() {
682
+ super.didMoveToWindow()
683
+ var view = superview
684
+ while let current = view, !(current is UIScrollView) { view = current.superview }
685
+ if let scrollView = view as? UIScrollView { found(scrollView) }
686
+ }
687
+ }
688
+ }
689
+
690
+ /// Calls `onRest` once the scroll comes to rest. Scroll phases need iOS 18; before that the scroll
691
+ /// counts as ended once the offset stops changing, which a finger held still also looks like.
692
+ private struct ScrollRest: ViewModifier {
693
+ let offset: CGFloat
694
+ let onRest: () -> Void
695
+
696
+ @State private var pending: Task<Void, Never>?
697
+
698
+ func body(content: Content) -> some View {
699
+ #if compiler(>=6.0)
700
+ if #available(iOS 18.0, *) {
701
+ content.onScrollPhaseChange { _, phase in
702
+ if phase == .idle { onRest() }
703
+ }
704
+ } else {
705
+ debounced(content)
706
+ }
707
+ #else
708
+ debounced(content)
709
+ #endif
710
+ }
711
+
712
+ private func debounced(_ content: Content) -> some View {
713
+ content
714
+ .onChange(of: offset) { _ in
715
+ pending?.cancel()
716
+ pending = Task { @MainActor in
717
+ try? await Task.sleep(nanoseconds: 150_000_000)
718
+ guard !Task.isCancelled else { return }
719
+ onRest()
720
+ }
721
+ }
722
+ .onDisappear { pending?.cancel() }
723
+ }
724
+ }
725
+
621
726
  // MARK: - Animated search header
622
727
 
623
728
  /// Compose's `SearchAnimated`: the search slides from below the header (scroll 0) up into the bar
@@ -633,6 +738,7 @@ private struct SearchAnimatedHeader: View {
633
738
 
634
739
  @Environment(\.screenLayout) private var layout
635
740
  @Environment(\.appTheme) private var theme
741
+ @OptionalEnvironmentObject private var navigator: Navigator?
636
742
 
637
743
  var body: some View {
638
744
  let scroll = max(scrollOffset, 0)
@@ -650,12 +756,20 @@ private struct SearchAnimatedHeader: View {
650
756
  ? Spacing.M
651
757
  : headerRightWidth + (hasHeaderRight ? Spacing.M * 2 : Spacing.M))
652
758
 
653
- let top = min(max(maxTop - scroll, minTop), maxTop)
654
- let start = min(max(minSide + scroll, minSide), maxStart)
655
- let end = min(max(minSide + scroll, minSide), maxEnd)
759
+ // one progress for all three, so the field docks with its final insets and never covers the toolkit
760
+ let progress = min(scroll / HEADER_HEIGHT, 1)
761
+ let top = maxTop + (minTop - maxTop) * progress
762
+ let start = minSide + (maxStart - minSide) * progress
763
+ let end = minSide + (maxEnd - minSide) * progress
656
764
 
657
765
  // padding, not a Spacer: on the iPhone Duo status bar row the collapsed top is above the safe area
658
- InputSearch(props: headerSearchProps(props, scroll: scroll, layout: layout, theme: theme))
766
+ InputSearch(props: headerSearchProps(
767
+ props,
768
+ scroll: scroll,
769
+ layout: layout,
770
+ theme: theme,
771
+ pop: navigator.map { navigator in { navigator.pop() } }
772
+ ))
659
773
  .frame(height: HEADER_HEIGHT)
660
774
  .padding(.leading, start)
661
775
  .padding(.trailing, end)