@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
@@ -2,7 +2,7 @@
2
2
  // BottomSheet.swift
3
3
  // MoMoUIKits
4
4
  //
5
- // SwiftUI mirror of Compose's `BottomSheet` (navigation/BottomSheet.kt).
5
+ // SwiftUI mirror of Compose's `navigation/BottomSheet.kt`.
6
6
  // A sheet that slides up from the bottom over a dimmed scrim, with a drag
7
7
  // handle, a header (title or custom), a divider and the content body.
8
8
  // Dragging down past a threshold (or tapping the scrim) dismisses it.
@@ -35,16 +35,20 @@ public struct BottomSheetHeader {
35
35
 
36
36
  // MARK: - Bottom sheet view
37
37
 
38
- @available(iOS 16.0, *)
39
38
  struct BottomSheetView<Content: View>: View {
40
39
  let header: BottomSheetHeader
41
40
  let isSurface: Bool
42
41
  let barrierDismissible: Bool
42
+ var draggable: Bool = true
43
+ /// Keeps the content above the home indicator (Compose `AppNavigationBar` padding).
44
+ var useBottomInset: Bool = true
45
+ var footer: (() -> AnyView)? = nil
43
46
  let content: () -> Content
44
47
 
45
48
  @EnvironmentObject private var navigator: Navigator
46
49
  @Environment(\.appTheme) private var theme
47
50
  @Environment(\.applicationEnvironment) private var appEnv
51
+ @Environment(\.screenLayout) private var layout
48
52
 
49
53
  /// Vertical translation of the sheet; starts off-screen and animates to 0.
50
54
  @State private var sheetOffset: CGFloat = UIScreen.main.bounds.height
@@ -66,8 +70,36 @@ struct BottomSheetView<Content: View>: View {
66
70
  /// steady-state (fully open) scrim peaks at 0.6, matching ModalScreen's peak.
67
71
  private let maxDynamicAlpha: CGFloat = 0.3
68
72
 
69
- private var screenHeight: CGFloat { UIScreen.main.bounds.height }
70
- private var bottomInset: CGFloat { safeAreaBottomInset() }
73
+ // UIScreen stays on the outer display on iPhone Duo, even when unfolded
74
+ private var screenHeight: CGFloat { isDuo ? layout.height : UIScreen.main.bounds.height }
75
+
76
+ private var isDuo: Bool { layout.isDuoDevice }
77
+ // iPhone Duo inner display: center the sheet instead of spanning the width
78
+ private var isDuoCentered: Bool { isDuo && layout.width >= 600 }
79
+
80
+ /// Keeps the portrait width in landscape, like system sheets.
81
+ private var duoCenteredWidth: CGFloat {
82
+ min(
83
+ min(layout.width, layout.height) - Spacing.L * 2,
84
+ layout.width - max(layout.insets.leading, layout.insets.trailing, Spacing.L) * 2
85
+ )
86
+ }
87
+
88
+ /// Landscape side buttons span the full height, so the content is inset; in portrait the
89
+ /// side status bar only covers the top corner.
90
+ private var sideInsetLeading: CGFloat {
91
+ isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.leading : 0
92
+ }
93
+
94
+ private var sideInsetTrailing: CGFloat {
95
+ isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.trailing : 0
96
+ }
97
+
98
+ /// The keyboard filler is part of the sheet, so capping the sheet shrinks the content by it.
99
+ private var maxHeight: CGFloat {
100
+ isDuo ? layout.height - max(layout.duoTopInset, Spacing.XL) : screenHeight - 90
101
+ }
102
+ private var bottomInset: CGFloat { useBottomInset ? safeAreaBottomInset() : 0 }
71
103
  /// While the keyboard is up, it already covers the home-indicator area, so it
72
104
  /// replaces (rather than adds to) the usual bottom-inset filler.
73
105
  private var effectiveBottomInset: CGFloat { keyboardHeight > 0 ? keyboardHeight : bottomInset }
@@ -110,7 +142,7 @@ struct BottomSheetView<Content: View>: View {
110
142
  .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
111
143
  guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
112
144
  withAnimation(.easeOut(duration: 0.25)) {
113
- keyboardHeight = UIScreen.main.bounds.height - endFrame.origin.y
145
+ keyboardHeight = screenHeight - endFrame.origin.y
114
146
  }
115
147
  }
116
148
  .onAppear {
@@ -138,23 +170,34 @@ struct BottomSheetView<Content: View>: View {
138
170
  headerView
139
171
  .frame(height: 56)
140
172
  .padding(.horizontal, Spacing.M)
173
+ .padding(.leading, sideInsetLeading)
174
+ .padding(.trailing, sideInsetTrailing)
141
175
  }
142
176
  .frame(maxWidth: .infinity)
143
177
  .frame(height: 72)
144
178
  .contentShape(Rectangle())
145
- .gesture(dragGesture)
179
+ .gesture(dragGesture, including: draggable ? .all : .subviews)
146
180
 
147
181
  Rectangle()
148
182
  .fill(theme.colors.border.default)
149
183
  .frame(height: 1)
150
184
 
151
185
  content()
186
+ .padding(.leading, sideInsetLeading)
187
+ .padding(.trailing, sideInsetTrailing)
188
+ // a tall sheet keeps its header (and footer) and gives the content what is left
189
+ .layoutPriority(-1)
152
190
 
153
- backgroundColor
191
+ if let footer {
192
+ BottomSheetFooter(content: footer)
193
+ }
194
+
195
+ (footer != nil ? theme.colors.background.surface : backgroundColor)
154
196
  .frame(height: effectiveBottomInset)
155
197
  }
156
- .frame(maxWidth: .infinity)
157
- .frame(alignment: .bottom)
198
+ .frame(maxWidth: isDuoCentered ? duoCenteredWidth : .infinity)
199
+ .modifier(CapHeight(maxHeight: maxHeight))
200
+ .clipped()
158
201
  .background(
159
202
  GeometryReader { geo in
160
203
  Color.clear.onAppear { sheetHeight = geo.size.height }
@@ -247,7 +290,83 @@ struct BottomSheetView<Content: View>: View {
247
290
  }
248
291
  }
249
292
 
293
+ /// Compose's `heightIn(max:)`. `.frame(maxHeight:)` grows to the cap whenever the proposal is
294
+ /// larger, leaving a gap above the header; this sizes the sheet to its content and only caps it
295
+ /// when taller.
296
+ private struct CapHeight: ViewModifier {
297
+ let maxHeight: CGFloat
298
+
299
+ func body(content: Content) -> some View {
300
+ if #available(iOS 16.0, *) {
301
+ CapHeightLayout(maxHeight: maxHeight) { content }
302
+ } else {
303
+ content.modifier(CapHeightFallback(maxHeight: maxHeight))
304
+ }
305
+ }
306
+ }
307
+
308
+ /// iOS 15 has no `Layout`: the sheet hugs its ideal height until that outgrows the cap, then is
309
+ /// held at the cap (the ideal height can't be read again without a second copy of the content).
310
+ private struct CapHeightFallback: ViewModifier {
311
+ let maxHeight: CGFloat
312
+ @State private var capped = false
313
+
314
+ func body(content: Content) -> some View {
315
+ content
316
+ // both modifiers stay in place whatever `capped` is, so the content keeps its identity
317
+ .fixedSize(horizontal: false, vertical: !capped)
318
+ .background(
319
+ GeometryReader { proxy in
320
+ Color.clear
321
+ .onAppear { cap(proxy.size.height) }
322
+ .onChange(of: proxy.size.height) { cap($0) }
323
+ }
324
+ )
325
+ .frame(height: capped ? maxHeight : nil, alignment: .top)
326
+ }
327
+
328
+ private func cap(_ height: CGFloat) {
329
+ if !capped, height > maxHeight { capped = true }
330
+ }
331
+ }
332
+
250
333
  @available(iOS 16.0, *)
334
+ private struct CapHeightLayout: Layout {
335
+ let maxHeight: CGFloat
336
+
337
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
338
+ guard let subview = subviews.first else { return .zero }
339
+ let cap = min(maxHeight, proposal.height ?? .infinity)
340
+ // ideal first: a scroll view would otherwise fill whatever height it is offered
341
+ let ideal = subview.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil))
342
+ return subview.sizeThatFits(ProposedViewSize(width: proposal.width, height: min(ideal.height, cap)))
343
+ }
344
+
345
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
346
+ subviews.first?.place(at: bounds.origin, proposal: ProposedViewSize(bounds.size))
347
+ }
348
+ }
349
+
350
+ /// Mirrors Compose's `BottomSheetFooter`: a surface band with a soft shadow cast on the content.
351
+ private struct BottomSheetFooter: View {
352
+ let content: () -> AnyView
353
+
354
+ @Environment(\.appTheme) private var theme
355
+
356
+ var body: some View {
357
+ content()
358
+ .frame(maxWidth: .infinity)
359
+ .padding(.vertical, Spacing.S)
360
+ .background(theme.colors.background.surface)
361
+ .overlay(alignment: .top) {
362
+ LinearGradient(colors: [.clear, Color.black.opacity(0.05)], startPoint: .top, endPoint: .bottom)
363
+ .frame(height: 6)
364
+ .offset(y: -6)
365
+ .allowsHitTesting(false)
366
+ }
367
+ }
368
+ }
369
+
251
370
  private func safeAreaBottomInset() -> CGFloat {
252
371
  let bottomInset = UIApplication.shared.connectedScenes
253
372
  .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
@@ -0,0 +1,185 @@
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
+
12
+ public struct BottomTab: View {
13
+ private let items: [BottomTabItem]
14
+ private let floatingButton: BottomTabFloatingButton?
15
+ private let compressionBehavior: DuoBarCompressionBehavior
16
+
17
+ @EnvironmentObject private var navigator: Navigator
18
+ /// The screen hosting the tabs (Compose's `LocalNavigation` in BottomTab).
19
+ @OptionalEnvironmentObject private var hostNavigation: Navigation?
20
+ @Environment(\.screenLayout) private var layout
21
+ @Environment(\.appTheme) private var theme
22
+ @StateObject private var tabNavigations = TabNavigations()
23
+ @State private var selectedIndex: Int = 0
24
+ @State private var toolbarHeight = DuoBarCompression.defaultToolbarHeight
25
+ @State private var expandedBar: DuoBar?
26
+ @State private var openedBar: DuoBar?
27
+
28
+ /// `compressionBehavior`: on iPhone Duo, when the side bar can't hold both bars, which one
29
+ /// starts compressed; tapping the compressed one swaps them.
30
+ public init(
31
+ items: [BottomTabItem],
32
+ floatingButton: BottomTabFloatingButton? = nil,
33
+ compressionBehavior: DuoBarCompressionBehavior = .toolbar
34
+ ) {
35
+ self.items = items
36
+ self.floatingButton = floatingButton
37
+ self.compressionBehavior = compressionBehavior
38
+ }
39
+
40
+ private var bottomInset: CGFloat { safeAreaBottomInset() }
41
+ // the Duo bars overlay the content or sit in the side strip, so only the home indicator is kept clear
42
+ private var reservedBottom: CGFloat {
43
+ layout.isIPhoneDuo ? layout.insets.bottom : BOTTOM_TAB_BAR_HEIGHT + bottomInset + Spacing.S
44
+ }
45
+
46
+ private var compression: DuoBarCompression {
47
+ DuoBarCompression.resolve(
48
+ layout: layout,
49
+ tabCount: items.count,
50
+ hasQr: floatingButton != nil,
51
+ toolbarHeight: toolbarHeight,
52
+ // 'toolbar' compression keeps the tab bar whole until the toolbar is tapped
53
+ expanded: Binding(
54
+ get: { expandedBar ?? (compressionBehavior == .toolbar ? .tabBar : .toolbar) },
55
+ set: { expandedBar = $0 }
56
+ ),
57
+ opened: $openedBar,
58
+ setToolbarHeight: { height in
59
+ if toolbarHeight != height { toolbarHeight = height }
60
+ }
61
+ )
62
+ }
63
+
64
+ private func duoInsets(_ compression: DuoBarCompression) -> DuoTabBarInsets {
65
+ layout.duoTabBarInsets(
66
+ itemCount: items.count,
67
+ hasFloatingButton: floatingButton != nil,
68
+ tabBarCompressed: compression.tabBarCompressed
69
+ )
70
+ }
71
+
72
+ public var body: some View {
73
+ let compression = compression
74
+ ZStack(alignment: .bottom) {
75
+ // Tab content area, inset above the bar.
76
+ ZStack {
77
+ ForEach(items.indices, id: \.self) { index in
78
+ if index == selectedIndex {
79
+ tabScreen(for: index)
80
+ .transition(.scale(scale: 0.97).combined(with: .opacity))
81
+ }
82
+ }
83
+ }
84
+ .padding(.bottom, reservedBottom)
85
+ .environment(\.duoTabBarInsets, duoInsets(compression))
86
+ .environment(\.duoBarCompression, compression)
87
+ .animation(.easeInOut(duration: 0.2), value: selectedIndex)
88
+
89
+ if layout.isIPhoneDuo {
90
+ DuoTabBar(
91
+ items: items,
92
+ floatingButton: floatingButton,
93
+ selectedIndex: selectedIndex,
94
+ onTabSelected: selectTab,
95
+ compression: compression
96
+ )
97
+ } else {
98
+ // Tab bar + home-indicator filler.
99
+ VStack(spacing: 0) {
100
+ BottomTabBar(
101
+ items: items,
102
+ floatingButton: floatingButton,
103
+ selectedIndex: selectedIndex,
104
+ onTabSelected: selectTab
105
+ )
106
+ theme.colors.background.surface
107
+ .frame(height: bottomInset + Spacing.S)
108
+ .ignoresSafeArea(edges: .horizontal)
109
+ }
110
+ }
111
+ }
112
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
113
+ .ignoresSafeArea(edges: .bottom)
114
+ .preference(key: IsBottomTabRootKey.self, value: true)
115
+ .onAppear {
116
+ hostNavigation?.setOptions {
117
+ $0.headerType = .none
118
+ $0.useAvoidKeyboard = false
119
+ }
120
+ updateTabBack()
121
+ }
122
+ .onChange(of: selectedIndex) { _ in updateTabBack() }
123
+ .onDisappear {
124
+ // both live outside this view
125
+ navigator.tabBackHandler = nil
126
+ HostBackGestureBridge.shared.shouldUseFloatingArrowBack = false
127
+ }
128
+ }
129
+
130
+ private func selectTab(_ index: Int) {
131
+ if index != selectedIndex { selectedIndex = index }
132
+ }
133
+
134
+ /// Back off the first tab returns to it; the host shows its floating arrow there (RN parity).
135
+ private func updateTabBack() {
136
+ let isFirstTab = selectedIndex == 0
137
+ HostBackGestureBridge.shared.shouldUseFloatingArrowBack = !isFirstTab
138
+ navigator.tabBackHandler = isFirstTab ? nil : (hostNavigation?.id ?? -1, { selectedIndex = 0 })
139
+ }
140
+
141
+ private func tabScreen(for index: Int) -> some View {
142
+ let item = items[index]
143
+ return TabScreenHost(
144
+ navigation: tabNavigations.navigation(for: index, options: item.options),
145
+ screen: item.screen,
146
+ goBack: { navigator.onBackSafe() }
147
+ )
148
+ }
149
+ }
150
+
151
+ /// Keeps each tab's `Navigation`, so options a tab set at runtime survive switching away.
152
+ private final class TabNavigations: ObservableObject {
153
+ private var navigations: [Int: Navigation] = [:]
154
+
155
+ func navigation(for index: Int, options: NavigationOptions?) -> Navigation {
156
+ if let navigation = navigations[index] { return navigation }
157
+ let navigation = Navigation(bottomTabIndex: index, options: options)
158
+ navigations[index] = navigation
159
+ return navigation
160
+ }
161
+ }
162
+
163
+ private struct TabScreenHost: View {
164
+ @ObservedObject var navigation: Navigation
165
+ let screen: () -> AnyView
166
+ let goBack: () -> Void
167
+
168
+ var body: some View {
169
+ OptionsStackScreen(
170
+ options: navigation.options,
171
+ screenName: navigation.options.screenName ?? "",
172
+ goBack: goBack,
173
+ content: screen
174
+ )
175
+ .environmentObject(navigation)
176
+ }
177
+ }
178
+
179
+ /// Bottom safe-area inset (home indicator).
180
+ private func safeAreaBottomInset() -> CGFloat {
181
+ let bottomInset = UIApplication.shared.connectedScenes
182
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
183
+ .first ?? 0
184
+ return min(bottomInset, 21)
185
+ }
@@ -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
  //
@@ -65,7 +65,11 @@ struct BottomTabBar: View {
65
65
  let selectedIndex: Int
66
66
  let onTabSelected: (Int) -> Void
67
67
 
68
- private var screenWidth: CGFloat { UIScreen.main.bounds.width }
68
+ @Environment(\.screenLayout) private var layout
69
+ @Environment(\.appTheme) private var theme
70
+
71
+ // the bar sits between the side safe areas
72
+ private var screenWidth: CGFloat { layout.width - layout.insets.leading - layout.insets.trailing }
69
73
  private var mid: Int { items.count / 2 }
70
74
 
71
75
  private var tabWidth: CGFloat {
@@ -95,11 +99,11 @@ struct BottomTabBar: View {
95
99
  }
96
100
  .frame(maxWidth: .infinity)
97
101
  .frame(height: BOTTOM_TAB_BAR_HEIGHT)
98
- .background(Colors.black01)
102
+ .background(theme.colors.background.surface.ignoresSafeArea(edges: .horizontal))
99
103
 
100
104
  // Animated selection indicator.
101
105
  RoundedRectangle(cornerRadius: 2)
102
- .fill(Colors.primary)
106
+ .fill(theme.colors.primary)
103
107
  .frame(width: 44, height: 2)
104
108
  .offset(x: indicatorOffset, y: -BOTTOM_TAB_BAR_HEIGHT + 2)
105
109
  .animation(.easeInOut(duration: 0.25), value: selectedIndex)
@@ -140,7 +144,9 @@ private struct TabBarItem: View {
140
144
  let selected: Bool
141
145
  let onClick: () -> Void
142
146
 
143
- private var tintColor: Color { selected ? Colors.primary : Colors.black10 }
147
+ @Environment(\.appTheme) private var theme
148
+
149
+ private var tintColor: Color { selected ? theme.colors.primary : theme.colors.text.hint }
144
150
 
145
151
  /// Mirrors Compose's `formatLabel`: hide a badge that is the number "0".
146
152
  private var badgeLabel: String? {
@@ -192,18 +198,20 @@ private struct TabBarItem: View {
192
198
  private struct CenterFloatingButton: View {
193
199
  let data: BottomTabFloatingButton
194
200
 
201
+ @Environment(\.appTheme) private var theme
202
+
195
203
  var body: some View {
196
204
  VStack(spacing: 0) {
197
205
  CurvedContainer()
198
206
  Circle()
199
- .fill(Colors.primary)
207
+ .fill(theme.colors.primary)
200
208
  .frame(width: 48, height: 48)
201
209
  .overlay(Icon(source: data.icon, size: 28, color: .white))
202
210
  Spacer().frame(height: 4)
203
211
  MomoText(data.label, typography: .labelXsMedium, color: Colors.black01, textAlign: .center, maxLines: 1)
204
212
  .frame(maxWidth: .infinity)
205
213
  .frame(height: 14)
206
- .background(Colors.primary)
214
+ .background(theme.colors.primary)
207
215
  .clipShape(RoundedRectangle(cornerRadius: Radius.XL))
208
216
  }
209
217
  .frame(width: bottomTabFloatingButtonWidth)
@@ -0,0 +1,105 @@
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
+ /// Back alone, while a rail opened from the compressed state takes the toolbar slot.
39
+ private static let backOnlyHeight = DuoMetrics.controlSize
40
+
41
+ /// - both fit: both stay whole
42
+ /// - one fits whole beside the other compressed: one is compressed, tapping it swaps them
43
+ /// - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
44
+ /// again once a tab is picked)
45
+ static func resolve(
46
+ layout: ScreenLayout,
47
+ tabCount: Int,
48
+ hasQr: Bool,
49
+ toolbarHeight: CGFloat,
50
+ expanded: Binding<DuoBar>,
51
+ opened: Binding<DuoBar?>,
52
+ setToolbarHeight: @escaping (CGFloat) -> Void
53
+ ) -> DuoBarCompression {
54
+ guard let sideBar = layout.sideBar else { return DuoBarCompression() }
55
+ let room = layout.height - sideBar.bottom - sideBar.top - groupGap
56
+ let tabBar = duoRailHeight(itemCount: tabCount, hasQr: hasQr)
57
+ let compressedTabBar = duoRailHeight(itemCount: 1, hasQr: hasQr, compressed: true)
58
+ func fits(_ toolbar: CGFloat, _ tabs: CGFloat) -> Bool { toolbar + tabs <= room }
59
+
60
+ if fits(toolbarHeight, tabBar) {
61
+ return DuoBarCompression(setToolbarHeight: setToolbarHeight)
62
+ }
63
+ if fits(compressedToolbarHeight, tabBar) && fits(toolbarHeight, compressedTabBar) {
64
+ return DuoBarCompression(
65
+ toolbarCompressed: expanded.wrappedValue == .tabBar,
66
+ tabBarCompressed: expanded.wrappedValue == .toolbar,
67
+ expandToolbar: { expanded.wrappedValue = .toolbar },
68
+ expandTabBar: { expanded.wrappedValue = .tabBar },
69
+ setToolbarHeight: setToolbarHeight
70
+ )
71
+ }
72
+
73
+ let expandToolbar = { opened.wrappedValue = .toolbar }
74
+ let expandTabBar = { opened.wrappedValue = .tabBar }
75
+ if opened.wrappedValue == .tabBar && fits(backOnlyHeight, tabBar) {
76
+ return DuoBarCompression(
77
+ toolbarCompressed: true,
78
+ tabBarCompressed: false,
79
+ toolbarHidden: true,
80
+ expandToolbar: expandToolbar,
81
+ expandTabBar: expandTabBar,
82
+ onTabSelected: { opened.wrappedValue = nil },
83
+ setToolbarHeight: setToolbarHeight
84
+ )
85
+ }
86
+ return DuoBarCompression(
87
+ toolbarCompressed: opened.wrappedValue != .toolbar,
88
+ tabBarCompressed: true,
89
+ expandToolbar: expandToolbar,
90
+ expandTabBar: expandTabBar,
91
+ setToolbarHeight: setToolbarHeight
92
+ )
93
+ }
94
+ }
95
+
96
+ private struct DuoBarCompressionKey: EnvironmentKey {
97
+ static let defaultValue = DuoBarCompression()
98
+ }
99
+
100
+ extension EnvironmentValues {
101
+ var duoBarCompression: DuoBarCompression {
102
+ get { self[DuoBarCompressionKey.self] }
103
+ set { self[DuoBarCompressionKey.self] = newValue }
104
+ }
105
+ }