@momo-kits/native-kits 0.167.1-duo.2-debug → 0.167.1-duo.20-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 (194) hide show
  1. package/CLAUDE.md +36 -8
  2. package/README.md +2 -2
  3. package/compose/build.gradle.kts +15 -1
  4. package/compose/build.gradle.kts.backup +14 -0
  5. package/compose/compose.podspec +1 -1
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +168 -52
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +24 -14
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +1 -1
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +53 -26
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -2
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +4 -0
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +10 -4
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +3 -17
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +173 -21
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +10 -0
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +153 -48
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +114 -71
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +8 -9
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +98 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +312 -0
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +84 -18
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +33 -13
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +2 -1
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +51 -19
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +4 -2
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -1
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +46 -23
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
  57. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +38 -0
  58. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
  59. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +94 -0
  60. package/gradle.properties +1 -1
  61. package/ios/Application/ApplicationEnvironment.swift +17 -0
  62. package/ios/Application/MaxApi.swift +12 -3
  63. package/ios/Avatar/Avatar.swift +4 -3
  64. package/ios/Badge/Badge.swift +6 -4
  65. package/ios/Badge/BadgeDot.swift +1 -1
  66. package/ios/Badge/BadgeRibbon.swift +0 -1
  67. package/ios/Button/Button.swift +15 -12
  68. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  69. package/ios/Carousel/Carousel.swift +29 -12
  70. package/ios/Checkbox/Checkbox.swift +7 -3
  71. package/ios/Chip/Chip.swift +5 -4
  72. package/ios/Collapse/Collapse.swift +70 -19
  73. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  74. package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
  75. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  76. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  77. package/ios/Divider/Divider.swift +37 -4
  78. package/ios/Extensions/Color++.swift +38 -9
  79. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  80. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  81. package/ios/Icon/Icon.swift +21 -23
  82. package/ios/Icon/Icons.swift +1329 -0
  83. package/ios/IconButton/IconButton.swift +15 -7
  84. package/ios/Image/Image.swift +7 -23
  85. package/ios/Information/Information.swift +27 -9
  86. package/ios/Input/ErrorView.swift +2 -2
  87. package/ios/Input/Input.swift +160 -147
  88. package/ios/Input/InputPhoneNumber.swift +12 -9
  89. package/ios/Input/InputSearch.swift +102 -75
  90. package/ios/Input/InputTextArea.swift +128 -145
  91. package/ios/InputDropDown/InputDropDown.swift +10 -7
  92. package/ios/InputMoney/InputMoney.swift +33 -11
  93. package/ios/InputOTP/InputOTP.swift +22 -12
  94. package/ios/Layout/Card.swift +50 -0
  95. package/ios/Layout/GridContext.swift +187 -3
  96. package/ios/Layout/Item.swift +5 -4
  97. package/ios/Layout/ScreenLayout.swift +380 -0
  98. package/ios/Layout/Section.swift +4 -100
  99. package/ios/Loader/Loader.swift +5 -9
  100. package/ios/Lottie/LottieView.swift +14 -2
  101. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +128 -9
  102. package/ios/Navigation/BottomTab/BottomTab.swift +185 -0
  103. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +15 -7
  104. package/ios/Navigation/BottomTab/DuoBarCompression.swift +105 -0
  105. package/ios/Navigation/BottomTab/DuoTabBar.swift +304 -0
  106. package/ios/{Application → Navigation/Component}/FloatingButton.swift +14 -3
  107. package/ios/Navigation/Component/Header.swift +531 -0
  108. package/ios/Navigation/Component/HeaderBackground.swift +133 -0
  109. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  110. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  111. package/ios/Navigation/Component/HeaderRight.swift +377 -0
  112. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  113. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  114. package/ios/Navigation/Component/SideBar.swift +183 -0
  115. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +5 -6
  116. package/ios/Navigation/HostBackGestureBridge.swift +78 -0
  117. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +5 -2
  118. package/ios/Navigation/Navigation.swift +204 -0
  119. package/ios/Navigation/NavigationContainer.swift +194 -0
  120. package/ios/{Application/Navigation → Navigation}/Navigator.swift +177 -91
  121. package/ios/Navigation/StackScreen.swift +820 -0
  122. package/ios/Navigation/StackTransition.swift +400 -0
  123. package/ios/Navigation/SwipeBackHaptic.swift +33 -0
  124. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  125. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  126. package/ios/Pagination/PaginationScroll.swift +2 -2
  127. package/ios/Popup/PopupDisplay.swift +72 -136
  128. package/ios/Popup/PopupInput.swift +10 -13
  129. package/ios/Popup/PopupLayout.swift +140 -0
  130. package/ios/Popup/PopupNotify.swift +56 -73
  131. package/ios/Popup/PopupPromotion.swift +31 -47
  132. package/ios/PopupView/FullscreenPopup.swift +13 -19
  133. package/ios/PopupView/PopupView.swift +1 -1
  134. package/ios/PopupView/Utils++.swift +0 -8
  135. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  136. package/ios/Radio/Radio.swift +2 -1
  137. package/ios/Rating/Rating.swift +9 -1
  138. package/ios/Resources/dot_loading.json +558 -0
  139. package/ios/Resources/lottie_circle_loader.json +1 -0
  140. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  141. package/ios/Skeleton/Skeleton.swift +22 -27
  142. package/ios/Slider/Slider.swift +16 -18
  143. package/ios/Stepper/Stepper.swift +6 -1
  144. package/ios/Steps/Steps.swift +8 -6
  145. package/ios/SuggestAction/SuggestAction.swift +5 -0
  146. package/ios/Swipeable/SwipeCell.swift +10 -6
  147. package/ios/Switch/Switch.swift +22 -20
  148. package/ios/TabView/TabView.swift +102 -21
  149. package/ios/Tag/Tag.swift +15 -4
  150. package/ios/Template/Logo/Logo.swift +2 -1
  151. package/ios/Template/TrustBanner/TrustBanner.swift +106 -54
  152. package/ios/Title/Title.swift +6 -17
  153. package/ios/Tooltip/Tooltip.swift +29 -14
  154. package/ios/Typography/Text.swift +61 -19
  155. package/ios/Uploader/Uploader.swift +3 -1
  156. package/ios/native-kits.podspec +2 -1
  157. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  158. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  159. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  160. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +1 -0
  161. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  162. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  163. package/package.json +1 -1
  164. package/scripts/gen-ios-icons.mjs +36 -0
  165. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
  166. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
  167. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
  168. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
  169. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
  170. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
  171. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
  172. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
  173. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
  174. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
  175. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
  176. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
  177. package/ios/Application/Components.swift +0 -270
  178. package/ios/Application/ComposeApi.swift +0 -22
  179. package/ios/Application/HeaderRight.swift +0 -306
  180. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  181. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  182. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  183. package/ios/Application/Navigation/NavigationContainer.swift +0 -213
  184. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  185. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  186. package/ios/Application/Navigation/component/Header.swift +0 -178
  187. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  188. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  189. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  190. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  191. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  192. package/ios/Application/Screen.swift +0 -250
  193. package/ios/Application/StackScreen.swift +0 -492
  194. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
