@momo-kits/native-kits 0.162.30-debug → 0.162.31-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 (93) hide show
  1. package/CLAUDE.md +19 -0
  2. package/compose/build.gradle.kts +1 -1
  3. package/compose/compose.podspec +1 -1
  4. package/gradle.properties +1 -1
  5. package/ios/Application/ApplicationEnvironment.swift +14 -6
  6. package/ios/Application/LanguageSource.swift +93 -0
  7. package/ios/Application/Localize.swift +73 -4
  8. package/ios/Application/LocalizeModifier.swift +30 -0
  9. package/ios/Application/Navigation/NavigationContainer.swift +28 -18
  10. package/ios/Application/Navigation/Navigator.swift +121 -53
  11. package/ios/Application/Navigation/NavigatorManager.swift +49 -0
  12. package/ios/Application/Navigation/component/NavigationFooter.swift +90 -0
  13. package/ios/Application/StackScreen.swift +19 -28
  14. package/ios/Badge/Badge.swift +2 -1
  15. package/ios/Badge/BadgeRibbon.swift +6 -2
  16. package/ios/Button/Button.swift +41 -61
  17. package/ios/Chip/Chip.swift +6 -5
  18. package/ios/Icon/Icon.swift +13 -4
  19. package/ios/Input/ErrorView.swift +2 -1
  20. package/ios/Input/Input.swift +9 -2
  21. package/ios/Input/InputPhoneNumber.swift +2 -1
  22. package/ios/Input/InputSearch.swift +3 -2
  23. package/ios/Input/InputTextArea.swift +6 -1
  24. package/ios/InputMoney/InputMoney.swift +5 -2
  25. package/ios/InputOTP/InputOTP.swift +2 -1
  26. package/ios/Layout/GridContext.swift +30 -0
  27. package/ios/Layout/Item.swift +42 -0
  28. package/ios/Layout/Section.swift +134 -0
  29. package/ios/Popup/PopupNotify.swift +2 -1
  30. package/ios/ProgressInfo/ProgressInfo.swift +4 -3
  31. package/ios/Rating/Rating.swift +2 -1
  32. package/ios/Stepper/Stepper.swift +2 -1
  33. package/ios/SuggestAction/SuggestAction.swift +3 -2
  34. package/ios/Tag/Tag.swift +2 -1
  35. package/ios/Template/TrustBanner/TrustBanner.swift +4 -2
  36. package/ios/Title/Title.swift +3 -2
  37. package/ios/Tooltip/Tooltip.swift +2 -1
  38. package/ios/Typography/FontScale.swift +103 -0
  39. package/ios/Typography/FontScaleStore.swift +52 -13
  40. package/ios/Typography/Text.swift +12 -29
  41. package/ios/Typography/Typography.swift +66 -86
  42. package/ios/native-kits.podspec +2 -1
  43. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  44. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/AvatarDemo.swift +87 -73
  45. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/BadgeDemo.swift +55 -249
  46. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ButtonDemo.swift +90 -82
  47. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CarouselDemo.swift +74 -104
  48. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CheckboxDemo.swift +47 -50
  49. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ChipDemo.swift +63 -7
  50. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CollapseDemo.swift +90 -81
  51. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DateTimePickerDemo.swift +73 -97
  52. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DemoScreen.swift +420 -0
  53. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DividerDemo.swift +46 -4
  54. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +54 -57
  55. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconButtonDemo.swift +80 -45
  56. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconDemo.swift +81 -0
  57. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ImageDemo.swift +49 -62
  58. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InformationDemo.swift +66 -116
  59. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDemo.swift +104 -98
  60. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDropDownDemo.swift +76 -118
  61. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputMoneyDemo.swift +73 -86
  62. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputOTPDemo.swift +69 -54
  63. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputPhoneNumberDemo.swift +74 -54
  64. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputSearchDemo.swift +73 -20
  65. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputTextAreaDemo.swift +68 -38
  66. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LoaderDemo.swift +55 -52
  67. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LocalizeDemo.swift +51 -4
  68. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +14 -2
  69. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PaginationDemo.swift +47 -51
  70. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PlaygroundData.swift +76 -0
  71. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +67 -93
  72. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +34 -43
  73. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ProgressInfoDemo.swift +83 -71
  74. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RadioDemo.swift +55 -23
  75. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RatingDemo.swift +58 -46
  76. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SectionDemo.swift +83 -0
  77. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SkeletonDemo.swift +41 -12
  78. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SliderDemo.swift +67 -127
  79. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SnackBarDemo.swift +54 -110
  80. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepperDemo.swift +69 -79
  81. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepsDemo.swift +78 -58
  82. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SuggestActionDemo.swift +76 -65
  83. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwipeDemo.swift +82 -127
  84. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwitchDemo.swift +44 -19
  85. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TabViewDemo.swift +92 -168
  86. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TagDemo.swift +79 -27
  87. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ThemeDemo.swift +0 -1
  88. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TitleDemo.swift +87 -92
  89. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TooltipDemo.swift +94 -163
  90. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TypographyDemo.swift +70 -70
  91. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/UploaderDemo.swift +68 -114
  92. package/ios-demo/Sources/MoMoUIKitsDemo/SwiftUIDemoBrowserView.swift +3 -1
  93. package/package.json +2 -2
