@momo-kits/native-kits 0.162.44-debug → 0.162.45-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 (199) hide show
  1. package/CLAUDE.md +45 -3
  2. package/README.md +2 -2
  3. package/compose/build.gradle.kts +9 -1
  4. package/compose/build.gradle.kts.backup +8 -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 +351 -111
  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 +1 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +55 -22
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -1
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +2 -1
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +1 -2
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +314 -0
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +10 -0
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +4 -0
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +6 -0
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +197 -60
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +127 -83
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +54 -62
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +97 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +377 -0
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +122 -24
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +34 -14
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +3 -3
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +116 -26
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
  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/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +13 -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/libs.versions.toml +1 -1
  61. package/gradle.properties +1 -1
  62. package/ios/Application/ApplicationEnvironment.swift +17 -0
  63. package/ios/Application/MaxApi.swift +30 -3
  64. package/ios/Avatar/Avatar.swift +4 -3
  65. package/ios/Badge/Badge.swift +6 -4
  66. package/ios/Badge/BadgeDot.swift +1 -1
  67. package/ios/Badge/BadgeRibbon.swift +0 -1
  68. package/ios/Button/Button.swift +15 -12
  69. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  70. package/ios/Carousel/Carousel.swift +29 -12
  71. package/ios/Checkbox/Checkbox.swift +7 -3
  72. package/ios/Chip/Chip.swift +5 -4
  73. package/ios/Collapse/Collapse.swift +70 -19
  74. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  75. package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
  76. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  77. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  78. package/ios/Divider/Divider.swift +37 -4
  79. package/ios/Extensions/Color++.swift +38 -9
  80. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  81. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  82. package/ios/Icon/Icon.swift +21 -23
  83. package/ios/Icon/Icons.swift +1329 -0
  84. package/ios/IconButton/IconButton.swift +15 -7
  85. package/ios/Image/Image.swift +7 -23
  86. package/ios/Information/Information.swift +27 -9
  87. package/ios/Input/ErrorView.swift +2 -2
  88. package/ios/Input/Input.swift +160 -147
  89. package/ios/Input/InputPhoneNumber.swift +12 -9
  90. package/ios/Input/InputSearch.swift +107 -77
  91. package/ios/Input/InputTextArea.swift +128 -145
  92. package/ios/InputDropDown/InputDropDown.swift +10 -7
  93. package/ios/InputMoney/InputMoney.swift +33 -11
  94. package/ios/InputOTP/InputOTP.swift +22 -12
  95. package/ios/Layout/Card.swift +50 -0
  96. package/ios/Layout/GridContext.swift +187 -3
  97. package/ios/Layout/Item.swift +5 -4
  98. package/ios/Layout/ScreenLayout.swift +385 -0
  99. package/ios/Layout/Section.swift +4 -100
  100. package/ios/Loader/Loader.swift +5 -9
  101. package/ios/Lottie/LottieView.swift +14 -2
  102. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +169 -29
  103. package/ios/Navigation/BottomTab/BottomTab.swift +332 -0
  104. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +18 -7
  105. package/ios/Navigation/BottomTab/DuoBarCompression.swift +104 -0
  106. package/ios/Navigation/BottomTab/DuoTabBar.swift +329 -0
  107. package/ios/{Application → Navigation/Component}/FloatingButton.swift +43 -54
  108. package/ios/Navigation/Component/Header.swift +545 -0
  109. package/ios/Navigation/Component/HeaderBackground.swift +154 -0
  110. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  111. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  112. package/ios/Navigation/Component/HeaderRight.swift +425 -0
  113. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  114. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  115. package/ios/Navigation/Component/SideBar.swift +183 -0
  116. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +8 -8
  117. package/ios/Navigation/HostBackGestureBridge.swift +78 -0
  118. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +9 -2
  119. package/ios/Navigation/Navigation.swift +204 -0
  120. package/ios/Navigation/NavigationContainer.swift +195 -0
  121. package/ios/{Application/Navigation → Navigation}/Navigator.swift +207 -107
  122. package/ios/Navigation/StackScreen.swift +930 -0
  123. package/ios/Navigation/StackTransition.swift +400 -0
  124. package/ios/Navigation/SwipeBackHaptic.swift +33 -0
  125. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  126. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  127. package/ios/Pagination/PaginationScroll.swift +2 -2
  128. package/ios/Popup/PopupDisplay.swift +72 -135
  129. package/ios/Popup/PopupInput.swift +10 -13
  130. package/ios/Popup/PopupLayout.swift +140 -0
  131. package/ios/Popup/PopupNotify.swift +59 -69
  132. package/ios/Popup/PopupPromotion.swift +31 -47
  133. package/ios/PopupView/FullscreenPopup.swift +13 -19
  134. package/ios/PopupView/PopupView.swift +1 -1
  135. package/ios/PopupView/Utils++.swift +0 -8
  136. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  137. package/ios/Radio/Radio.swift +2 -1
  138. package/ios/Rating/Rating.swift +9 -1
  139. package/ios/Resources/dot_loading.json +558 -0
  140. package/ios/Resources/lottie_circle_loader.json +1 -0
  141. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  142. package/ios/Skeleton/Skeleton.swift +22 -27
  143. package/ios/Slider/Slider.swift +16 -18
  144. package/ios/Stepper/Stepper.swift +6 -1
  145. package/ios/Steps/Steps.swift +8 -6
  146. package/ios/SuggestAction/SuggestAction.swift +5 -0
  147. package/ios/Swipeable/SwipeCell.swift +10 -6
  148. package/ios/Switch/Switch.swift +22 -20
  149. package/ios/TabView/TabView.swift +102 -21
  150. package/ios/Tag/Tag.swift +15 -4
  151. package/ios/Template/Logo/Logo.swift +2 -1
  152. package/ios/Template/TrustBanner/TrustBanner.swift +106 -54
  153. package/ios/Title/Title.swift +6 -17
  154. package/ios/Tooltip/Tooltip.swift +29 -14
  155. package/ios/Typography/FontScale.swift +3 -3
  156. package/ios/Typography/Text.swift +81 -19
  157. package/ios/Uploader/Uploader.swift +3 -1
  158. package/ios/native-kits.podspec +2 -1
  159. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  160. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  161. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  162. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
  163. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  164. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  165. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
  166. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
  167. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
  168. package/package.json +1 -1
  169. package/scripts/gen-ios-icons.mjs +36 -0
  170. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
  171. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
  172. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
  173. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
  174. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
  175. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
  176. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
  177. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
  178. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
  179. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
  180. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
  181. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
  182. package/ios/Application/Components.swift +0 -270
  183. package/ios/Application/ComposeApi.swift +0 -22
  184. package/ios/Application/HeaderRight.swift +0 -306
  185. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  186. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  187. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  188. package/ios/Application/Navigation/NavigationContainer.swift +0 -213
  189. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  190. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  191. package/ios/Application/Navigation/component/Header.swift +0 -178
  192. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  193. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  194. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  195. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  196. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  197. package/ios/Application/Screen.swift +0 -250
  198. package/ios/Application/StackScreen.swift +0 -492
  199. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