@@ -0,0 +1,194 @@
1
+ //
2
+ // NavigationContainer.swift
3
+ // MoMoUIKits
4
+ //
5
+ // SwiftUI mirror of Compose's `navigation/NavigationContainer.kt`: hosts the kit stack
6
+ // (`StackHost`, the NavHost counterpart), injects Navigator, theme, localize and font scale, and
7
+ // renders every registered screen inside `StackScreen` chrome built from its options.
8
+ //
9
+
10
+ import SwiftUI
11
+
12
+ public struct NavigationContainer<Initial: View>: View {
13
+ @StateObject private var navigator: Navigator
14
+ @StateObject private var applicationEnvironment: ApplicationEnvironment
15
+ @StateObject private var localize: Localize
16
+
17
+ @Environment(\.appTheme) private var parentTheme
18
+
19
+ private let setNavigator: ((Navigator) -> Void)?
20
+ private let composeApi: KitComposeApi?
21
+ private let maxApi: MaxApi?
22
+ private let initialTheme: Theme?
23
+
24
+ /// `initialTheme` nil keeps the theme the host already put in the environment.
25
+ public init(
26
+ initialScreenName: String = "",
27
+ @ViewBuilder initialScreen: @escaping () -> Initial,
28
+ options: NavigationOptions? = nil,
29
+ initialTheme: Theme? = nil,
30
+ applicationContext: MiniAppContext? = nil,
31
+ composeApi: KitComposeApi? = nil,
32
+ config: KitConfig? = nil,
33
+ maxApi: MaxApi? = nil,
34
+ localize: Localize? = nil,
35
+ setNavigator: ((Navigator) -> Void)? = nil
36
+ ) {
37
+ let nav = Navigator(
38
+ initialScreenName: initialScreenName.isEmpty ? (options?.screenName ?? "") : initialScreenName,
39
+ initialContent: { AnyView(initialScreen()) },
40
+ options: options,
41
+ composeApi: composeApi
42
+ )
43
+ nav.maxApi = maxApi
44
+ _navigator = StateObject(wrappedValue: nav)
45
+ _applicationEnvironment = StateObject(
46
+ wrappedValue: ApplicationEnvironment(
47
+ applicationContext: applicationContext,
48
+ composeApi: composeApi,
49
+ config: config,
50
+ maxApi: maxApi
51
+ )
52
+ )
53
+ // AI-GENERATED START: default to the app-language-observing shared Localize
54
+ _localize = StateObject(wrappedValue: localize ?? Localize.shared)
55
+ // AI-GENERATED END: default to the app-language-observing shared Localize
56
+ self.composeApi = composeApi
57
+ self.maxApi = maxApi
58
+ self.initialTheme = initialTheme
59
+ self.setNavigator = setNavigator
60
+ }
61
+
62
+ /// RN parity: the theme's own header image wins; config `headerBar` is only the fallback.
63
+ private var resolvedTheme: Theme {
64
+ let theme = initialTheme ?? parentTheme
65
+ guard !theme.assets.isHeaderImage,
66
+ let headerBar = applicationEnvironment.config?.headerBar, !headerBar.isEmpty else {
67
+ return theme
68
+ }
69
+ return theme.copy(assets: ThemeAssets(headerBackground: headerBar))
70
+ }
71
+
72
+ public var body: some View {
73
+ StackHost(
74
+ navigator: navigator,
75
+ entries: navigator.entries,
76
+ screen: { [navigator] entry in
77
+ guard let screen = navigator.registry.getScreen(id: entry.id) else { return AnyView(EmptyView()) }
78
+ return AnyView(ScreenHost(screen: screen, navigator: navigator))
79
+ },
80
+ decorate: { AnyView(withKitEnvironment($0)) }
81
+ )
82
+ // edge to edge: each screen's hosting controller takes the safe area and keyboard itself
83
+ .ignoresSafeArea()
84
+ .onAppear {
85
+ if navigator.composeApi == nil {
86
+ navigator.composeApi = composeApi
87
+ }
88
+ if navigator.maxApi == nil {
89
+ navigator.maxApi = maxApi
90
+ }
91
+ setNavigator?(navigator)
92
+ // Idempotent, so a re-appear won't double-count.
93
+ HostBackGestureBridge.shared.attach(navigator)
94
+ // AI-GENERATED START: demote the MaxApi language pull to a seed of last resort
95
+ // Only a seed: a Localize with a language source already tracks the host in both
96
+ // directions, so asking maxApi would just race it. `changeLanguage` normalizes the
97
+ // raw response and hops to main itself.
98
+ guard !localize.observesAppLanguage else { return }
99
+ maxApi?.getLanguage { data in
100
+ localize.changeLanguage(data?["response"] as? String)
101
+ }
102
+ // AI-GENERATED END: demote the MaxApi language pull to a seed of last resort
103
+ }
104
+ .onDisappear {
105
+ HostBackGestureBridge.shared.detach(navigator)
106
+ }
107
+ }
108
+
109
+ private func withKitEnvironment<Content: View>(_ content: Content) -> some View {
110
+ content
111
+ .environmentObject(navigator)
112
+ .environmentObject(applicationEnvironment)
113
+ .environment(\.applicationEnvironment, applicationEnvironment)
114
+ .environment(\.appTheme, resolvedTheme)
115
+ .momoLocalize(localize)
116
+ .momoFontScale(context: applicationEnvironment.applicationContext)
117
+ }
118
+ }
119
+
120
+ // MARK: - ScreenHost
121
+
122
+ private struct ScreenHost: View {
123
+ let screen: DynamicScreen
124
+ let navigator: Navigator
125
+ @StateObject private var navigation: Navigation
126
+
127
+ init(screen: DynamicScreen, navigator: Navigator) {
128
+ self.screen = screen
129
+ self.navigator = navigator
130
+ _navigation = StateObject(
131
+ wrappedValue: Navigation(id: screen.id, options: screen.options, registry: navigator.registry)
132
+ )
133
+ }
134
+
135
+ var body: some View {
136
+ OptionsStackScreen(
137
+ options: navigation.options,
138
+ screenName: screen.name,
139
+ goBack: { navigator.onBackSafe() },
140
+ content: screen.content
141
+ )
142
+ // Compose draws the overplay inside the screen it was shown over, under its LocalNavigation.
143
+ .overlay { OverplayLayer(screenId: screen.id) }
144
+ .environmentObject(navigation)
145
+ .momoScreenLayout()
146
+ }
147
+ }
148
+
149
+ private struct OverplayLayer: View {
150
+ let screenId: Int
151
+ @EnvironmentObject private var navigator: Navigator
152
+
153
+ var body: some View {
154
+ if let item = navigator.overplay, item.screenId == screenId {
155
+ OverplayHost(item: item)
156
+ }
157
+ }
158
+ }
159
+
160
+ // MARK: - Overplay host
161
+
162
+ private struct OverplayHost: View {
163
+ let item: OverplayItem
164
+ @EnvironmentObject var navigator: Navigator
165
+
166
+ var body: some View {
167
+ // Each component self-animates on appear and drives its own exit through
168
+ // the Navigator before the item is removed (mirrors Compose's overplay).
169
+ switch item.type {
170
+ case .modal:
171
+ ModalScreen(barrierDismissible: item.barrierDismissible) {
172
+ item.content()
173
+ }
174
+ .id(item.id)
175
+ case .bottomSheet:
176
+ BottomSheetView(
177
+ header: item.header ?? .title(),
178
+ isSurface: item.isSurface,
179
+ barrierDismissible: item.barrierDismissible,
180
+ draggable: item.draggable,
181
+ useBottomInset: item.useBottomInset,
182
+ footer: item.footer
183
+ ) {
184
+ item.content()
185
+ }
186
+ .id(item.id)
187
+ case .snackBar:
188
+ if let snackBar = item.snackBar {
189
+ SnackBarView(snackBar: snackBar, bottomInset: navigator.snackBarBottomInset())
190
+ .id(item.id)
191
+ }
192
+ }
193
+ }
194
+ }
@@ -2,9 +2,9 @@
2
2
  // Navigator.swift