@@ -89,7 +89,11 @@ public struct OverplayItem: Identifiable {
89
89
 
90
90
  @available(iOS 16.0, *)
91
91
  public final class Navigator: ObservableObject {
92
- @Published public var path: [DynamicScreenRoute] = []
92
+ @Published public var path: [DynamicScreenRoute] = [] {
93
+ // Disposal hangs off the path so every route that leaves the stack — pop,
94
+ // replace, reset — is released the same way, after its exit transition.
95
+ didSet { disposeScreens(droppedFrom: oldValue) }
96
+ }
93
97
  @Published public var presented: DynamicDialogRoute?
94
98
  @Published public var overplay: OverplayItem?
95
99
 
@@ -147,8 +151,7 @@ public final class Navigator: ObservableObject {
147
151
 
148
152
  public func replace<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
149
153
  guard !path.isEmpty else { return }
150
- let removed = path.removeLast()
151
- registry.unregister(id: removed.id)
154
+ path.removeLast()
152
155
  push(content, options: options)
153
156
  }
154
157
 
@@ -158,14 +161,12 @@ public final class Navigator: ObservableObject {
158
161
  options: NavigationOptions? = nil
159
162
  ) {
160
163
  guard !path.isEmpty else { return }
161
- let removed = path.removeLast()
162
- registry.unregister(id: removed.id)
164
+ path.removeLast()
163
165
  push(screenName: screenName, content, options: options)
164
166
  }
165
167
 
166
168
  public func reset<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
167
- for route in path { registry.unregister(id: route.id) }
168
- registry.unregister(id: rootRoute.id)
169
+ let previousRoot = rootRoute.id
169
170
  path.removeAll()
170
171
  let route = registry.register(
171
172
  screenName: options?.screenName ?? "",
@@ -173,6 +174,7 @@ public final class Navigator: ObservableObject {
173
174
  options: options
174
175
  )
175
176
  rootRoute = route
177
+ disposeScreens([previousRoot])
176
178
  }
177
179
 
178
180
  public func reset<V: View>(
@@ -180,8 +182,7 @@ public final class Navigator: ObservableObject {
180
182
  @ViewBuilder _ content: @escaping () -> V,
181
183
  options: NavigationOptions? = nil
182
184
  ) {
183
- for route in path { registry.unregister(id: route.id) }
184
- registry.unregister(id: rootRoute.id)
185
+ let previousRoot = rootRoute.id
185
186
  path.removeAll()
186
187
  let route = registry.register(
187
188
  screenName: screenName,
@@ -189,26 +190,50 @@ public final class Navigator: ObservableObject {
189
190
  options: options
190
191
  )
191
192
  rootRoute = route
193
+ disposeScreens([previousRoot])
192
194
  }
193
195
 
194
196
  public func pop(_ count: Int = 1, callback: (() -> Void)? = nil) {
195
- var remaining = count
196
- while remaining > 0 {
197
- if overplay?.type == .snackBar {
198
- // Mirrors Kotlin: a snackbar doesn't block back-navigation, so popping
199
- // while one is showing pops the screen underneath and clears the
200
- // snackbar immediately, without playing its dismiss animation.
201
- overplayDismissHandler = nil
202
- overplay = nil
203
- if !popScreen() { break }
204
- } else if overplay != nil {
205
- hideOverplay()
206
- } else if !popScreen() {
207
- break
197
+ popStep(remaining: count, callback: callback)
198
+ }
199
+
200
+ /// One `pop` iteration. Compose runs the loop in a coroutine and suspends over the
201
+ /// overplay's exit animation before the next step; the same wait is needed here, or
202
+ /// a `pop(2)` (prevent-back: close the popup, then leave) would spend both steps on
203
+ /// the overplay that is still animating out and never pop the screen.
204
+ private func popStep(remaining: Int, callback: (() -> Void)?) {
205
+ guard remaining > 0 else {
206
+ callback?()
207
+ return
208
+ }
209
+
210
+ if overplay?.type == .snackBar {
211
+ // Mirrors Kotlin: a snackbar doesn't block back-navigation, so popping
212
+ // while one is showing pops the screen underneath and clears the
213
+ // snackbar immediately, without playing its dismiss animation.
214
+ overplayDismissHandler = nil
215
+ overplay = nil
216
+ guard popScreen() else {
217
+ callback?()
218
+ return
219
+ }
220
+ popStep(remaining: remaining - 1, callback: callback)
221
+ } else if overplay != nil {
222
+ hideOverplay()
223
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.overplayDismissDelay) { [weak self] in
224
+ guard let self else { return }
225
+ // The component reports back through `finishOverplayDismiss` once its exit
226
+ // animation ends; clear it here if it never did, so the step is not wasted.
227
+ if self.overplay != nil { self.finishOverplayDismiss() }
228
+ self.popStep(remaining: remaining - 1, callback: callback)
208
229
  }
209
- remaining -= 1
230
+ } else {
231
+ guard popScreen() else {
232
+ callback?()
233
+ return
234
+ }
235
+ popStep(remaining: remaining - 1, callback: callback)
210
236
  }
211
- callback?()
212
237
  }
213
238
 
214
239
  /// Pops the topmost presented screen or stack entry. Returns `false` (and requests
@@ -218,11 +243,10 @@ public final class Navigator: ObservableObject {
218
243
  if presented != nil {
219
244
  let dismissedId = presented?.id
220
245
  presented = nil
221
- if let id = dismissedId { registry.unregister(id: id) }
246
+ if let id = dismissedId { disposeScreens([id]) }
222
247
  return true
223
248
  } else if !path.isEmpty {
224
- let removed = path.removeLast()
225
- registry.unregister(id: removed.id)
249
+ path.removeLast()
226
250
  return true
227
251
  } else {
228
252
  composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
@@ -231,42 +255,54 @@ public final class Navigator: ObservableObject {
231
255
  }
232
256
 
233
257
  public func popToRoot() {
234
- for route in path { registry.unregister(id: route.id) }
235
258
  path.removeAll()
236
259
  }
237
260
 
261
+ /// The entry a back action applies to — mirrors Compose's `currentScreen()`
262
+ /// (`navController.currentBackStackEntry`): a presented screen sits on top of
263
+ /// the stack, otherwise it is the last pushed screen, otherwise the root.
264
+ private func currentScreen() -> DynamicScreen? {
265
+ registry.getScreen(id: presented?.id ?? path.last?.id ?? rootRoute.id)
266
+ }
267
+
238
268
  public func onBackSafe(callback: (() -> Void)? = nil) {
239
- let latest = registry.latest()
269
+ let latest = currentScreen()
240
270
  if let onBack = latest?.options?.onBackHandler {
241
271
  onBack()
242
272
  return
243
273
  }
244
274
  if let preventBack = latest?.options?.headerBackProps?.preventBack {
245
275
  showModal {
246
- PopupDisplay(
247
- isPresented: .constant(true),
248
- title: preventBack.title,
249
- description: preventBack.description,
250
- url: preventBack.image,
251
- buttonDirection: preventBack.buttonDirection,
252
- buttonType: .button,
253
- actionButtonTitle: preventBack.primary?.title ?? "",
254
- closeButtonTitle: preventBack.secondary?.title ?? "",
255
- onPressAction: { [weak self] in
256
- preventBack.primary?.onPress?()
257
- // Close the popup, then leave the screen (matches Compose's pop(2)).
258
- self?.pop(2, callback: callback)
259
- },
260
- onPressCloseButton: { [weak self] in
261
- preventBack.secondary?.onPress?()
262
- self?.hideOverplay()
263
- },
264
- onClose: { [weak self] in
265
- preventBack.onIconClose?()
266
- self?.hideOverplay()
267
- },
268
- errorCode: preventBack.error,
269
- isShowCloseIcon: preventBack.isShowCloseIcon
276
+ PopupNotify(
277
+ props: PopupNotifyProps(
278
+ image: preventBack.image,
279
+ title: preventBack.title,
280
+ description: preventBack.description,
281
+ error: preventBack.error,
282
+ primary: PopupAction(
283
+ title: preventBack.primary?.title ?? "",
284
+ onPress: { [weak self] in
285
+ preventBack.primary?.onPress?()
286
+ // Close the popup, then leave the screen (matches Compose's pop(2)).
287
+ self?.pop(2, callback: callback)
288
+ }
289
+ ),
290
+ secondary: preventBack.secondary.map { secondary in
291
+ PopupAction(
292
+ title: secondary.title,
293
+ onPress: { [weak self] in
294
+ secondary.onPress?()
295
+ self?.hideOverplay()
296
+ }
297
+ )
298
+ },
299
+ buttonDirection: preventBack.buttonDirection,
300
+ onIconClose: { [weak self] in
301
+ preventBack.onIconClose?()
302
+ self?.hideOverplay()
303
+ },
304
+ isShowCloseIcon: preventBack.isShowCloseIcon
305
+ )
270
306
  )
271
307
  }
272
308
  return
@@ -382,4 +418,36 @@ public final class Navigator: ObservableObject {
382
418
  public func hideSnackBar() {
383
419
  if overplay?.type == .snackBar { hideOverplay() }
384
420
  }
421
+
422
+ // MARK: Screen disposal
423
+
424
+ /// Matches the `delay(300)` Compose waits in `dismissScreen` before unregistering,
425
+ /// with a little headroom for the pop transition.
426
+ private static let screenDisposeDelay: TimeInterval = 0.35
427
+
428
+ /// Longest overplay exit animation (the bottom sheet's), so a queued pop step runs
429
+ /// after the component has taken itself down.
430
+ private static let overplayDismissDelay: TimeInterval = 0.3
431
+
432
+ private func disposeScreens(droppedFrom previous: [DynamicScreenRoute]) {
433
+ let live = Set(path.map(\.id))
434
+ disposeScreens(previous.map(\.id).filter { !live.contains($0) })
435
+ }
436
+
437
+ /// Screens are released only once the pop transition has played out: the outgoing
438
+ /// view is still on screen while it animates and the registry is what renders it —
439
+ /// dropping it right away blanks the screen mid-transition. Liveness is re-checked
440
+ /// when the timer fires, so a route pushed back before then keeps its screen.
441
+ private func disposeScreens(_ ids: [Int]) {
442
+ guard !ids.isEmpty else { return }
443
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.screenDisposeDelay) { [weak self] in
444
+ guard let self else { return }
445
+ var live = Set(self.path.map(\.id))
446
+ live.insert(self.rootRoute.id)
447
+ if let presented = self.presented { live.insert(presented.id) }
448
+ for id in ids where !live.contains(id) {
449
+ self.registry.unregister(id: id)
450
+ }
451
+ }
452
+ }
385
453
  }
@@ -0,0 +1,49 @@
1
+ //
2
+ // NavigatorManager.swift
3
+ // MoMoUIKits
4
+ //
5
+ // Host bridge for the native iOS edge-swipe back — the SwiftUI mirror of Compose's
6
+ // `ComposeNavigatorManager` (momo-app `vn.momo.compose.uikits.ComposeNavigatorManager`).
7
+ //
8
+ // The kit does NOT own the swipe gesture. In the host app a kit screen lives inside a
9
+ // `UIHostingController` hosted by the mini-app container VC, and that VC owns the
10
+ // `UIScreenEdgePanGestureRecognizer`. When the swipe fires the host asks this manager
11
+ // whether a kit stack is mounted (`checkCanPop`) and, if so, routes the back into it
12
+ // (`pop` → `Navigator.onBackSafe`) instead of dismissing the whole mini-app. Standalone
13
+ // (no such host, e.g. the demo app) there is no edge swipe — same as Compose on iOS,
14
+ // whose `BackHandler` is a no-op and which relies entirely on the host.
15
+ //
16
+
17
+ import Foundation
18
+
19
+ @available(iOS 16.0, *)
20
+ public final class NavigatorManager {
21
+ public static let shared = NavigatorManager()
22
+ private init() {}
23
+
24
+ /// Stack of mounted navigators; the last one is the visible top (nested mini-apps
25
+ /// push more than one). Held strongly, mirroring `ComposeNavigatorManager` — entries
26
+ /// are released by `NavigationContainer` on disappear, not by ARC.
27
+ private var navigators: [Navigator] = []
28
+
29
+ /// Idempotent: SwiftUI can re-run a view's `onAppear`, and a double push would need a
30
+ /// matching double remove to unwind.
31
+ public func push(_ navigator: Navigator) {
32
+ guard !navigators.contains(where: { $0 === navigator }) else { return }
33
+ navigators.append(navigator)
34
+ }
35
+
36
+ /// Removes by identity rather than `removeLast` so a spurious disappear of a covered
37
+ /// (non-top) container can't drop the wrong navigator.
38
+ public func remove(_ navigator: Navigator) {
39
+ navigators.removeAll { $0 === navigator }
40
+ }
41
+
42
+ /// True while any kit stack is mounted — tells the host to route the back into the kit
43
+ /// instead of dismissing the mini-app. Matches `ComposeNavigatorManager.checkCanPop`:
44
+ /// it does not inspect stack depth; `onBackSafe` handles the at-root case itself by
45
+ /// asking the host to dismiss (via `composeApi`).
46
+ public func checkCanPop() -> Bool { !navigators.isEmpty }
47
+
48
+ public func pop() { navigators.last?.onBackSafe() }
49
+ }
@@ -0,0 +1,90 @@
1
+ //
2
+ // NavigationFooter.swift
3
+ // MoMoUIKits
4
+ //
5
+ // SwiftUI mirror of Compose's `Footer` (navigation/StackScreen.kt): the band
6
+ // pinned under the screen content — surface background, S/M padding, the
7
+ // navigation-bar reserve, and the 6pt gradient shadow cast up onto the content.
8
+ //
9
+ // Named `NavigationFooter` to coexist with the legacy `Footer` in `Screen.swift`
10
+ // (kept as-is for the old `Screen` path). Used by `StackScreen`.
11
+ //
12
+
13
+ import SwiftUI
14
+
15
+ public struct NavigationFooter: View {
16
+ /// Compose's `footerComponent`: nothing renders when nil.
17
+ var footerComponent: (() -> AnyView)?
18
+ /// Current keyboard height, measured from the bottom of the screen.
19
+ var keyboardSize: CGFloat
20
+
21
+ public init(
22
+ footerComponent: (() -> AnyView)?,
23
+ keyboardSize: CGFloat = 0
24
+ ) {
25
+ self.footerComponent = footerComponent
26
+ self.keyboardSize = keyboardSize
27
+ }
28
+
29
+ @Environment(\.appTheme) private var theme
30
+
31
+ /// Read from the window, not from an enclosing `GeometryProxy`: the proxy's bottom
32
+ /// inset grows to the keyboard height while the keyboard is up.
33
+ static var safeAreaBottom: CGFloat {
34
+ UIApplication.shared.connectedScenes
35
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
36
+ .first ?? 0
37
+ }
38
+
39
+ /// Compose's `AppNavigationBar.current` (`getNavigationBarHeight` clamps iOS to 21pt).
40
+ static var navigationBarInset: CGFloat { min(safeAreaBottom, 21) }
41
+
42
+ /// Compose's `bottomPadding + Spacing.S` with the keyboard down. The footer is laid
43
+ /// out inside the safe area, which already covers part of that gap.
44
+ static var bottomPadding: CGFloat {
45
+ max(navigationBarInset + Spacing.S - safeAreaBottom, 0)
46
+ }
47
+
48
+ /// Deliberately an offset and not padding: growing the footer feeds back into SwiftUI's
49
+ /// keyboard avoidance, so a padding-based lift never settles at the right place.
50
+ /// Negative lifts the band over the keyboard; positive shaves the few points by which
51
+ /// the safe area over-reserves compared to Compose. `FloatingContent` rides the same
52
+ /// amount so the FAB keeps its distance above the band.
53
+ ///
54
+ /// Natural position is `safeAreaBottom + bottomPadding` above the bottom of the screen;
55
+ /// Compose puts it at `(AppNavigationBar - keyboard).coerceAtLeast(0) + Spacing.S` on
56
+ /// top of the `imePadding()` lift — i.e. `max(navigationBar, keyboard) + Spacing.S`.
57
+ static func verticalOffset(keyboardSize: CGFloat) -> CGFloat {
58
+ (safeAreaBottom + bottomPadding) - (max(navigationBarInset, keyboardSize) + Spacing.S)
59
+ }
60
+
61
+ public var body: some View {
62
+ if let footerComponent = footerComponent {
63
+ VStack(alignment: .leading, spacing: 0) {
64
+ footerComponent()
65
+ }
66
+ .frame(maxWidth: .infinity, alignment: .leading)
67
+ .padding(.top, Spacing.S)
68
+ .padding(.horizontal, Spacing.M)
69
+ .padding(.bottom, Self.bottomPadding)
70
+ // Compose paints the surface all the way to the bottom of the screen; the
71
+ // footer's own frame stops at the safe area, so only the fill bleeds down.
72
+ .background(theme.colors.background.surface.ignoresSafeArea(.container, edges: .bottom))
73
+ .overlay(alignment: .top) { shadow }
74
+ .offset(y: Self.verticalOffset(keyboardSize: keyboardSize))
75
+ }
76
+ }
77
+
78
+ /// Compose offsets the shadow box by -6dp, so it lands on the content above the
79
+ /// footer rather than on the footer surface itself.
80
+ private var shadow: some View {
81
+ LinearGradient(
82
+ colors: [Color.clear, Color.black.opacity(0.05)],
83
+ startPoint: .top,
84
+ endPoint: .bottom
85
+ )
86
+ .frame(height: 6)
87
+ .offset(y: -6)
88
+ .allowsHitTesting(false)
89
+ }
90
+ }
@@ -5,7 +5,7 @@
5
5
  // SwiftUI mirror of Compose's `StackScreen`. This is a refactor of `Screen`:
6
6
  // the body is decomposed into the same focused pieces Compose uses — header
7
7
  // layer, scrollable content (`StackScreenContent`), floating button
8
- // (`FloatingContent`) and footer (`FooterContent`) — instead of one
8
+ // (`FloatingContent`) and footer (`NavigationFooter`) — instead of one
9
9
  // monolithic, partially-duplicated view. The public init matches `Screen`
10
10
  // so call sites (e.g. NavigationContainer's ScreenHost) can swap over.
11
11
  //
@@ -143,13 +143,8 @@ public struct StackScreen<Content: View>: View {
143
143
  // MARK: - Body
144
144
 
145
145
  public var body: some View {
146
- GeometryReader { geometry in
146
+ GeometryReader { _ in
147
147
  let keyboardSize: CGFloat = useAvoidKeyboard ? max(keyboardHeight, 0) : 0
148
- let bottomInset = min(geometry.safeAreaInsets.bottom, 21)
149
- // Skip this screen's own bottom-inset reservation when hosting a footer
150
- // OR a self-contained BottomTab surface (mirrors Compose's
151
- // `if (options.footerComponent != null || isBottomTab) 0.dp else navigationBar`).
152
- let contentBottomInset = (footer == nil && !isBottomTabRoot) ? bottomInset : 0
153
148
 
154
149
  ZStack(alignment: .top) {
155
150
  // Background color
@@ -161,16 +156,16 @@ public struct StackScreen<Content: View>: View {
161
156
  VStack(alignment: horizontalAlignment, spacing: 0) {
162
157
  headerAndContent
163
158
  }
164
- .padding(.bottom, contentBottomInset)
165
159
 
166
160
  FloatingContent(
167
161
  fabProps: fabProps,
168
- keyboardOffset: 0,
162
+ keyboardSize: keyboardSize,
163
+ hasFooter: footer != nil,
169
164
  scrollOffset: scrollOffset
170
165
  )
171
166
  }
172
167
 
173
- FooterContent(footer: footer, keyboardSize: keyboardSize, bottomInset: bottomInset)
168
+ NavigationFooter(footerComponent: footer, keyboardSize: keyboardSize)
174
169
  }
175
170
 
176
171
  // Animated search bar overlay (mirrors Compose's SearchAnimated layer).
@@ -444,17 +439,29 @@ private struct SearchAnimatedHeader: View {
444
439
  /// Mirrors Compose's `FloatingContent`: no FAB props means nothing renders.
445
440
  private struct FloatingContent: View {
446
441
  let fabProps: FabProps?
447
- let keyboardOffset: CGFloat
442
+ let keyboardSize: CGFloat
443
+ /// With a footer the FAB is anchored to the footer's *layout* top, which does not move
444
+ /// when the band offsets itself over the keyboard — so it rides the same offset.
445
+ let hasFooter: Bool
448
446
  let scrollOffset: CGFloat
449
447
 
448
+ /// Compose keeps the FAB `Spacing.M + AppNavigationBar` above the bottom of the screen
449
+ /// and lifts it with `imePadding()`; here it sits `Spacing.M` above the safe area and
450
+ /// nothing lifts it, so the difference is applied as an offset (see NavigationFooter).
451
+ private var verticalOffset: CGFloat {
452
+ if hasFooter { return NavigationFooter.verticalOffset(keyboardSize: keyboardSize) }
453
+ return NavigationFooter.safeAreaBottom - NavigationFooter.navigationBarInset - keyboardSize
454
+ }
455
+
450
456
  var body: some View {
451
457
  if let fabProps = fabProps {
452
458
  FloatingButton(
453
459
  props: fabProps,
454
- keyboardOffset: keyboardOffset,
460
+ keyboardOffset: 0,
455
461
  scrollOffset: scrollOffset
456
462
  )
457
463
  .padding(.horizontal, Spacing.M)
464
+ .offset(y: verticalOffset)
458
465
  .ignoresSafeArea(.keyboard, edges: .bottom)
459
466
  }
460
467
  }
@@ -483,19 +490,3 @@ private struct HideKeyboardOnTap: ViewModifier {
483
490
  }
484
491
  }
485
492
  }
486
-
487
- // MARK: - Footer content
488
-
489
- /// Mirrors Compose's `FooterContent`: renders the footer chrome only when a
490
- /// footer component is supplied. Reuses the existing `Footer` view from Screen.swift.
491
- private struct FooterContent: View {
492
- let footer: (() -> AnyView)?
493
- let keyboardSize: CGFloat
494
- let bottomInset: CGFloat
495
-
496
- var body: some View {
497
- if let footerView = footer?() {
498
- Footer(footerView: footerView, keyboardSize: keyboardSize, bottomInset: bottomInset)
499
- }
500
- }
501
- }
@@ -2,6 +2,7 @@ import SwiftUI
2
2
 
3
3
  public struct Badge: View {
4
4
  @Environment(\.appTheme) private var theme
5
+ @Environment(\.momoFontScale) private var fontScale
5
6
  let label: String
6
7
  let backgroundColor: Color?
7
8
 
@@ -55,7 +56,7 @@ public struct Badge: View {
55
56
  public var body: some View {
56
57
  MomoText(formatTitle(label), typography: .actionXxsBold, color: Colors.black01)
57
58
  .padding(.horizontal, Spacing.XS)
58
- .frame(minWidth: scaleSize(16), minHeight: scaleSize(16))
59
+ .frame(minWidth: fontScale(16), minHeight: fontScale(16))
59
60
  .background(badgeColor)
60
61
  .cornerRadius(Radius.M)
61
62
  .overlay(
@@ -174,6 +174,7 @@ public struct BadgeRibbon: View {
174
174
  )
175
175
  .frame(width: RibbonConstants.headTailWidth, height: RibbonConstants.headTailHeight)
176
176
  .rotationEffect(.degrees(180))
177
+ .offset(x: 1)
177
178
  .clipped()
178
179
  }
179
180
 
@@ -183,6 +184,7 @@ public struct BadgeRibbon: View {
183
184
  placeholder: AnyView(Color.clear)
184
185
  )
185
186
  .frame(width: RibbonConstants.headTailWidth, height: RibbonConstants.headTailHeight)
187
+ .offset(x: 1)
186
188
  .clipped()
187
189
  }
188
190
 
@@ -192,6 +194,7 @@ public struct BadgeRibbon: View {
192
194
  placeholder: AnyView(Color.clear)
193
195
  )
194
196
  .frame(width: RibbonConstants.skewTailWidth, height: RibbonConstants.skewTailHeight)
197
+ .offset(x: -2)
195
198
  .clipped()
196
199
  }
197
200
  }
@@ -201,6 +204,7 @@ public struct BadgeRibbon: View {
201
204
  /// rotation) — the label gets the same scale applied a second time so it stays right-reading
202
205
  /// while only the ribbon shape flips.
203
206
  public struct RoundedBadgeRibbon: View {
207
+ @Environment(\.momoFontScale) private var fontScale
204
208
  let text: String
205
209
  let position: RibbonPosition
206
210
 
@@ -219,7 +223,7 @@ public struct RoundedBadgeRibbon: View {
219
223
  }
220
224
 
221
225
  private var measuredTextSize: CGSize {
222
- let fontSize = scaleSize(TypographyStyle.labelXsMedium.fontSize)
226
+ let fontSize = fontScale(TypographyStyle.labelXsMedium.fontSize)
223
227
  let font = UIFont.systemFont(ofSize: fontSize, weight: .medium)
224
228
  return text.size(withAttributes: [.font: font])
225
229
  }
@@ -281,7 +285,7 @@ private struct RibbonConstants {
281
285
  static let ribbonHeight: CGFloat = 20
282
286
  static let roundHeight: CGFloat = 16
283
287
  static let skewBodyHeight: CGFloat = 16
284
- static let roundRightRadius: CGFloat = 12
288
+ static let roundRightRadius: CGFloat = 8
285
289
  static let roundPaddingEnd: CGFloat = 6
286
290
  static let skewTailWidth: CGFloat = 8
287
291
  static let skewTailHeight: CGFloat = 16