@momo-kits/native-kits 0.300.3-debug → 0.300.4-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 (218) hide show
  1. package/CLAUDE.md +51 -7
  2. package/README.md +2 -2
  3. package/compose/build.gradle.kts +10 -2
  4. package/compose/build.gradle.kts.backup +8 -0
  5. package/compose/compose.podspec +1 -3
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +4 -3
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
  8. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -14
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/application/DesignSystemKits.kt +62 -0
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +351 -111
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +1 -1
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +55 -22
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -1
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +2 -1
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +20 -15
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +1 -2
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +306 -0
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +10 -0
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +6 -2
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +6 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +197 -60
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +132 -83
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +54 -62
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +97 -0
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +377 -0
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +122 -24
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +34 -14
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +3 -3
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +113 -26
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +2 -2
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HelpCenterRequest.kt +37 -0
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/ToolkitRequests.kt +8 -0
  62. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
  63. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +20 -0
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +13 -0
  65. package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/HelpCenterRequestTest.kt +44 -0
  66. package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/ToolkitRequestsTest.kt +15 -0
  67. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +3 -1
  68. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
  69. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +138 -0
  70. package/gradle/libs.versions.toml +3 -14
  71. package/gradle/wrapper/gradle-wrapper.properties +0 -2
  72. package/gradle.properties +3 -5
  73. package/ios/Application/ApplicationEnvironment.swift +24 -6
  74. package/ios/Application/DesignSystemKits.swift +100 -0
  75. package/ios/Application/DesignSystemKitsPayload.swift +68 -0
  76. package/ios/Application/MaxApi.swift +30 -3
  77. package/ios/Avatar/Avatar.swift +4 -3
  78. package/ios/Badge/Badge.swift +6 -4
  79. package/ios/Badge/BadgeDot.swift +1 -1
  80. package/ios/Badge/BadgeRibbon.swift +8 -2
  81. package/ios/Button/Button.swift +15 -12
  82. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  83. package/ios/Carousel/Carousel.swift +29 -12
  84. package/ios/Checkbox/Checkbox.swift +7 -3
  85. package/ios/Chip/Chip.swift +5 -4
  86. package/ios/Collapse/Collapse.swift +70 -19
  87. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  88. package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
  89. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  90. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  91. package/ios/Divider/Divider.swift +37 -4
  92. package/ios/Extensions/Color++.swift +38 -9
  93. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  94. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  95. package/ios/Icon/Icon.swift +21 -23
  96. package/ios/Icon/Icons.swift +1329 -0
  97. package/ios/IconButton/IconButton.swift +15 -7
  98. package/ios/Image/Image.swift +7 -23
  99. package/ios/Information/Information.swift +27 -9
  100. package/ios/Input/ErrorView.swift +2 -2
  101. package/ios/Input/Input.swift +171 -148
  102. package/ios/Input/InputPhoneNumber.swift +10 -9
  103. package/ios/Input/InputSearch.swift +107 -78
  104. package/ios/Input/InputTextArea.swift +133 -148
  105. package/ios/InputDropDown/InputDropDown.swift +10 -7
  106. package/ios/InputMoney/InputMoney.swift +32 -11
  107. package/ios/InputOTP/InputOTP.swift +22 -12
  108. package/ios/Layout/Card.swift +50 -0
  109. package/ios/Layout/GridContext.swift +187 -3
  110. package/ios/Layout/Item.swift +5 -4
  111. package/ios/Layout/ScreenLayout.swift +415 -0
  112. package/ios/Layout/Section.swift +4 -100
  113. package/ios/Loader/Loader.swift +5 -9
  114. package/ios/Lottie/LottieView.swift +14 -2
  115. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +169 -29
  116. package/ios/Navigation/BottomTab/BottomTab.swift +332 -0
  117. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +18 -7
  118. package/ios/Navigation/BottomTab/DuoBarCompression.swift +104 -0
  119. package/ios/Navigation/BottomTab/DuoTabBar.swift +329 -0
  120. package/ios/{Application → Navigation/Component}/FloatingButton.swift +43 -54
  121. package/ios/Navigation/Component/Header.swift +545 -0
  122. package/ios/Navigation/Component/HeaderBackground.swift +154 -0
  123. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  124. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  125. package/ios/Navigation/Component/HeaderRight.swift +425 -0
  126. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  127. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  128. package/ios/Navigation/Component/SideBar.swift +183 -0
  129. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +8 -8
  130. package/ios/Navigation/HostBackGestureBridge.swift +78 -0
  131. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +9 -2
  132. package/ios/Navigation/Navigation.swift +204 -0
  133. package/ios/Navigation/NavigationContainer.swift +195 -0
  134. package/ios/{Application/Navigation → Navigation}/Navigator.swift +207 -107
  135. package/ios/Navigation/StackScreen.swift +930 -0
  136. package/ios/Navigation/StackTransition.swift +400 -0
  137. package/ios/Navigation/SwipeBackHaptic.swift +33 -0
  138. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  139. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  140. package/ios/Pagination/PaginationScroll.swift +2 -2
  141. package/ios/Popup/PopupDisplay.swift +72 -135
  142. package/ios/Popup/PopupInput.swift +10 -13
  143. package/ios/Popup/PopupLayout.swift +138 -0
  144. package/ios/Popup/PopupNotify.swift +59 -69
  145. package/ios/Popup/PopupPromotion.swift +31 -47
  146. package/ios/PopupView/FullscreenPopup.swift +13 -19
  147. package/ios/PopupView/PopupView.swift +1 -1
  148. package/ios/PopupView/Utils++.swift +0 -8
  149. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  150. package/ios/Radio/Radio.swift +2 -1
  151. package/ios/Rating/Rating.swift +9 -1
  152. package/ios/Resources/dot_loading.json +558 -0
  153. package/ios/Resources/lottie_circle_loader.json +1 -0
  154. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  155. package/ios/Skeleton/Skeleton.swift +22 -27
  156. package/ios/Slider/Slider.swift +16 -18
  157. package/ios/Stepper/Stepper.swift +7 -3
  158. package/ios/Steps/Steps.swift +8 -6
  159. package/ios/SuggestAction/SuggestAction.swift +5 -0
  160. package/ios/Swipeable/SwipeCell.swift +10 -6
  161. package/ios/Switch/Switch.swift +22 -20
  162. package/ios/TabView/TabView.swift +102 -21
  163. package/ios/Tag/Tag.swift +15 -4
  164. package/ios/Template/Logo/Logo.swift +2 -1
  165. package/ios/Template/TrustBanner/TrustBanner.swift +109 -55
  166. package/ios/Title/Title.swift +6 -17
  167. package/ios/Tooltip/Tooltip.swift +29 -14
  168. package/ios/Typography/FontScale.swift +17 -14
  169. package/ios/Typography/Text.swift +156 -20
  170. package/ios/Typography/Typography.swift +2 -2
  171. package/ios/Uploader/Uploader.swift +3 -1
  172. package/ios/native-kits.podspec +2 -3
  173. package/ios-demo/MoMoUIKitsDemo.podspec +1 -3
  174. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  175. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  176. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
  177. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  178. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  179. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
  180. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
  181. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
  182. package/package.json +1 -1
  183. package/publish.sh +19 -2
  184. package/scripts/gen-ios-icons.mjs +36 -0
  185. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
  186. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
  187. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
  188. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
  189. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
  190. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
  191. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
  192. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
  193. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
  194. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
  195. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
  196. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
  197. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -18
  198. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
  199. package/ios/Application/Components.swift +0 -270
  200. package/ios/Application/ComposeApi.swift +0 -22
  201. package/ios/Application/HeaderRight.swift +0 -306
  202. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  203. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  204. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  205. package/ios/Application/Navigation/NavigationContainer.swift +0 -213
  206. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  207. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  208. package/ios/Application/Navigation/component/Header.swift +0 -178
  209. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  210. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  211. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  212. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  213. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  214. package/ios/Application/Screen.swift +0 -250
  215. package/ios/Application/StackScreen.swift +0 -492
  216. package/ios/Typography/FontScaleStore.swift +0 -58
  217. package/ios/Typography/KitDesignSystem.swift +0 -17
  218. /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,21 @@ 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 {
39
+ let overplayId: UUID
40
40
  let header: BottomSheetHeader
41
41
  let isSurface: Bool
42
42
  let barrierDismissible: Bool
43
+ var draggable: Bool = true
44
+ /// Keeps the content above the home indicator (Compose `AppNavigationBar` padding).
45
+ var useBottomInset: Bool = true
46
+ var footer: (() -> AnyView)? = nil
43
47
  let content: () -> Content
44
48
 
45
49
  @EnvironmentObject private var navigator: Navigator
46
50
  @Environment(\.appTheme) private var theme
47
51
  @Environment(\.applicationEnvironment) private var appEnv
52
+ @Environment(\.screenLayout) private var layout
48
53
 
49
54
  /// Vertical translation of the sheet; starts off-screen and animates to 0.
50
55
  @State private var sheetOffset: CGFloat = UIScreen.main.bounds.height
@@ -52,6 +57,7 @@ struct BottomSheetView<Content: View>: View {
52
57
  @State private var backgroundAlpha: CGFloat = 0
53
58
  @State private var sheetHeight: CGFloat = 0
54
59
  @State private var didAppear = false
60
+ @State private var isClosing = false
55
61
  @State private var keyboardHeight: CGFloat = 0
56
62
  /// Live drag offset, isolated from `sheetOffset` so a touch-move only invalidates
57
63
  /// this gesture-scoped value instead of forcing the whole body (including
@@ -66,8 +72,35 @@ struct BottomSheetView<Content: View>: View {
66
72
  /// steady-state (fully open) scrim peaks at 0.6, matching ModalScreen's peak.
67
73
  private let maxDynamicAlpha: CGFloat = 0.3
68
74
 
69
- private var screenHeight: CGFloat { UIScreen.main.bounds.height }
70
- private var bottomInset: CGFloat { safeAreaBottomInset() }
75
+ // UIScreen stays on the outer display on iPhone Duo, even when unfolded
76
+ private var screenHeight: CGFloat { isDuo ? layout.height : UIScreen.main.bounds.height }
77
+
78
+ private var isDuo: Bool { layout.isDuoDevice }
79
+ // iPhone Duo inner display: center the sheet instead of spanning the width
80
+ private var isDuoCentered: Bool { isDuo && layout.width >= 600 }
81
+
82
+ /// Keeps the portrait width in landscape, like system sheets.
83
+ private var duoCenteredWidth: CGFloat {
84
+ min(
85
+ min(layout.width, layout.height) - Spacing.L * 2,
86
+ layout.width - max(layout.insets.leading, layout.insets.trailing, Spacing.L) * 2
87
+ )
88
+ }
89
+
90
+ /// Landscape side buttons span the full height, so the content is inset; in portrait the
91
+ /// side status bar only covers the top corner.
92
+ private var sideInsetLeading: CGFloat {
93
+ isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.leading : 0
94
+ }
95
+
96
+ private var sideInsetTrailing: CGFloat {
97
+ isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.trailing : 0
98
+ }
99
+
100
+ private var maxHeight: CGFloat {
101
+ isDuo ? layout.height - max(layout.duoTopInset, Spacing.XL) : screenHeight - 90
102
+ }
103
+ private var bottomInset: CGFloat { useBottomInset ? safeAreaBottomInset() : 0 }
71
104
  /// While the keyboard is up, it already covers the home-indicator area, so it
72
105
  /// replaces (rather than adds to) the usual bottom-inset filler.
73
106
  private var effectiveBottomInset: CGFloat { keyboardHeight > 0 ? keyboardHeight : bottomInset }
@@ -110,7 +143,7 @@ struct BottomSheetView<Content: View>: View {
110
143
  .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
111
144
  guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
112
145
  withAnimation(.easeOut(duration: 0.25)) {
113
- keyboardHeight = UIScreen.main.bounds.height - endFrame.origin.y
146
+ keyboardHeight = screenHeight - endFrame.origin.y
114
147
  }
115
148
  }
116
149
  .onAppear {
@@ -128,33 +161,48 @@ struct BottomSheetView<Content: View>: View {
128
161
 
129
162
  private var sheet: some View {
130
163
  VStack(spacing: 0) {
131
- // Drag handle + header (the draggable region).
132
164
  VStack(spacing: 0) {
133
- Spacer().frame(height: 8)
134
- Capsule()
135
- .fill(Colors.black06)
136
- .frame(width: 40, height: 4)
137
- Spacer().frame(height: 4)
138
- headerView
139
- .frame(height: 56)
140
- .padding(.horizontal, Spacing.M)
141
- }
142
- .frame(maxWidth: .infinity)
143
- .frame(height: 72)
144
- .contentShape(Rectangle())
145
- .gesture(dragGesture)
165
+ // Drag handle + header (the draggable region).
166
+ VStack(spacing: 0) {
167
+ Spacer().frame(height: 8)
168
+ Capsule()
169
+ .fill(Colors.black06)
170
+ .frame(width: 40, height: 4)
171
+ Spacer().frame(height: 4)
172
+ headerView
173
+ .frame(height: 56)
174
+ .padding(.horizontal, Spacing.M)
175
+ .padding(.leading, sideInsetLeading)
176
+ .padding(.trailing, sideInsetTrailing)
177
+ }
178
+ .frame(maxWidth: .infinity)
179
+ .frame(height: 72)
180
+ .contentShape(Rectangle())
181
+ .gesture(dragGesture, including: draggable ? .all : .subviews)
146
182
 
147
- Rectangle()
148
- .fill(theme.colors.border.default)
149
- .frame(height: 1)
183
+ Rectangle()
184
+ .fill(theme.colors.border.default)
185
+ .frame(height: 1)
150
186
 
151
- content()
187
+ content()
188
+ .padding(.leading, sideInsetLeading)
189
+ .padding(.trailing, sideInsetTrailing)
190
+ // a tall sheet keeps its header (and footer) and gives the content what is left
191
+ .layoutPriority(-1)
192
+
193
+ if let footer {
194
+ BottomSheetFooter(content: footer)
195
+ }
196
+ }
197
+ // The filler stays outside the cap, which shrinks by it instead: the sheet still ends at
198
+ // `maxHeight`, and the iOS 15 fallback never measures the keyboard as content.
199
+ .modifier(CapHeight(maxHeight: maxHeight - effectiveBottomInset))
152
200
 
153
- backgroundColor
201
+ (footer != nil ? theme.colors.background.surface : backgroundColor)
154
202
  .frame(height: effectiveBottomInset)
155
203
  }
156
- .frame(maxWidth: .infinity)
157
- .frame(alignment: .bottom)
204
+ .frame(maxWidth: isDuoCentered ? duoCenteredWidth : .infinity)
205
+ .clipped()
158
206
  .background(
159
207
  GeometryReader { geo in
160
208
  Color.clear.onAppear { sheetHeight = geo.size.height }
@@ -198,14 +246,16 @@ struct BottomSheetView<Content: View>: View {
198
246
  // same bottom-anchored top edge — is still in flight doesn't fight a
199
247
  // moving target. Without this, the live drag value chases an animated
200
248
  // one, producing a couple of corrective jumps before it settles.
201
- if sheetOffset != 0 {
249
+ // A closing sheet keeps going down.
250
+ if sheetOffset != 0 && !isClosing {
202
251
  sheetOffset = 0
203
252
  }
204
253
  }
205
254
  .updating($dragTranslation) { value, state, _ in
206
- state = max(value.translation.height, 0)
255
+ state = isClosing ? 0 : max(value.translation.height, 0)
207
256
  }
208
257
  .onEnded { value in
258
+ guard !isClosing else { return }
209
259
  let finalOffset = max(value.translation.height, 0)
210
260
  // Capture into persisted state BEFORE @GestureState resets to 0,
211
261
  // avoiding a one-frame snap back to rest before close()'s own
@@ -233,6 +283,8 @@ struct BottomSheetView<Content: View>: View {
233
283
  /// Mirrors Compose's `closeEvent`: slide out over 200ms, then fade the scrim
234
284
  /// out over 100ms only after the slide finishes, then dismiss.
235
285
  private func close() {
286
+ guard !isClosing else { return }
287
+ isClosing = true
236
288
  withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.2)) {
237
289
  sheetOffset = max(sheetHeight, screenHeight)
238
290
  }
@@ -242,12 +294,100 @@ struct BottomSheetView<Content: View>: View {
242
294
  }
243
295
  }
244
296
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
245
- navigator.finishOverplayDismiss()
297
+ navigator.finishOverplayDismiss(id: overplayId)
246
298
  }
247
299
  }
248
300
  }
249
301
 
302
+ /// Compose's `heightIn(max:)`. `.frame(maxHeight:)` grows to the cap whenever the proposal is
303
+ /// larger, leaving a gap above the header; this sizes the sheet to its content and only caps it
304
+ /// when taller.
305
+ private struct CapHeight: ViewModifier {
306
+ let maxHeight: CGFloat
307
+
308
+ func body(content: Content) -> some View {
309
+ if #available(iOS 16.0, *) {
310
+ CapHeightLayout(maxHeight: maxHeight) { content }
311
+ } else {
312
+ content.modifier(CapHeightFallback(maxHeight: maxHeight))
313
+ }
314
+ }
315
+ }
316
+
317
+ /// iOS 15 has no `Layout`: the sheet hugs its ideal height until that outgrows the cap, then is
318
+ /// held at the cap. A capped sheet can't read its ideal height (that needs a second copy of the
319
+ /// content), so it is re-measured only when the cap grows, e.g. once the keyboard goes down.
320
+ private struct CapHeightFallback: ViewModifier {
321
+ let maxHeight: CGFloat
322
+ @State private var capped = false
323
+ @State private var idealHeight: CGFloat = 0
324
+
325
+ func body(content: Content) -> some View {
326
+ content
327
+ // both modifiers stay in place whatever `capped` is, so the content keeps its identity
328
+ .fixedSize(horizontal: false, vertical: !capped)
329
+ .background(
330
+ GeometryReader { proxy in
331
+ Color.clear
332
+ .onAppear { measure(proxy.size.height) }
333
+ .onChange(of: proxy.size.height) { measure($0) }
334
+ }
335
+ )
336
+ .frame(height: capped ? maxHeight : nil, alignment: .top)
337
+ .onChange(of: maxHeight) { cap in
338
+ if capped {
339
+ // the ideal height was over the old cap, so only a taller cap can release it
340
+ if cap > idealHeight { capped = false }
341
+ } else if idealHeight > cap {
342
+ capped = true
343
+ }
344
+ }
345
+ }
346
+
347
+ private func measure(_ height: CGFloat) {
348
+ guard !capped else { return }
349
+ idealHeight = height
350
+ if height > maxHeight { capped = true }
351
+ }
352
+ }
353
+
250
354
  @available(iOS 16.0, *)
355
+ private struct CapHeightLayout: Layout {
356
+ let maxHeight: CGFloat
357
+
358
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
359
+ guard let subview = subviews.first else { return .zero }
360
+ let cap = min(maxHeight, proposal.height ?? .infinity)
361
+ // ideal first: a scroll view would otherwise fill whatever height it is offered
362
+ let ideal = subview.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil))
363
+ return subview.sizeThatFits(ProposedViewSize(width: proposal.width, height: min(ideal.height, cap)))
364
+ }
365
+
366
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
367
+ subviews.first?.place(at: bounds.origin, proposal: ProposedViewSize(bounds.size))
368
+ }
369
+ }
370
+
371
+ /// Mirrors Compose's `BottomSheetFooter`: a surface band with a soft shadow cast on the content.
372
+ private struct BottomSheetFooter: View {
373
+ let content: () -> AnyView
374
+
375
+ @Environment(\.appTheme) private var theme
376
+
377
+ var body: some View {
378
+ content()
379
+ .frame(maxWidth: .infinity)
380
+ .padding(.vertical, Spacing.S)
381
+ .background(theme.colors.background.surface)
382
+ .overlay(alignment: .top) {
383
+ LinearGradient(colors: [.clear, Color.black.opacity(0.05)], startPoint: .top, endPoint: .bottom)
384
+ .frame(height: 6)
385
+ .offset(y: -6)
386
+ .allowsHitTesting(false)
387
+ }
388
+ }
389
+ }
390
+
251
391
  private func safeAreaBottomInset() -> CGFloat {
252
392
  let bottomInset = UIApplication.shared.connectedScenes
253
393
  .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
@@ -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
+ }