3
3
  // MoMoUIKits
4
4
  //
5
- // SwiftUI mirror of Compose's Navigator + DynamicScreenRegistry.
6
- // Screens are registered by Int id; the NavigationStack path stores routes
7
- // by id and looks up content from the registry on render.
5
+ // SwiftUI mirror of Compose's `navigation/Navigator.kt`: Navigator, DynamicScreenRegistry and
6
+ // the overplay model. Screens are registered by Int id; the stack paths store routes by id and
7
+ // content is looked up from the registry on render.
8
8
  //
9
9
 
10
10
  import SwiftUI
@@ -31,6 +31,7 @@ public struct DynamicScreen {
31
31
 
32
32
  public final class DynamicScreenRegistry {
33
33
  private var screens: [Int: DynamicScreen] = [:]
34
+ private var navigations: [Int: WeakNavigation] = [:]
34
35
  private var idCounter: Int = 1
35
36
 
36
37
  public init() {}
@@ -49,12 +50,20 @@ public final class DynamicScreenRegistry {
49
50
 
50
51
  public func unregister(id: Int) {
51
52
  screens.removeValue(forKey: id)
53
+ navigations.removeValue(forKey: id)
52
54
  }
53
55
 
54
56
  public func unregisterAll() {
55
57
  screens.removeAll()
58
+ navigations.removeAll()
56
59
  }
57
60
 
61
+ func bind(_ navigation: Navigation) {
62
+ navigations[navigation.id] = WeakNavigation(value: navigation)
63
+ }
64
+
65
+ func navigation(id: Int) -> Navigation? { navigations[id]?.value }
66
+
58
67
  public func getScreen(id: Int) -> DynamicScreen? { screens[id] }
59
68
 
60
69
  public func latest() -> DynamicScreen? {
@@ -66,6 +75,10 @@ public final class DynamicScreenRegistry {
66
75
  }
67
76
  }
68
77
 
78
+ private struct WeakNavigation {
79
+ weak var value: Navigation?
80
+ }
81
+
69
82
  // MARK: - Overplay
70
83
 
71
84
  public enum OverplayType {
@@ -81,20 +94,34 @@ public struct OverplayItem: Identifiable {
81
94
  // Bottom-sheet params (mirrors Compose's OverplayComponentParams.BottomSheet).
82
95
  public var isSurface: Bool = false
83
96
  public var header: BottomSheetHeader? = nil
97
+ public var draggable: Bool = true
98
+ public var useBottomInset: Bool = true
99
+ public var footer: (() -> AnyView)? = nil
84
100
  // Snack-bar payload (mirrors Compose's SnackBar value).
85
101
  public var snackBar: SnackBar? = nil
102
+ /// The screen it was shown over (Compose's overplay root id): it renders inside that screen,
103
+ /// so a later push slides over it instead of re-mounting it on the new top.
104
+ public internal(set) var screenId: Int? = nil
105
+ }
106
+
107
+ // MARK: - Stack
108
+
109
+ /// One back-stack entry, root first. A presented screen is an entry like any other (Compose's
110
+ /// `DynamicDialogRoute` in the same NavHost): it rises instead of sliding in, and later pushes
111
+ /// stack on top of it.
112
+ struct StackEntry: Hashable, Identifiable {
113
+ let id: Int
114
+ let isDialog: Bool
86
115
  }
87
116
 
88
117
  // MARK: - Navigator
89
118
 
90
- @available(iOS 16.0, *)
91
119
  public final class Navigator: ObservableObject {
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) }
120
+ @Published private(set) var entries: [StackEntry] = [] {
121
+ // Disposal hangs off the stack so every entry that leaves it — pop, replace, reset,
122
+ // dismiss — is released the same way, after its exit transition.
123
+ didSet { disposeScreens(droppedFrom: oldValue, keeping: entries) }
96
124
  }
97
- @Published public var presented: DynamicDialogRoute?
98
125
  @Published public var overplay: OverplayItem?
99
126
 
100
127
  /// Owned per-Navigator (mirrors Compose's `remember { DynamicScreenRegistry() }` +
@@ -108,8 +135,20 @@ public final class Navigator: ObservableObject {
108
135
  /// actually removed via `finishOverplayDismiss`.
109
136
  private var overplayDismissHandler: (() -> Void)?
110
137
 
111
- public private(set) var rootRoute: DynamicScreenRoute
138
+ public var rootRoute: DynamicScreenRoute { DynamicScreenRoute(id: entries[0].id) }
112
139
  public var composeApi: KitComposeApi?
140
+ /// Host dismiss fallback when there is no `composeApi` (Compose's `maxApi.dismiss`).
141
+ public var maxApi: MaxApi?
142
+
143
+ /// Set by a bottom tab off its first tab: back on the screen hosting the tabs returns to the
144
+ /// first tab (Compose `tabBackHandler`). Keyed by that screen, so a screen pushed or presented
145
+ /// over the tabs still pops normally.
146
+ var tabBackHandler: (screenId: Int, handler: () -> Void)?
147
+
148
+ /// The container's stack view, which plays (and lets the host gesture scrub) the transitions.
149
+ weak var stackController: StackController?
150
+ private var isSeekingBack = false
151
+ private var swipeHaptic = SwipeBackHaptic()
113
152
 
114
153
  public init(
115
154
  initialScreenName: String = "",
@@ -118,22 +157,18 @@ public final class Navigator: ObservableObject {
118
157
  composeApi: KitComposeApi? = nil
119
158
  ) {
120
159
  self.composeApi = composeApi
121
- self.rootRoute = registry.register(
160
+ let root = registry.register(
122
161
  screenName: initialScreenName,
123
162
  content: initialContent,
124
163
  options: options
125
164
  )
165
+ entries = [StackEntry(id: root.id, isDialog: false)]
126
166
  }
127
167
 
128
168
  // MARK: Stack
129
169
 
130
170
  public func push<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
131
- let route = registry.register(
132
- screenName: options?.screenName ?? "",
133
- content: { AnyView(content()) },
134
- options: options
135
- )
136
- path.append(route)
171
+ push(screenName: options?.screenName ?? "", content, options: options)
137
172
  }
138
173
 
139
174
  public func push<V: View>(
@@ -141,40 +176,26 @@ public final class Navigator: ObservableObject {
141
176
  @ViewBuilder _ content: @escaping () -> V,
142
177
  options: NavigationOptions? = nil
143
178
  ) {
144
- let route = registry.register(
145
- screenName: screenName,
146
- content: { AnyView(content()) },
147
- options: options
148
- )
149
- path.append(route)
179
+ entries.append(register(screenName, content, options, isDialog: false))
150
180
  }
151
181
 
152
182
  public func replace<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
153
- guard !path.isEmpty else { return }
154
- path.removeLast()
155
- push(content, options: options)
183
+ replace(screenName: options?.screenName ?? "", content, options: options)
156
184
  }
157
185
 
186
+ /// Compose pops the top entry (a presented one included) and navigates, so the replacement
187
+ /// always lands as a pushed screen; on the root it becomes the new root.
158
188
  public func replace<V: View>(
159
189
  screenName: String,
160
190
  @ViewBuilder _ content: @escaping () -> V,
161
191
  options: NavigationOptions? = nil
162
192
  ) {
163
- guard !path.isEmpty else { return }
164
- path.removeLast()
165
- push(screenName: screenName, content, options: options)
193
+ let entry = register(screenName, content, options, isDialog: false)
194
+ entries = Array(entries.dropLast()) + [entry]
166
195
  }
167
196
 
168
197
  public func reset<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
169
- let previousRoot = rootRoute.id
170
- path.removeAll()
171
- let route = registry.register(
172
- screenName: options?.screenName ?? "",
173
- content: { AnyView(content()) },
174
- options: options
175
- )
176
- rootRoute = route
177
- disposeScreens([previousRoot])
198
+ reset(screenName: options?.screenName ?? "", content, options: options)
178
199
  }
179
200
 
180
201
  public func reset<V: View>(
@@ -182,15 +203,21 @@ public final class Navigator: ObservableObject {
182
203
  @ViewBuilder _ content: @escaping () -> V,
183
204
  options: NavigationOptions? = nil
184
205
  ) {
185
- let previousRoot = rootRoute.id
186
- path.removeAll()
206
+ entries = [register(screenName, content, options, isDialog: false)]
207
+ }
208
+
209
+ private func register<V: View>(
210
+ _ screenName: String,
211
+ _ content: @escaping () -> V,
212
+ _ options: NavigationOptions?,
213
+ isDialog: Bool
214
+ ) -> StackEntry {
187
215
  let route = registry.register(
188
216
  screenName: screenName,
189
217
  content: { AnyView(content()) },
190
218
  options: options
191
219
  )
192
- rootRoute = route
193
- disposeScreens([previousRoot])
220
+ return StackEntry(id: route.id, isDialog: isDialog)
194
221
  }
195
222
 
196
223
  public func pop(_ count: Int = 1, callback: (() -> Void)? = nil) {
@@ -236,43 +263,57 @@ public final class Navigator: ObservableObject {
236
263
  }
237
264
  }
238
265
 
239
- /// Pops the topmost presented screen or stack entry. Returns `false` (and requests
240
- /// a host dismiss) when there is nothing left to pop.
266
+ /// Pops the top entry. Returns `false` (and requests a host dismiss) on the root.
241
267
  @discardableResult
242
268
  private func popScreen() -> Bool {
243
- if presented != nil {
244
- let dismissedId = presented?.id
245
- presented = nil
246
- if let id = dismissedId { disposeScreens([id]) }
247
- return true
248
- } else if !path.isEmpty {
249
- path.removeLast()
250
- return true
251
- } else {
252
- composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
269
+ guard canPop() else {
270
+ requestHostDismiss()
253
271
  return false
254
272
  }
273
+ entries.removeLast()
274
+ return true
275
+ }
276
+
277
+ private func requestHostDismiss() {
278
+ if let composeApi {
279
+ composeApi.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
280
+ } else {
281
+ maxApi?.dismiss(completion: nil)
282
+ }
283
+ }
284
+
285
+ /// Whether there is a screen to go back to (Compose's `canPop`); at the root a back
286
+ /// dismisses the mini app instead.
287
+ public func canPop() -> Bool {
288
+ entries.count > 1
255
289
  }
256
290
 
291
+ /// Back to the root of the active flow: the topmost presented screen while one is up,
292
+ /// the stack root otherwise.
257
293
  public func popToRoot() {
258
- path.removeAll()
294
+ let root = entries.lastIndex(where: \.isDialog) ?? 0
295
+ entries = Array(entries[...root])
259
296
  }
260
297
 
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.
298
+ /// The entry a back action applies to — Compose's `currentScreen()`
299
+ /// (`navController.currentBackStackEntry`).
264
300
  private func currentScreen() -> DynamicScreen? {
265
- registry.getScreen(id: presented?.id ?? path.last?.id ?? rootRoute.id)
301
+ entries.last.flatMap { registry.getScreen(id: $0.id) }
266
302
  }
267
303
 
268
304
  public func onBackSafe(callback: (() -> Void)? = nil) {
269
305
  let latest = currentScreen()
306
+ if let tabBackHandler, tabBackHandler.screenId == -1 || tabBackHandler.screenId == latest?.id {
307
+ tabBackHandler.handler()
308
+ return
309
+ }
270
310
  if let onBack = latest?.options?.onBackHandler {
271
311
  onBack()
272
312
  return
273
313
  }
274
314
  if let preventBack = latest?.options?.headerBackProps?.preventBack {
275
- showModal {
315
+ // stored in `overplay`, so a strong self would cycle until the popup closes
316
+ showModal { [weak self] in
276
317
  PopupNotify(
277
318
  props: PopupNotifyProps(
278
319
  image: preventBack.image,
@@ -281,7 +322,7 @@ public final class Navigator: ObservableObject {
281
322
  error: preventBack.error,
282
323
  primary: PopupAction(
283
324
  title: preventBack.primary?.title ?? "",
284
- onPress: { [weak self] in
325
+ onPress: {
285
326
  preventBack.primary?.onPress?()
286
327
  // Close the popup, then leave the screen (matches Compose's pop(2)).
287
328
  self?.pop(2, callback: callback)
@@ -290,14 +331,14 @@ public final class Navigator: ObservableObject {
290
331
  secondary: preventBack.secondary.map { secondary in
291
332
  PopupAction(
292
333
  title: secondary.title,
293
- onPress: { [weak self] in
334
+ onPress: {
294
335
  secondary.onPress?()
295
336
  self?.hideOverplay()
296
337
  }
297
338
  )
298
339
  },
299
340
  buttonDirection: preventBack.buttonDirection,
300
- onIconClose: { [weak self] in
341
+ onIconClose: {
301
342
  preventBack.onIconClose?()
302
343
  self?.hideOverplay()
303
344
  },
@@ -313,12 +354,7 @@ public final class Navigator: ObservableObject {
313
354
  // MARK: Modal-style screen (vertical slide)
314
355
 
315
356
  public func present<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
316
- let route = registry.register(
317
- screenName: options?.screenName ?? "",
318
- content: { AnyView(content()) },
319
- options: options
320
- )
321
- presented = DynamicDialogRoute(id: route.id)
357
+ present(screenName: options?.screenName ?? "", content, options: options)
322
358
  }
323
359
 
324
360
  public func present<V: View>(
@@ -326,19 +362,55 @@ public final class Navigator: ObservableObject {
326
362
  @ViewBuilder _ content: @escaping () -> V,
327
363
  options: NavigationOptions? = nil
328
364
  ) {
329
- let route = registry.register(
330
- screenName: screenName,
331
- content: { AnyView(content()) },
332
- options: options
333
- )
334
- presented = DynamicDialogRoute(id: route.id)
365
+ entries.append(register(screenName, content, options, isDialog: true))
335
366
  }
336
367
 
368
+ /// Closes the topmost presented screen together with everything pushed over it.
337
369
  public func dismiss() {
338
- if let id = presented?.id {
339
- registry.unregister(id: id)
370
+ guard let presented = entries.lastIndex(where: \.isDialog) else { return }
371
+ entries = Array(entries[..<presented])
372
+ }
373
+
374
+ // MARK: Host back gesture (Compose's HostBackTarget)
375
+
376
+ /// Only a plain pop is scrubbed. A back the screen handles itself — tab back, custom
377
+ /// handler, prevent-back popup, an open overplay — or one closing a presented screen
378
+ /// waits for the release and goes through `onBackSafe`, as RN turns the gesture off there.
379
+ private var canSeekBack: Bool {
380
+ guard canPop(), overplay == nil, let top = entries.last, !top.isDialog else { return false }
381
+ if let tabBackHandler, tabBackHandler.screenId == -1 || tabBackHandler.screenId == top.id {
382
+ return false
340
383
  }
341
- presented = nil
384
+ let options = registry.getScreen(id: top.id)?.options
385
+ return options?.onBackHandler == nil && options?.headerBackProps?.preventBack == nil
386
+ }
387
+
388
+ func backStarted() {
389
+ swipeHaptic.reset()
390
+ isSeekingBack = canSeekBack && stackController?.beginBackSeek() == true
391
+ }
392
+
393
+ func backProgressed(_ progress: CGFloat) {
394
+ guard isSeekingBack, let stackController else { return }
395
+ stackController.seekBack(to: progress)
396
+ swipeHaptic.update(travelled: progress * stackController.view.bounds.width, maxApi: maxApi)
397
+ }
398
+
399
+ func backCompleted() {
400
+ let wasSeeking = isSeekingBack
401
+ isSeekingBack = false
402
+ // A seek cut short by a programmatic change falls back to a regular back.
403
+ guard wasSeeking, stackController?.finishBackSeek() == true else {
404
+ onBackSafe()
405
+ return
406
+ }
407
+ entries.removeLast()
408
+ }
409
+
410
+ func backCancelled() {
411
+ guard isSeekingBack else { return }
412
+ isSeekingBack = false
413
+ stackController?.cancelBackSeek()
342
414
  }
343
415
 
344
416
  // MARK: Overplay
@@ -353,7 +425,8 @@ public final class Navigator: ObservableObject {
353
425
  type: .modal,
354
426
  content: { AnyView(content()) },
355
427
  barrierDismissible: barrierDismissible,
356
- onDismiss: onDismiss
428
+ onDismiss: onDismiss,
429
+ screenId: entries.last?.id
357
430
  )
358
431
  }
359
432
 
@@ -362,7 +435,10 @@ public final class Navigator: ObservableObject {
362
435
  isSurface: Bool = false,
363
436
  barrierDismissible: Bool = true,
364
437
  header: BottomSheetHeader? = nil,
365
- onDismiss: (() -> Void)? = nil
438
+ onDismiss: (() -> Void)? = nil,
439
+ draggable: Bool = true,
440
+ useBottomInset: Bool = true,
441
+ footer: (() -> AnyView)? = nil
366
442
  ) {
367
443
  overplayDismissHandler = nil
368
444
  overplay = OverplayItem(
@@ -371,7 +447,11 @@ public final class Navigator: ObservableObject {
371
447
  barrierDismissible: barrierDismissible,
372
448
  onDismiss: onDismiss,
373
449
  isSurface: isSurface,
374
- header: header
450
+ header: header,
451
+ draggable: draggable,
452
+ useBottomInset: useBottomInset,
453
+ footer: footer,
454
+ screenId: entries.last?.id
375
455
  )
376
456
  }
377
457
 
@@ -383,7 +463,8 @@ public final class Navigator: ObservableObject {
383
463
  content: { AnyView(EmptyView()) },
384
464
  barrierDismissible: false,
385
465
  onDismiss: onDismiss,
386
- snackBar: snackBar
466
+ snackBar: snackBar,
467
+ screenId: entries.last?.id
387
468
  )
388
469
  }
389
470
 
@@ -419,6 +500,14 @@ public final class Navigator: ObservableObject {
419
500
  if overplay?.type == .snackBar { hideOverplay() }
420
501
  }
421
502
 
503
+ /// Room the current screen's footer (or navigation bar) takes from the window bottom.
504
+ func snackBarBottomInset() -> CGFloat {
505
+ guard let id = currentScreen()?.id, let navigation = registry.navigation(id: id) else {
506
+ return NavigationFooter.navigationBarInset
507
+ }
508
+ return navigation.bottomObstruction
509
+ }
510
+
422
511
  // MARK: Screen disposal
423
512
 
424
513
  /// Matches the `delay(300)` Compose waits in `dismissScreen` before unregistering,
@@ -429,22 +518,19 @@ public final class Navigator: ObservableObject {
429
518
  /// after the component has taken itself down.
430
519
  private static let overplayDismissDelay: TimeInterval = 0.3
431
520
 
432
- private func disposeScreens(droppedFrom previous: [DynamicScreenRoute]) {
433
- let live = Set(path.map(\.id))
434
- disposeScreens(previous.map(\.id).filter { !live.contains($0) })
521
+ private func disposeScreens(droppedFrom previous: [StackEntry], keeping current: [StackEntry]) {
522
+ let kept = Set(current.map(\.id))
523
+ disposeScreens(previous.map(\.id).filter { !kept.contains($0) })
435
524
  }
436
525
 
437
526
  /// 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.
527
+ /// view is still on screen while it animates. Liveness is re-checked when the timer
528
+ /// fires, so an entry back on the stack by then keeps its screen.
441
529
  private func disposeScreens(_ ids: [Int]) {
442
530
  guard !ids.isEmpty else { return }
443
531
  DispatchQueue.main.asyncAfter(deadline: .now() + Self.screenDisposeDelay) { [weak self] in
444
532
  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) }
533
+ let live = Set(self.entries.map(\.id))
448
534
  for id in ids where !live.contains(id) {
449
535
  self.registry.unregister(id: id)
450
536
  }