@@ -0,0 +1,332 @@
1
+ //
2
+ // BottomTab.swift
3
+ // MoMoUIKits
4
+ //
5
+ // SwiftUI mirror of Compose's `navigation/bottomtab/BottomTab.kt`: tabbed screens, each in
6
+ // `StackScreen` chrome built from its own `Navigation`, over a `BottomTabBar` (or the iPhone Duo
7
+ // rail / floating bar). Switching tabs cross-fades + scales the content.
8
+ //
9
+
10
+ import SwiftUI
11
+ import UIKit
12
+
13
+ public struct BottomTab: View {
14
+ private let items: [BottomTabItem]
15
+ private let floatingButton: BottomTabFloatingButton?
16
+ private let compressionBehavior: DuoBarCompressionBehavior
17
+ private let onTabPress: ((_ index: Int, _ switchTab: @escaping () -> Void) -> Void)?
18
+ private let backToInitialTab: Bool
19
+
20
+ @EnvironmentObject private var navigator: Navigator
21
+ /// The screen hosting the tabs (Compose's `LocalNavigation` in BottomTab).
22
+ @OptionalEnvironmentObject private var hostNavigation: Navigation?
23
+ @Environment(\.screenLayout) private var layout
24
+ @Environment(\.appTheme) private var theme
25
+ @StateObject private var tabNavigations = TabNavigations()
26
+ /// Compose's `startRoute`: picked once from `initialRouteName`.
27
+ @State private var startIndex: Int
28
+ @State private var selectedIndex: Int
29
+ @State private var toolbarHeight = DuoBarCompression.defaultToolbarHeight
30
+ @State private var expandedBar: DuoBar?
31
+ @State private var openedBar: DuoBar?
32
+
33
+ /// `compressionBehavior`: on iPhone Duo, when the side bar can't hold both bars, which one
34
+ /// starts compressed; tapping the compressed one swaps them.
35
+ /// `onTabPress` gets every tab press with the switch to perform, e.g. after a login gate.
36
+ /// `backToInitialTab`: back off another tab returns to the initial one before leaving the screen.
37
+ public init(
38
+ items: [BottomTabItem],
39
+ floatingButton: BottomTabFloatingButton? = nil,
40
+ compressionBehavior: DuoBarCompressionBehavior = .toolbar,
41
+ initialRouteName: String? = nil,
42
+ onTabPress: ((_ index: Int, _ switchTab: @escaping () -> Void) -> Void)? = nil,
43
+ backToInitialTab: Bool = true
44
+ ) {
45
+ self.items = items
46
+ self.floatingButton = floatingButton
47
+ self.compressionBehavior = compressionBehavior
48
+ self.onTabPress = onTabPress
49
+ self.backToInitialTab = backToInitialTab
50
+ let start = items.firstIndex { $0.name == initialRouteName } ?? 0
51
+ _startIndex = State(initialValue: start)
52
+ _selectedIndex = State(initialValue: start)
53
+ }
54
+
55
+ private var bottomInset: CGFloat { safeAreaBottomInset() }
56
+ // the Duo bars overlay the content or sit in the side strip, so only the home indicator is kept clear
57
+ private var reservedBottom: CGFloat {
58
+ layout.isIPhoneDuo ? layout.insets.bottom : BOTTOM_TAB_BAR_HEIGHT + bottomInset + Spacing.S
59
+ }
60
+
61
+ private var compression: DuoBarCompression {
62
+ DuoBarCompression.resolve(
63
+ layout: layout,
64
+ tabCount: items.count,
65
+ hasQr: floatingButton != nil,
66
+ toolbarHeight: toolbarHeight,
67
+ // 'toolbar' compression keeps the tab bar whole until the toolbar is tapped
68
+ expanded: Binding(
69
+ get: { expandedBar ?? (compressionBehavior == .toolbar ? .tabBar : .toolbar) },
70
+ set: { expandedBar = $0 }
71
+ ),
72
+ opened: $openedBar,
73
+ setToolbarHeight: { height in
74
+ if toolbarHeight != height { toolbarHeight = height }
75
+ }
76
+ )
77
+ }
78
+
79
+ private func duoInsets(_ compression: DuoBarCompression) -> DuoTabBarInsets {
80
+ layout.duoTabBarInsets(
81
+ itemCount: items.count,
82
+ hasFloatingButton: floatingButton != nil,
83
+ tabBarCompressed: compression.tabBarCompressed
84
+ )
85
+ }
86
+
87
+ public var body: some View {
88
+ let compression = compression
89
+ ZStack(alignment: .bottom) {
90
+ // Tab content area, inset above the bar.
91
+ TabHost(selectedIndex: selectedIndex, count: items.count) { index in AnyView(tabScreen(for: index)) }
92
+ // each tab hosts itself, so it needs the window's safe area to draw its header under the status bar
93
+ .ignoresSafeArea(.container, edges: [.top, .horizontal])
94
+ .padding(.bottom, reservedBottom)
95
+ .environment(\.duoTabBarInsets, duoInsets(compression))
96
+ .environment(\.duoBarCompression, compression)
97
+
98
+ if layout.isIPhoneDuo {
99
+ DuoTabBar(
100
+ items: items,
101
+ floatingButton: floatingButton,
102
+ selectedIndex: selectedIndex,
103
+ onTabSelected: selectTab,
104
+ compression: compression
105
+ )
106
+ } else {
107
+ // Tab bar + home-indicator filler.
108
+ VStack(spacing: 0) {
109
+ BottomTabBar(
110
+ items: items,
111
+ floatingButton: floatingButton,
112
+ selectedIndex: selectedIndex,
113
+ onTabSelected: selectTab
114
+ )
115
+ theme.colors.background.surface
116
+ .frame(height: bottomInset + Spacing.S)
117
+ .ignoresSafeArea(edges: .horizontal)
118
+ }
119
+ }
120
+ }
121
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
122
+ .ignoresSafeArea(edges: .bottom)
123
+ .preference(key: IsBottomTabRootKey.self, value: true)
124
+ .onAppear {
125
+ hostNavigation?.setOptions {
126
+ $0.headerType = .none
127
+ $0.useAvoidKeyboard = false
128
+ }
129
+ updateTabBack()
130
+ }
131
+ .onChange(of: selectedIndex) { _ in updateTabBack() }
132
+ .onDisappear {
133
+ // both live outside this view
134
+ navigator.tabBackHandler = nil
135
+ HostBackGestureBridge.shared.shouldUseFloatingArrowBack = false
136
+ }
137
+ }
138
+
139
+ private func selectTab(_ index: Int) {
140
+ let switchTab = { if index != selectedIndex { selectedIndex = index } }
141
+ if let onTabPress { onTabPress(index, switchTab) } else { switchTab() }
142
+ }
143
+
144
+ /// Back off the initial tab returns to it; the host shows its floating arrow there (RN parity).
145
+ private func updateTabBack() {
146
+ let isFirstTab = selectedIndex == startIndex || !backToInitialTab
147
+ HostBackGestureBridge.shared.shouldUseFloatingArrowBack = !isFirstTab
148
+ navigator.tabBackHandler = isFirstTab ? nil : (hostNavigation?.id ?? -1, { selectedIndex = startIndex })
149
+ }
150
+
151
+ private func tabScreen(for index: Int) -> some View {
152
+ let item = items[index]
153
+ return TabScreenHost(
154
+ navigation: tabNavigations.navigation(for: index, options: item.options),
155
+ screen: item.screen,
156
+ goBack: { navigator.onBackSafe() }
157
+ )
158
+ }
159
+ }
160
+
161
+ /// Keeps each tab's `Navigation`, so options a tab set at runtime survive switching away.
162
+ private final class TabNavigations: ObservableObject {
163
+ private var navigations: [Int: Navigation] = [:]
164
+
165
+ func navigation(for index: Int, options: NavigationOptions?) -> Navigation {
166
+ if let navigation = navigations[index] { return navigation }
167
+ let navigation = Navigation(bottomTabIndex: index, options: options)
168
+ navigations[index] = navigation
169
+ return navigation
170
+ }
171
+ }
172
+
173
+ private struct TabScreenHost: View {
174
+ @ObservedObject var navigation: Navigation
175
+ let screen: () -> AnyView
176
+ let goBack: () -> Void
177
+
178
+ var body: some View {
179
+ OptionsStackScreen(
180
+ options: navigation.options,
181
+ screenName: navigation.options.screenName ?? "",
182
+ goBack: goBack,
183
+ content: screen
184
+ )
185
+ .environmentObject(navigation)
186
+ }
187
+ }
188
+
189
+ // MARK: - Tab host
190
+
191
+ /// Compose's tab NavHost with `saveState` / `restoreState`. A tab gets its own UIHostingController
192
+ /// on first selection and keeps it, so its state and scroll survive a switch; while hidden it is
193
+ /// out of the window, so it gets onDisappear / onAppear (and re-runs screen tracking) like a NavHost
194
+ /// destination or a UITabBarController tab. Same scheme as `StackController`.
195
+ private struct TabHost: UIViewControllerRepresentable {
196
+ let selectedIndex: Int
197
+ let count: Int
198
+ let content: (Int) -> AnyView
199
+
200
+ func makeUIViewController(context: Context) -> TabController {
201
+ TabController()
202
+ }
203
+
204
+ func updateUIViewController(_ controller: TabController, context: Context) {
205
+ controller.update(selectedIndex: selectedIndex, count: count, environment: context.environment, content: content)
206
+ }
207
+ }
208
+
209
+ private final class TabController: UIViewController {
210
+ private final class Tab {
211
+ let motion = TabMotion()
212
+ var host: UIHostingController<AnyView>!
213
+ }
214
+
215
+ private static let switchDuration: TimeInterval = 0.2
216
+
217
+ private var tabs: [Int: Tab] = [:]
218
+ private var selectedIndex: Int?
219
+ private var environment = EnvironmentValues()
220
+ private var content: (Int) -> AnyView = { _ in AnyView(EmptyView()) }
221
+
222
+ override func viewDidLoad() {
223
+ super.viewDidLoad()
224
+ view.backgroundColor = .clear
225
+ }
226
+
227
+ override func viewDidLayoutSubviews() {
228
+ super.viewDidLayoutSubviews()
229
+ for subview in view.subviews { subview.frame = view.bounds }
230
+ }
231
+
232
+ func update(
233
+ selectedIndex index: Int,
234
+ count: Int,
235
+ environment: EnvironmentValues,
236
+ content: @escaping (Int) -> AnyView
237
+ ) {
238
+ self.environment = environment
239
+ self.content = content
240
+ for (stale, removed) in tabs where stale >= count {
241
+ tabs[stale] = nil
242
+ tearDown(removed)
243
+ }
244
+ if let selectedIndex, tabs[selectedIndex] == nil { self.selectedIndex = nil }
245
+ // hidden tabs too, so a tab coming back already shows the latest items and environment
246
+ for (tabIndex, existing) in tabs { existing.host.rootView = rootView(tabIndex, existing) }
247
+ guard (0..<count).contains(index), index != selectedIndex else { return }
248
+
249
+ let previous = selectedIndex.flatMap { tabs[$0] }
250
+ selectedIndex = index
251
+ let selected = tabs[index] ?? makeTab(index)
252
+ attach(selected)
253
+
254
+ guard let previous, view.window != nil else {
255
+ selected.motion.visible = true
256
+ previous.map(leave)
257
+ return
258
+ }
259
+ previous.host.view.isUserInteractionEnabled = false
260
+ // a frame at the start values first, so a tab created just now fades in too
261
+ DispatchQueue.main.async {
262
+ selected.motion.visible = true
263
+ previous.motion.visible = false
264
+ }
265
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.switchDuration) { [weak self] in
266
+ self?.leave(previous)
267
+ }
268
+ }
269
+
270
+ private func makeTab(_ index: Int) -> Tab {
271
+ let tab = Tab()
272
+ let host = UIHostingController(rootView: rootView(index, tab))
273
+ host.view.backgroundColor = .clear
274
+ addChild(host)
275
+ host.didMove(toParent: self)
276
+ tab.host = host
277
+ tabs[index] = tab
278
+ return tab
279
+ }
280
+
281
+ private func rootView(_ index: Int, _ tab: Tab) -> AnyView {
282
+ AnyView(TabCard(motion: tab.motion, content: content(index)).environment(\.self, environment))
283
+ }
284
+
285
+ private func attach(_ tab: Tab) {
286
+ let hostView: UIView = tab.host.view
287
+ hostView.isUserInteractionEnabled = true
288
+ hostView.frame = view.bounds
289
+ if hostView.superview === view {
290
+ view.bringSubviewToFront(hostView)
291
+ } else {
292
+ view.addSubview(hostView)
293
+ }
294
+ }
295
+
296
+ /// Off the window once hidden; a tab selected again in the meantime stays.
297
+ private func leave(_ tab: Tab) {
298
+ guard selectedIndex.flatMap({ tabs[$0] }) !== tab else { return }
299
+ tab.host.view.removeFromSuperview()
300
+ }
301
+
302
+ private func tearDown(_ tab: Tab) {
303
+ tab.host.willMove(toParent: nil)
304
+ tab.host.view.removeFromSuperview()
305
+ tab.host.removeFromParent()
306
+ }
307
+ }
308
+
309
+ private final class TabMotion: ObservableObject {
310
+ @Published var visible = false
311
+ }
312
+
313
+ /// The tab switch: 0.97 scale + fade over 200ms, both ways (Compose's tab enter / exit transitions).
314
+ private struct TabCard: View {
315
+ @ObservedObject var motion: TabMotion
316
+ let content: AnyView
317
+
318
+ var body: some View {
319
+ content
320
+ .opacity(motion.visible ? 1 : 0)
321
+ .scaleEffect(motion.visible ? 1 : 0.97)
322
+ .animation(.easeInOut(duration: 0.2), value: motion.visible)
323
+ }
324
+ }
325
+
326
+ /// Bottom safe-area inset (home indicator).
327
+ private func safeAreaBottomInset() -> CGFloat {
328
+ let bottomInset = UIApplication.shared.connectedScenes
329
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
330
+ .first ?? 0
331
+ return min(bottomInset, 21)
332
+ }
@@ -2,7 +2,7 @@
2
2
  // BottomTabBar.swift
3
3
  // MoMoUIKits
4
4
  //
5
- // SwiftUI mirror of Compose's `BottomTabBar` (navigation/bottomtab/BottomTabBar.kt).
5
+ // SwiftUI mirror of Compose's `navigation/bottomtab/BottomTabBar.kt`.
6
6
  // Renders the row of tab items, an animated selection indicator, a top shadow,
7
7
  // badges/dots, and an optional center floating button (with the curved notch).
8
8
  //
@@ -20,6 +20,7 @@ public struct BottomTabItem: Identifiable {
20
20
  public let showDot: Bool
21
21
  public let badgeLabel: String?
22
22
  public let options: NavigationOptions?
23
+ public let initialParams: Any?
23
24
  public let screen: () -> AnyView
24
25
 
25
26
  public init(
@@ -29,6 +30,7 @@ public struct BottomTabItem: Identifiable {
29
30
  showDot: Bool = false,
30
31
  badgeLabel: String? = nil,
31
32
  options: NavigationOptions? = nil,
33
+ initialParams: Any? = nil,
32
34
  @ViewBuilder screen: @escaping () -> some View
33
35
  ) {
34
36
  self.name = name
@@ -37,6 +39,7 @@ public struct BottomTabItem: Identifiable {
37
39
  self.showDot = showDot
38
40
  self.badgeLabel = badgeLabel
39
41
  self.options = options
42
+ self.initialParams = initialParams
40
43
  self.screen = { AnyView(screen()) }
41
44
  }
42
45
  }
@@ -65,7 +68,11 @@ struct BottomTabBar: View {
65
68
  let selectedIndex: Int
66
69
  let onTabSelected: (Int) -> Void
67
70
 
68
- private var screenWidth: CGFloat { UIScreen.main.bounds.width }
71
+ @Environment(\.screenLayout) private var layout
72
+ @Environment(\.appTheme) private var theme
73
+
74
+ // the bar sits between the side safe areas
75
+ private var screenWidth: CGFloat { layout.width - layout.insets.leading - layout.insets.trailing }
69
76
  private var mid: Int { items.count / 2 }
70
77
 
71
78
  private var tabWidth: CGFloat {
@@ -95,11 +102,11 @@ struct BottomTabBar: View {
95
102
  }
96
103
  .frame(maxWidth: .infinity)
97
104
  .frame(height: BOTTOM_TAB_BAR_HEIGHT)
98
- .background(Colors.black01)
105
+ .background(theme.colors.background.surface.ignoresSafeArea(edges: .horizontal))
99
106
 
100
107
  // Animated selection indicator.
101
108
  RoundedRectangle(cornerRadius: 2)
102
- .fill(Colors.primary)
109
+ .fill(theme.colors.primary)
103
110
  .frame(width: 44, height: 2)
104
111
  .offset(x: indicatorOffset, y: -BOTTOM_TAB_BAR_HEIGHT + 2)
105
112
  .animation(.easeInOut(duration: 0.25), value: selectedIndex)
@@ -140,7 +147,9 @@ private struct TabBarItem: View {
140
147
  let selected: Bool
141
148
  let onClick: () -> Void
142
149
 
143
- private var tintColor: Color { selected ? Colors.primary : Colors.black10 }
150
+ @Environment(\.appTheme) private var theme
151
+
152
+ private var tintColor: Color { selected ? theme.colors.primary : theme.colors.text.hint }
144
153
 
145
154
  /// Mirrors Compose's `formatLabel`: hide a badge that is the number "0".
146
155
  private var badgeLabel: String? {
@@ -192,18 +201,20 @@ private struct TabBarItem: View {
192
201
  private struct CenterFloatingButton: View {
193
202
  let data: BottomTabFloatingButton
194
203
 
204
+ @Environment(\.appTheme) private var theme
205
+
195
206
  var body: some View {
196
207
  VStack(spacing: 0) {
197
208
  CurvedContainer()
198
209
  Circle()
199
- .fill(Colors.primary)
210
+ .fill(theme.colors.primary)
200
211
  .frame(width: 48, height: 48)
201
212
  .overlay(Icon(source: data.icon, size: 28, color: .white))
202
213
  Spacer().frame(height: 4)
203
214
  MomoText(data.label, typography: .labelXsMedium, color: Colors.black01, textAlign: .center, maxLines: 1)
204
215
  .frame(maxWidth: .infinity)
205
216
  .frame(height: 14)
206
- .background(Colors.primary)
217
+ .background(theme.colors.primary)
207
218
  .clipShape(RoundedRectangle(cornerRadius: Radius.XL))
208
219
  }
209
220
  .frame(width: bottomTabFloatingButtonWidth)
@@ -0,0 +1,104 @@
1
+ //
2
+ // DuoBarCompression.swift
3
+ // MoMoUIKits
4
+ //
5
+ // Port of RN `BottomTab/DuoBarCompression.tsx` (HIG, Designing for iPhone Duo: Vertical controls):
6
+ // keeps the side bar's toolbar and tab bar clear of each other.
7
+ //
8
+
9
+ import SwiftUI
10
+
11
+ /// iPhone Duo: which side bar bar starts compressed when the strip cannot hold both whole.
12
+ public enum DuoBarCompressionBehavior {
13
+ case toolbar, tabBar
14
+ }
15
+
16
+ enum DuoBar {
17
+ case toolbar, tabBar
18
+ }
19
+
20
+ struct DuoBarCompression {
21
+ var toolbarCompressed = false
22
+ var tabBarCompressed = false
23
+ /// The open rail needs the toolbar button slot too: only Back stays.
24
+ var toolbarHidden = false
25
+ var expandToolbar: () -> Void = {}
26
+ var expandTabBar: () -> Void = {}
27
+ /// A rail opened from the compressed state closes once a tab is picked.
28
+ var onTabSelected: () -> Void = {}
29
+ /// Side bar toolkits report their full height so the tab bar knows what is left.
30
+ var setToolbarHeight: (CGFloat) -> Void = { _ in }
31
+
32
+ /// Space kept between the top and the bottom groups (HIG Vertical controls).
33
+ static let groupGap = Spacing.M
34
+ /// Back, then the right toolkit: help + home capsule.
35
+ static let defaultToolbarHeight = DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch
36
+ /// Back, then the single button the toolbar folds into.
37
+ private static let compressedToolbarHeight = 2 * DuoMetrics.controlSize + Spacing.M
38
+
39
+ /// - both fit: both stay whole
40
+ /// - one fits whole beside the other compressed: one is compressed, tapping it swaps them
41
+ /// - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
42
+ /// again once a tab is picked)
43
+ static func resolve(
44
+ layout: ScreenLayout,
45
+ tabCount: Int,
46
+ hasQr: Bool,
47
+ toolbarHeight: CGFloat,
48
+ expanded: Binding<DuoBar>,
49
+ opened: Binding<DuoBar?>,
50
+ setToolbarHeight: @escaping (CGFloat) -> Void
51
+ ) -> DuoBarCompression {
52
+ guard let sideBar = layout.sideBar else { return DuoBarCompression() }
53
+ let room = layout.height - sideBar.bottom - sideBar.top - groupGap
54
+ let tabBar = duoRailHeight(itemCount: tabCount, hasQr: hasQr)
55
+ let compressedTabBar = duoRailHeight(itemCount: 1, hasQr: hasQr, compressed: true)
56
+ func fits(_ toolbar: CGFloat, _ tabs: CGFloat) -> Bool { toolbar + tabs <= room }
57
+
58
+ if fits(toolbarHeight, tabBar) {
59
+ return DuoBarCompression(setToolbarHeight: setToolbarHeight)
60
+ }
61
+ if fits(compressedToolbarHeight, tabBar) && fits(toolbarHeight, compressedTabBar) {
62
+ return DuoBarCompression(
63
+ toolbarCompressed: expanded.wrappedValue == .tabBar,
64
+ tabBarCompressed: expanded.wrappedValue == .toolbar,
65
+ expandToolbar: { expanded.wrappedValue = .toolbar },
66
+ expandTabBar: { expanded.wrappedValue = .tabBar },
67
+ setToolbarHeight: setToolbarHeight
68
+ )
69
+ }
70
+
71
+ let expandToolbar = { opened.wrappedValue = .toolbar }
72
+ let expandTabBar = { opened.wrappedValue = .tabBar }
73
+ // an opened rail always shows, even when only the group gap is short: it closes once a tab is picked
74
+ if opened.wrappedValue == .tabBar {
75
+ return DuoBarCompression(
76
+ toolbarCompressed: true,
77
+ tabBarCompressed: false,
78
+ toolbarHidden: true,
79
+ expandToolbar: expandToolbar,
80
+ expandTabBar: expandTabBar,
81
+ onTabSelected: { opened.wrappedValue = nil },
82
+ setToolbarHeight: setToolbarHeight
83
+ )
84
+ }
85
+ return DuoBarCompression(
86
+ toolbarCompressed: opened.wrappedValue != .toolbar,
87
+ tabBarCompressed: true,
88
+ expandToolbar: expandToolbar,
89
+ expandTabBar: expandTabBar,
90
+ setToolbarHeight: setToolbarHeight
91
+ )
92
+ }
93
+ }
94
+
95
+ private struct DuoBarCompressionKey: EnvironmentKey {
96
+ static let defaultValue = DuoBarCompression()
97
+ }
98
+
99
+ extension EnvironmentValues {
100
+ var duoBarCompression: DuoBarCompression {
101
+ get { self[DuoBarCompressionKey.self] }
102
+ set { self[DuoBarCompressionKey.self] = newValue }
103
+ }
104
+ }